diff --git a/.gitignore b/.gitignore index eed72eb7..eea0a2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ tmp_* *.swo /resources/renderers/installed/ /test-results.xml +.DS_Store diff --git a/docs/cinematics.md b/docs/cinematics.md index 15b05778..181061e1 100644 --- a/docs/cinematics.md +++ b/docs/cinematics.md @@ -191,7 +191,7 @@ cutscene-local ID and are not party members, enemies, persistent NPC records, or saved world entities. The declared staged-actor fields are `id`, `sprite`, `asset`, `x`, `y`, -`facing`, `visible`, `collision`, and directional `sprites`. Collision +`facing`, `visible`, `collision`, directional `sprites`, and optional `sprites2d`. Collision defaults to `false`. `stage_actor`, `show_actor`, `hide_actor`, and `remove_actor` alter the temporary cast. `move_route` accepts `subject => staged_actor` with `actorId`; its timing and cardinal step shape @@ -207,6 +207,70 @@ does not affect field collision. Staged actors render through the field camera. Map transfer, normal completion, authored skip, and controlled failure remove temporary cast state. +`sprites2d` accepts the Player's existing four-direction configuration, including +`mode => sheet`, or a single pose with `asset`, `width`, `height`, optional +`anchor` (only `bottom_center`), `layer` (0..999), and optional integer +`sourceRect => ['x' => ..., 'y' => ..., 'width' => ..., 'height' => ...]`. +The terminal `sprite` or `asset` remains required. Successful staged movement +advances the existing PHP walk animation; facing-only, blocked movement, hiding +and idle stop it. Definitions do not infer movement from input. +Graphical identity is `staged:`; only that actor's terminal contribution is +excluded when its graphical representation is emitted. Unconfigured cast keeps +its terminal representation. New metadata is runtime-validatable; dedicated +Editor graphical-asset controls have not yet been added. + +An optional `subject => ['kind' => 'player']` or +`subject => ['kind' => 'npc', 'id' => '...']` binds a staged visual to the real +subject's transform. Bound cast cannot supply another position/facing or route: +move the real subject, retaining one motion authority. Optional `suppress` +subject references support paired visuals without changing collision, authored +visibility, NPC lookup or wandering eligibility. `replace => true` explicitly +changes an existing staged visual while preserving the subject lease. + +Leases are scoped to object, map and session identity. Normal completion keeps +intentional transforms; failure, transfer and scene stop restore temporary ones +and release visual suppression. Legal skip restores before finalizer writes; +successful finalizer `move_player` commits are not undone by a later failure. +Stage `commitSubjectTransforms()` provides the explicit transform boundary. +Re-entry and same-ID NPCs on other maps do not inherit stale suppression. + +### Captured-entry walking and return + +The existing `move_route` command accepts exactly one of `steps`, `waypoints`, +or `retrace`. Relative `steps` remain unchanged. Real Player/NPC waypoints are +a non-empty list of integral map coordinates; an omitted axis retains that +subject's coordinate when the leg begins. Each authored waypoint leg uses a +deterministic cardinal search (up, down, left, right), limited to the map and +65,536 visited cells. Add closer waypoints if that budget is insufficient. +Terrain, NPCs and collidable staged actors block planning; the player also +blocks NPC routes. Each executed unit still uses ordinary movement collision +and field gates. A newly blocked unit fails with context, never silently +reroutes, bypasses a gate or teleports. Staged actors retain relative routes; +bound visuals follow their real subject rather than owning another route. + +```php +['type' => 'move_route', 'subject' => 'player', 'remember' => 'approach', + 'waypoints' => [['y' => 5], ['x' => 12, 'y' => 5]], 'secondsPerStep' => 0.15], +// Perform the scene, returning to the approach endpoint before retracing. +['type' => 'move_route', 'subject' => 'player', 'retrace' => 'approach', + 'secondsPerStep' => 0.15], +``` + +`remember` is a unique identifier within one cinematic event session, not a +save variable. It records successful units and the actual entry facing/idle +sprite. `retrace` consumes that completed history once, walks the exact inverse +units with collision checks, then restores facing without changing the reached +position. It requires the same real subject object, map, stage generation and +recorded endpoint. Missing, incomplete, duplicate, consumed or stale histories +fail closed. Transfers, failure, skip and completion cannot leak history into +another session. Replacing a visual does not replace the movement subject. + +Only one route may move a subject at a time, including parallel lanes. Normal +motion advances at most one visible unit per update; reduced motion executes +the same collision-checked units without their display delays. Cinematic routes +capture real-subject rollback leases even when no replacement artwork exists. +Rollback is failure recovery, not the successful walking-return presentation. + ## Camera operations The `camera` command delegates movement and clamping to `Rendering\Camera`. @@ -227,6 +291,64 @@ leave ordinary play with a stranded detached camera. ## Presentation and audio +### Current graphical boundary + +The first graphical foundation is implemented locally: cinematic field ownership +retains terrain and Player graphics and includes optional staged sprites. Field +effects, narration and opaque covers have explicit precedence above world +sprites; an initially hidden field is covered before the first yield. Normal +scene eligibility still excludes menu/battle presentation. This is not complete +graphical cinematic parity. Scoped real-subject visual takeover and transform +recovery are implemented and regression-tested; bound staged +hide/show keeps the underlying ordinary art suppressed without rewriting map +eligibility. Captured-entry waypoint walking and recorded inverse returns are +implemented on the shared movement runner. Application quit and caught-crash +cleanup stop owned scenes before audio/renderer teardown; cancellation attempts +every parallel lane even if an operation or presentation reset fails. A temporary +battle suspends the calling scene instead of destroying its continuation; +return resumes it, while abandonment disposes it. These are Engine lifecycle +guarantees for handled shutdown, not recovery from an OS kill or power loss. +Representative Game rescue staging and native acceptance remain separate gates. + +The existing renderer field contract supports multiple sheet-backed sprites, +explicit layers, terrain and opaque text overlays in one complete snapshot. +Engine must continue emitting the world in each snapshot; omitted collections +are cleared. This path uses integral cell positions and bottom-centre anchors, +not smooth pixel movement or per-instance opacity. The separate graphical +battle canvas cannot be mixed with field sprites, tiles or text. + +The [integration roadmap](rendering/integration-roadmap.md) owns the graphical +cinematic work queue. Extend the existing interpreter, provider and lifecycle +boundaries rather than introducing another scripting runtime, changing quest +flags to hide a visual, or retaining omitted actors secretly in the renderer. + +### Production extension boundaries + +The first scene is a bounded delivery, not the final cinematic model. Keep real +subject identity and movement independent of the displayed pose: changing from +an idle image to a sheet animation, paired pose or effect must not require +recreating a gameplay entity or duplicating its movement authority. Walking +animation is one playback policy, not the universal model for character actions. + +Extend the existing event session and cancellable operations for presentation +timing and cleanup, rather than adding separate scene controllers. Effects need +independent ownership so one parallel operation cannot overwrite or clear +another. Voice playback will need per-line identity and playback control, +completion/skip behavior, subtitles and a valid silent or unavailable-audio path; +dialogue progression must not assume a fixed recording length. Suspension, +cancellation, transfer and shutdown must also govern external audio playback, +not merely stop advancing the interpreter's clock. + +These are implementation constraints, not delivered capabilities. Currently the +field presentation manager has one animation slot, staged graphics support +explicit replacement and optional walk playback, and sound effects are fire-and-forget +without a caller-owned playback handle. Resolve those boundaries as their +consumers are implemented; do not encode scene-specific workarounds or introduce +an unused animation/voice framework. Keep PHP responsible for scene semantics +and renderer-independent state; renderers consume presentation, not story logic. + +### Existing presentation commands + Cinematics may compose existing Engine presentation systems with: - `field_animation` at a subject, map position, or screen position; diff --git a/docs/effect-animation.md b/docs/effect-animation.md new file mode 100644 index 00000000..72a38946 --- /dev/null +++ b/docs/effect-animation.md @@ -0,0 +1,219 @@ +# Effect animation and authoring - authoritative plan + +This plan unifies the engine's animation systems into one timeline model for +magic, skill, item and field effects, gives that model a graphical (GPUI) +presentation alongside its terminal one, and builds the authoring tooling in +the editor. It feeds the integration roadmap's G2 (battler animation and +combat feedback) and G3 (graphical summon presentation) gates rather than +replacing them. Related plans: [layered-tilemaps.md](layered-tilemaps.md); related contracts: +[summons.md](summons.md), [cinematics.md](cinematics.md), +[rendering/sprite-sheets.md](rendering/sprite-sheets.md). + +## Principles + +1. **The terminal always represents the whole effect.** Every animation has a + glyph presentation that is the authoring truth; GPUI renders the same + timeline with richer fidelity (styled runs, images, opacity) as sugar. + Layer + glyph = identity, colour = attention, crop/asset = fidelity, as + established by the layered-tilemaps plan. +2. **One playback model.** The summon timeline (fps, tracks, keyframes, cues, + compiled segments, a non-blocking session that separates elapsed time and + cue traversal from rendering) is the engine's single effect-animation + runtime. Spectacles are data, never bespoke runtimes. +3. **Presentation never changes combat identity or outcomes.** An effect that + cannot render, on any presentation, still applies its gameplay result at + its authored moment. Reduced motion always has a counterpart: result, + final frame, cues, no motion. +4. **Assets are the developer's; the engine advises.** Timelines may reference + images (sprite sheets, single stills). The engine publishes preferred + shapes and dimensions, loads what exists, fails an asset only when + missing, wrong type or corrupt, and renders best-effort otherwise. + Authored metadata never restates what a file knows about itself. +5. **Preview is the runtime.** The editor previews through the engine's own + playback session, as the summon preview already does. Anything the preview + fires, battle fires; anything battle honors, the preview shows. + +## Current state (audited 2026-09-20) + +Two authored animation systems exist, plus one orphan: + +- **Cell-frame animations** (`assets/Data/animations.php`): id, name, anchor + position (center/head/feet/screen), frames of positioned single glyphs, + cues (sound, flash). Played by a blocking `AnimationPlayer` squeezed into a + fixed fraction of the battle turn. Bound to skills by name equality with a + two-animation fallback; skills carry no animation reference. The shipping + game has exactly two entries. +- **Summon timelines** (`assets/Cutscenes/Summons//`): formatVersion, + authored fps, lengthFrames, typed tracks (glyph/text/flash/shake) of + keyframes (frame, duration, position, multi-line ASCII content, assetId, + colour, visibility, zIndex, blendMode, easing, payload), cues, a compiler + producing sorted playback segments with a source hash, and + `SummonPlaybackSession` for non-blocking traversal. The editor authors and + previews these end to end through the engine session. +- **Battle-entry frames** (`Graphics/Animations/battle-transition.txt`): + a separate frame-file path used only by `BattleStartState`. + +Defects the plan corrects (with their locations): + +1. No GPUI path for any battle animation or summon: five + `usesGraphicalField()` early-returns in `BattleFieldWindow` silently drop + them. The one precedent for motion reaching GPUI is + `GraphicalBattleFeedback`: PHP re-emits per-frame canvas state. +2. `effectTiming` is validated, authored and previewed but inert in battle: + `ActionExecutionState::playSummonCutscene()` omits the player's `$onCue` + callback, so damage always lands after playback regardless of mode. +3. Flash cues (`flashColor`, `flashDurationFrames`) have no consumer + anywhere; they are authored in both shipping animations and editable in + the editor. +4. Skills and items carry no animation reference; binding is name equality. +5. Compiled `zIndex` and `clearBeforeDraw` are ignored by the terminal + renderer; `blendMode` and `easing` are stored and never consumed. +6. Reduced motion is honored at fifteen sites but not by the two loudest + motion sources: action animations and summon cutscenes. +7. Animation and summon libraries re-read their files inside the turn loop. +8. Timeline positions are absolute coordinates against a ~133x28 terminal, + blocking resolution independence. +9. Orphan asset: `assets/Data/Animations/explosion01/` has no loader. + +## The unified model: effect timelines + +One authored format, the summon timeline generalized: + +- **Library**: `assets/Animations//.timeline.php` (+ optional + `.data.php` for identity/metadata), compiled and cached exactly as + summons are today. Summon timelines remain where they are; they become + consumers of the same model rather than a special case. +- **Tracks**: the existing types, made honest - `glyph` (multi-line ASCII + content, the terminal truth), `text`, `flash` (screen or target flash with + colour and duration; terminal renders a brief recolour pulse, GPUI a + translucent canvas rectangle), `shake`, and new `image` (an asset path + with optional sprite-sheet frame progression; GPUI-only fidelity, invisible + in the terminal by design, like decoration layers). +- **Anchored coordinates** replace absolute ones: every keyframe position is + an offset from an authored anchor - `target`, `caster`, `screen`, or a + named slot - resolved at play time by the presentation (terminal battler + positions, or GPUI arena slot geometry). This fixes resolution dependence + for both presentations at once. Existing absolute summon timelines keep + playing through a compatibility anchor (`screen` at the legacy offset) + until migrated. +- **Cues**: the existing vocabulary (`applyEffect`, `playSound`, + `showMessage`, `flash`, `shake`), all honored at runtime. +- **References, not names**: skills, items and (later) states carry an + explicit animation id, selected in the editor through a reference picker, + never typed. Name-matching remains only as a deprecation-period fallback. + +## Phases + +### Phase 0 - Make what exists honest + +No new formats; the authored data that already exists starts meaning what it +says. + +1. Fire summon cues in battle: pass the cue callback, honor `effectTiming` + (`end`/`cue`/`frame`), land the gameplay effect at its authored moment. + Preview and runtime stop disagreeing. +2. Implement flash cues in the terminal (target/screen recolour pulse for + the authored duration) so the two shipping animations' cues render. +3. Honor reduced motion in both players: skip to the final frame, fire every + cue in order, apply the effect, no motion. +4. Honor compiled `zIndex` and `clearBeforeDraw` in the terminal summon draw. +5. Cache animation and summon libraries for the battle's lifetime; stop + re-reading files per action. +6. Add the explicit animation reference to skills and items (engine schema + + editor picker), keeping the name fallback with a validator notice. + +### Phase 1 - One runtime + +1. Generalize the summon compiler/session into the effect-timeline library + (`assets/Animations/`), with anchored coordinates. +2. Replace the blocking `AnimationPlayer` path with a non-blocking session + driven from the battle update loop; turn pacing waits on session + completion or the effect cue instead of squeezing frames into a fixed + turn slice. Authored fps is honored everywhere, as summons already do. +3. Migrate the two cell-frame animations to timelines (a cell frame is a + one-glyph content grid); retire the cell-frame runtime after a + deprecation window. The editor migrates its bespoke animation database to + the schema-driven record path the summons already use. +4. Fold the battle-entry frame file into a timeline played by the same + session. +5. Migrate `assets/Data/Animations/explosion01/` (currently orphaned): each + of its text files is one frame, becoming one glyph-track keyframe of a + timeline. + +### Phase 2 - GPUI parity (feeds gates G2/G3) + +1. A presentation adapter renders a playing session's frame to canvas + primitives, following the `GraphicalBattleFeedback` precedent of per-frame + re-emission: glyph/text tracks become `CanvasTextLayer` runs (with + glyph effects and opacity available), flash tracks become translucent + `CanvasRectangle` fills, image tracks become `CanvasImage`s with + sprite-sheet `sourceRect` progression, shake becomes a bounded offset on + the affected layers (zero under reduced motion). No renderer or protocol + changes: the canvas is already a stateless per-frame description. +2. Remove the five `usesGraphicalField()` early-returns by routing terminal + and GPUI through the same session with two presenters. +3. Summons render graphically through the identical path - G3 is then a + content and acceptance gate, not new machinery. + +### Phase 3 - Authoring in the editor + +The summon timeline surface generalizes into the animation editor: + +1. Effect timelines join the Cutscenes-style authoring path: track list, + keyframe fields, cue lane, and the existing preview session controls + (play/pause/step/seek/boundaries/speed/loop) - all already built for + summons and reused, not duplicated. +2. Frame painting uses the canvas the editor already has: modal Paint mode, + the brush colour system, and the character map author each keyframe's + glyph content in place, with onion-skin ghosts of the previous and next + keyframes and the existing target silhouette for anchor preview. +3. A battle-context preview stage: battler silhouettes at real arena + anchors, so an effect is authored against the geometry it will play on. +4. Cue authoring gains the pieces Phase 0 made real: flash parameters, + effect timing against the cue lane, per-track mute/solo for isolating a + layer while authoring. +5. Skill and item forms gain the animation reference picker, and Ctrl+G + follows the reference instead of the name. + +### Phase 4 - The rich 2D editor (direction, scoped separately) + +The long-term destination is a graphical authoring surface for the 2D +presentation. The staged route that reuses what exists: + +1. First, a **GPUI preview window from within the TUI editor**: the editor + launches the installed renderer exactly as the game does and presents the + playing timeline through the Phase 2 adapter. Authoring stays in the TUI; + the artist sees the true graphical result live. This is dogfooding the + engine's own renderer protocol, and it is small once Phase 2 exists. +2. Then, informed by that experience, the graphical editing surface itself - + selection, dragging keyframe positions on the canvas, scrubbing with + rendered frames. Its scope, toolkit and relationship to the TUI editor + (which remains the always-available surface) are decided when Phase 4 is + scoped, not preempted here. + +## Sequencing (decided 2026-09-20) + +This plan's Phase 0 and the layered-tilemaps Phase 0 run simultaneously. +After both Phase 0s, the layered-tilemaps implementation proceeds first; +this plan's Phases 1-4 follow it. + +## Decisions already made (do not relitigate) + +- The summon timeline model is the single effect-animation runtime; + spectacles are data, not bespoke runtimes. +- Every effect has a terminal presentation as its authoring truth; image + tracks are GPUI-only fidelity, mirroring decoration layers. +- Coordinates are anchor-relative, never absolute terminal positions. +- References are selected, never typed; name-binding is a deprecated + fallback. +- Preview goes through the engine's own session: preview/runtime parity is + a structural property, not a testing goal. +- Reduced motion, skip, and no-render paths always apply the gameplay + result; presentation never changes combat identity or outcomes. +- The cell-frame animation format retires after migration; the timeline is + the only authored format. +- Flash is a brief recolour pulse of the target or screen in the terminal, + and a translucent canvas fill in GPUI, for the authored duration. +- The G2 and G3 roadmap gates take this plan as their scoped brief. +- `explosion01` migrates into the timeline library: each text file is one + frame. diff --git a/docs/layered-tilemaps.md b/docs/layered-tilemaps.md new file mode 100644 index 00000000..465d5254 --- /dev/null +++ b/docs/layered-tilemaps.md @@ -0,0 +1,290 @@ +# Layered tilemaps - authoritative plan + +This plan replaces the engine's single-grid map model with authored map +layers, restores the HEREDOC authoring contract that the editor depends on, +and removes object-oriented map builders from game assets. Layered tilemaps +are an **engine capability**: every game built on Ichiloto gets this +functionality. Last Legend is the reference consumer and supplies the first +full migration; its game-owned vocabulary and layout policy live in its own +`docs/development/map-visual-language.md`. + +The plan was decided by the author; the sections below record those decisions +and the implementation phases. Related engine docs: [maps.md](maps.md), +[rendering/tile-batches.md](rendering/tile-batches.md). + +## Principles + +1. **The terminal always represents the whole game.** Everything a player must + know to play - geometry, collision, interactions, routes - is expressed in + glyphs the terminal displays. GPUI and any future renderer derive from the + text, never the other way around. +2. **Three channels, never overloaded:** + - **Layer + glyph = identity.** What a cell *is* comes from its character + and the layer it sits on. The same glyph may mean different things on + different layers (`/` can be a roof slope on a buildings layer and a + hanging blade on a fixtures layer) because each layer has its own + vocabulary. + - **Colour = attention.** Colour is spent sparingly, only on information + that needs the player's eye: encounter grass, water, information points, + event cues. Colour never carries tile identity - yellow terrain cannot + tell sand from savannah, and it should never have to. + - **Crop = fidelity.** Visual richness (checkered kitchen tiles, rugs, + wall paintings, isometric facades) lives in per-layer atlas crop tables, + keyed off authored glyphs. Fidelity is sugar on top; it costs the + terminal nothing and the terminal costs it nothing. +3. **The map text is the terminal display.** Gameplay layers contain exactly + what the terminal shows. No glyph in a gameplay layer may secretly mean + something other than what it displays. Presentation-only detail goes in + decoration layers, which the terminal never consults. +4. **RPG Maker is the model, adapted.** Flat 2D grids; passability belongs to + the tile definition (the collision dictionary), not the map; resolution is + topmost-tile-wins with a pass-through marker; the pseudo-isometric look is + tileset art, not map geometry. Hand-drawn 3/4-view ASCII facades already + encode the projection in text; GPUI crops render it. + +## Why the current model must change + +- **One grid carries three jobs.** `Camera::worldSpace`, the collision map and + the `tiles2d` crop lookup all derive from the same character array, so a + glyph must mean one thing per map, globally. In Last Legend this is why `x` + (windows *and* ridges) could never be mapped to a crop, and why the blade in + Kaelion's home sits on the room floor instead of hanging on the wall: the + wall cell's glyph was already spoken for. +- **Object-oriented map builders broke the editor contract.** Maps were + designed as HEREDOC strings precisely so the editor could round-trip them. + The editor canonicalises `.map.php` to a nowdoc the moment one cell is + painted (`ProjectMap::save()` → `buildMapPayload()`), destroying any builder + code and its colourising passes without warning - demonstrated live on + Last Legend's `waymeet-field-post`, whose painted cells also fell out of the + map's colour scheme because the colouring lived in the destroyed code. +- **A contract asymmetry exists.** The engine accepts `string|string[]` from + `.map.php` (`MapManager::parseMapLayer()`); the editor accepts `string` + only (`ProjectMap::fromDirectory()`). +- **The capacity for layers is already shipped and unused.** The renderer + protocol supports 64 tile batches at arbitrary z (`PresentationTileBatch`); + the engine emits exactly one, hardcoded `'terrain'` at −100. The event layer + already proves the authored-grid-per-layer idiom. + +## Authoring format + +Each map folder gains a `layers/` subdirectory. Each layer is one file: + +``` +..map.php - gameplay layer (nowdoc character grid) +..deco.php - decoration layer (nowdoc character grid) +``` + +`` is a two-digit integer prefix that determines stacking (bottom to +top); `` names the layer and may be changed later without +disturbing order. Example: + +``` +temple-of-the-listening-stone/ + temple-of-the-listening-stone.data.php + temple-of-the-listening-stone.event.php + layers/ + 01.terrain.map.php + 02.floors.deco.php + 03.buildings.map.php + 04.fixtures.map.php +``` + +- Discovery is by convention: glob `layers/*.{map,deco}.php`, sort on the + integer prefix. `.data.php` needs no layer declaration; the editor's + surgical `.data.php` rewrite path is untouched. +- Every layer file returns a literal `<<<'ICHILOTO_MAP'` nowdoc, same + dimensions as the base layer, validated like the event layer is today. + No classes, no function calls, no generated grids - enforced by validation. +- `.event.php` and `.data.php` are unchanged. +- Layer names and the standard stack for a given game (e.g. Last Legend's + terrain → buildings → fixtures) are game-owned conventions; the engine + imposes only the file format and ordering. Maps declare only the layers + they use; a simple map may be `01.terrain.map.php` alone. + +### Gameplay layers (`.map.php`) + +- Composed **topmost-wins** into the terminal display: for each cell, the + highest layer with a non-space glyph supplies the character. On layers above + the base, a plain space means "empty, show through." The composed grid is + what `Camera::worldSpace` holds; the terminal renders exactly this. +- Feed collision (below) and per-layer crop tables (below). + +### Decoration layers (`.deco.php`) + +Presentation-only, with three hard properties: + +1. **The terminal never sees them.** They contribute nothing to the composed + grid and nothing to collision. +2. **Their only output is crops.** Each has its own `tiles2d` symbol table and + emits its own GPUI batch at its stacking position. Their glyph namespace is + completely free - any character, reused anywhere - because nothing + gameplay-bearing can key off it. +3. **They are structurally barred from gameplay.** Validation rejects a + decoration layer's name in the collision dictionary and rejects any deco + glyph without a crop mapping. If a decoration ever becomes interactive, it + moves to a gameplay layer or an event; gameplay is never bolted onto a + decoration in place. + +This is how a kitchen gets checkered tiling, a living room a rug over plain +tile, and a hallway wooden boards - all within one house - while the terminal +shows clean blank floors and the colour budget stays untouched. + +## Collision + +Resolution is **topmost-occupied-wins with pass-through**, the RPG Maker rule +translated to glyphs: + +- For each cell, consult gameplay layers from the top down. Skip empty cells + (spaces on non-base layers) and pass-through cells. The first remaining + glyph decides the collision via its layer's dictionary. Layers below it are + never consulted. +- `CollisionType::PASS_THROUGH` is the `☆` equivalent: "skip me, consult the + layer below." An awning over encounter grass keeps its encounters; a bridge + over water is walkable. Pass-through tiles are also the future candidates + for rendering above the player (a batch above `WORLD 0`), as RPG Maker draws + its star tiles - a later, purely presentational extension. +- The game's `collisions.php` stays backward-compatible: a flat array applies + to all layers; optional sections keyed by layer name (`'terrain' => […]`, + `'fixtures' => […]`) give a glyph different meanings per layer. Unknown + glyphs still default to `SOLID`. +- Collision is always *derived* from the visible text through the dictionary - + never stored beside it. There are no per-layer collision grids. + +## GPUI presentation + +- `tiles2d` becomes per-layer symbol tables (shared or per-layer atlases). + Keys are bare glyphs, normalised exactly as `GraphicalTileDefinition` does + today - no style-aware lookup. +- `GraphicalTileCollector` emits one `PresentationTileBatch` per layer + (gameplay and decoration), z-ordered by the layer prefix, all below + `WORLD 0` (e.g. terrain −100, upward in steps). The protocol needs no + changes; the renderer binary needs no changes. Verified against the + renderer source (`gpui-renderer`, `src/state.rs` `prepare_v2`): the v2 + paint plan stable-sorts tile batches, text layers and sprites together by + their `i32` layer, so multiple batches at distinct layers, and later even + above-player pass-through batches, paint correctly as shipped. +- This resolves glyph-reuse ambiguity by construction: in Last Legend, `x` on + the buildings layer is a window crop, `x` on terrain is a ridge crop, and + neither is a guess. +- Wall-mounted objects render as crops over the wall tile: a fixtures layer's + `/` (blade) or `i` (mounted notice) crops draw above the buildings layer's + wall crop. In the terminal the same cells read `====/====` and `====i====` - + visibly different by glyph, no colour spent. Interaction stays on the + traversable approach tile in front, per the established event grammar. +- The single-column-glyph limitation for atlas mapping is unchanged; wide + glyphs and emoji stay unmapped in GPUI, as today. + +## Phases + +### Phase 0 - Restore the authoring contract + +1. Flatten Last Legend's remaining `AsciiMap` maps deliberately, in one pass - + not by editing them in the editor. Evaluate each builder once, capture the + rendered string, write it back as a literal nowdoc, and verify the output + byte-identical against the evaluated builder so no colour runs are lost. + Maps: `garden-of-roads/temple-of-the-listening-stone`, + `garden-of-roads/lanternrest-wayhouse`, and the three + `bsa/licensing-facility` maps. Repair `waymeet-field-post`'s colour runs + (already flattened by an editor edit) in the same pass. Other + generator-style maps (e.g. `happyville/municipal-office`) flatten here too: + everything converts. +2. Delete Last Legend's `assets/Maps/Support/AsciiMap.php` once no map + references it. It is gone by the end of this work; none of its grammar + survives as map source. +3. Add validation: every `.map.php` / `.event.php` (and later every layer + file) must be a literal nowdoc - no classes, no calls. +4. Close the editor gap: extend the `MapSourceRefusal` mechanism to the grid + files, so the editor refuses to save over a grid whose source is not + already canonical instead of silently flattening it. +5. Unify the contract: canonical nowdoc string on both sides; engine and + editor agree on exactly one accepted shape. + +### Phase 1 - Engine: layered map model + +1. `MapManager` discovers and parses `layers/`, validates dimensions across + all layers plus the event layer, composes gameplay layers topmost-wins into + `worldSpace`, and retains per-layer grids. +2. `generateCollisionMap()` implements topmost-occupied-wins with + `PASS_THROUGH`; `collisions.php` gains optional per-layer sections. +3. Per-layer `tiles2d` tables; `GraphicalTileCollector` emits one batch per + layer; decoration layers are parsed, never composed, and feed batches only. +4. Backward compatibility: a map without `layers/` behaves exactly as today. + The legacy single-`.map.php` path remains until migration completes + (retirement timing is an open item below). + +### Phase 2 - Editor: generalised layer canvas + +1. Replace the hardcoded tile/event layer pair with a dynamic layer list from + `layers/`; canvas modes cycle through the map's layers plus the event + layer, with per-layer visibility toggles and dimming. +2. A terminal-preview mode hides decoration layers, showing exactly what a + terminal player sees. +3. Saves write each layer to its own nowdoc file under the existing rule: an + untouched layer's file is never written; the transactional write covers the + whole set. Validation checks all layers share dimensions. +4. Layer create/rename/remove manipulates files in `layers/` (rename never + disturbs the order prefix). +5. Multi-row facade stamps: a game's building catalogue (Last Legend's + `buildings.txt`) becomes stampable brushes placed onto a buildings layer; + the catalogue remains the single source of shapes. +6. Minimal `tiles2d` awareness: surface it read-only in the inspector; warn + when a painted glyph has a crop mapping on that layer. + +### Phase 3 - Reference migration (Last Legend) + +Everything converts; the terminal game must play identically throughout. + +1. Order: the flattened `AsciiMap` maps first, then the remaining maps + following the Milestone 1–3 use cases. +2. Per map: split terrain / buildings / fixtures (and decoration layers where + the art calls for them), move wall-mounted objects (the Kaelion home blade, + mounted notices) onto the fixtures layer, and free the floor cells they + were compromising. +3. Every migration is guarded the S8-B way: a fingerprint test asserting the + composed render and every cell's collision result are identical before and + after the split. Identical collision means no save-manifest bump; any + deliberate geometry change goes through the established save-migration + process separately. +4. Extend the Garden atlas per-layer (grass and water stay; `x` finally maps + on both layers with distinct crops). +5. End state: every Last Legend map has `layers/` with at least + `01.terrain.map.php`; no root `.map.php` remains; `AsciiMap` is gone. + +### Phase 4 - Documentation and tests + +1. Update the engine's [maps.md](maps.md) and + [rendering/tile-batches.md](rendering/tile-batches.md) with the layer + format, collision resolution and per-layer batches. +2. Game-side: rewrite the glyph table in Last Legend's + `map-visual-language.md` per-layer; codify the three-channel rule and the + colour budget (colour is spent on attention, never identity); amend the + "never classify cells by colour" rule to its precise form - *inferred* + colour is never meaning, and identity never rides on colour at all. Update + `graphical-terrain.md`. +3. Test coverage: engine (layer discovery and parsing, dimension mismatches, + collision precedence including pass-through, multi-batch collection, + composed camera output); editor (per-layer round-trip integrity extending + `LastLegendMapIntegrityTest`, the grid-source refusal, terminal preview); + game (per-map migration fingerprints, save compatibility). + +## Open items needing the author's decision + +1. **Legacy path retirement.** Once Last Legend is fully migrated, does the + engine's single-`.map.php` support get removed, or does it remain for + `epic-quest` and other example projects until they migrate as well? + +## Decisions already made (do not relitigate) + +- Layered tilemaps are engine functionality, available to every game built on + Ichiloto. This plan lives in the engine's docs; game docs hold only each + game's own vocabulary and layer conventions. +- Maps are HEREDOC/nowdoc character grids, one file per layer. No map builder + objects in game assets, ever. `AsciiMap` is deleted. +- Layers live in `layers/` with integer-prefixed, renamable filenames. +- Collision: topmost-occupied-wins with `PASS_THROUGH`; per-glyph dictionaries + per layer; no per-layer collision grids. +- Colour is an attention channel, never a tile-identity channel. +- Decoration layers exist, are terminal-invisible, and are structurally barred + from gameplay. +- Migration covers everything, starting with the `AsciiMap` maps, then the + Milestone 1–3 use cases. diff --git a/docs/releases/unreleased-notes.md b/docs/releases/unreleased-notes.md index 5ebac970..557516e6 100644 --- a/docs/releases/unreleased-notes.md +++ b/docs/releases/unreleased-notes.md @@ -5,6 +5,15 @@ create a release, tag, or release branch. ## Rendering and input +- Shutdown consumes cached confirmation input and stops remaining field/frame + work. Blocking modals unwind without repainting or resuming stopped scenes, + preventing an Exit confirmation from opening a new interaction after quit. +- Battle Pause reuses the existing state with Resume, Config, To Title and Exit, + optional project-owned graphical art and terminal controls. Resume preserves + active-time gauges, actions and feedback timing; destructive confirmations + default to Cancel. Config shares the main menu's settings interaction and + restores Pause focus without resuming gameplay. Live visual acceptance is + tracked in the [integration roadmap](../rendering/integration-roadmap.md#approved-pause-upgrade). - Optional supervised graphical rendering with structured colour, text layers, PNG sprites, PHP-owned directional sheet animation and negotiated field tiles. - Native terminal viewports capped at 135x36 and centered within larger windows, @@ -14,9 +23,31 @@ create a release, tag, or release branch. - Reduced repeated styled-row parsing during scrolling while preserving Unicode width semantics and sparse terminal output. - Region-map locations remain visible and reachable in bounded viewports. +- Cinematic field presentation retains graphical terrain and Player sprites. + Temporary actors support optional static or sheet-backed graphics, with + terminal fallbacks and explicit narration/cover precedence. +- Real-subject cinematic visual leases support paired suppression, pose replacement + and temporary transform recovery without changing NPC gameplay eligibility. +- Optional graphical battle Results show real party progression and rewards over + the final battlefield, with shared terminal paging and presentation-only timing. + Result snapshots cannot replay EXP, loot or other committed gameplay outcomes. ## Gameplay and persistence +- Game bootstrap now loads the existing Editor-authored + `assets/Data/equipment-optimization.php` policy for ordinary Optimize actions. + Omitted declarations retain legacy scoring; invalid declarations stop startup + with a file-specific error instead of silently using different weights. +- Shops and their sell menus now enforce item sale eligibility from the owned + inventory definition, including `sellable: false`. Existing shop rates and + once-per-transaction rounding are unchanged. +- Purchases create party-owned stacks rather than sharing the shop's catalogue + object. Selling the last copy no longer empties subsequent purchases, and + quantity/capacity checks cover equipment as well as consumables before payment. +- Battle openings share one pre-emptive/ambush decision across battle modes and + honor scripted overrides. ATB starting gauges have meaningful bounded variation; + readiness order survives high speeds and delayed frames without losing the + existing speed-based refill behavior. - Scenario-aware field music, structured battle-entry rules and field-skill targeting, alongside the existing scene and resource-preservation fixes. - Relocated save summaries use the file actually selected instead of following diff --git a/docs/rendering/garden-output-validation.md b/docs/rendering/garden-output-validation.md index 7e2183fd..147040b5 100644 --- a/docs/rendering/garden-output-validation.md +++ b/docs/rendering/garden-output-validation.md @@ -72,7 +72,7 @@ controlled FPS benchmark or a guarantee for all Garden gameplay. Full frame-stage traces, analysis, receipts and the distinct control/foreground workloads are retained by the renderer task in -`website/gpui-renderer/docs/garden-performance.md` and its adjacent +`gpui-renderer/docs/garden-performance.md` and its adjacent `docs/evidence/garden-output` directory. Timing samples are sequential single runs, subject to system load; neither CPU paint completion nor these elapsed totals establish visible frame rate. diff --git a/docs/rendering/graphical-battle-g1.md b/docs/rendering/graphical-battle-g1.md index 3503b02f..cdddd012 100644 --- a/docs/rendering/graphical-battle-g1.md +++ b/docs/rendering/graphical-battle-g1.md @@ -140,14 +140,41 @@ use the canvas. Returning to another scene sends an ordinary replacement frame. ## Project Metadata API -The optional `assets/Data/battle-presentation.php` returns a typed +The optional `assets/Data/Presentation/battle.php` returns a typed `Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog`. It is loaded from current project configuration when a graphical battle is configured, not stored in `BattleConfig`, actor data or save payloads. Native Terminal never loads this file or inspects the PNGs. Ordinary GPUI selection requires `graphical_canvas` in addition to its existing capabilities only when the catalog -exists. A missing catalog or missing arena entry keeps the entire battle on the -legacy path, including in GPUI. A malformed declared catalog fails explicitly. +exists. Its optional `ui: BattleCanvasLayout` supplies the shared skin and canvas +geometry for every battle, independently of arena or combatant artwork. Without +an arena entry, the existing owned battlefield is drawn below the native HUD; +missing artwork does not revert the controls to terminal windows. Only a missing +catalog, or an encounter with neither an arena nor a shared UI, keeps the entire +battle on the legacy path. A malformed declared catalog is diagnosed and the +battle uses the terminal presentation. + +Artwork changes throughout development, and the engine treats the image on +disk as the moment's truth. Character identity never depends on an image's +contents, checksum or a previous revision's dimensions. Supported format, +decoding, path safety and the generic resource limits above remain enforced; +recommended authoring sizes are not exact-image acceptance gates. Supplying +appropriately composed artwork is the developer's responsibility. Authored +battler crops and pivots reconcile to the current image at battle start: a +crop that still overlaps the image clamps to it, and a crop the image no +longer contains falls back to the whole image, in both cases rendering +best-effort with the mismatch logged for the author. Any other failure while +assembling the graphical presentation, a missing catalog under an explicit +arena included, logs loudly and degrades that battle to the terminal +presentation. Presentation never decides whether combat happens. + +For whole-file UI textures, use `CanvasNineSlice::fromPng($assetRoot, $path, ...)` +with any intended border cuts, density and destination minimums. It reads the +current source dimensions instead of repeating them in the catalog. Explicit +atlas regions continue to use the constructor with a `SpriteSourceRect`. +Results preparation resolves portrait/icon families as current whole images, +then contains them in their existing display slots; old source dimensions do +not reject replacements. Frame budgets still apply to the actual decoded files. Example structure (generic geometry and placeholder paths, not game artwork): @@ -155,7 +182,7 @@ Example structure (generic geometry and placeholder paths, not game artwork): ` IDs held for this battle's lifetime; no RNG or save identity is added. Targets and feedback refer to the actual PHP object. +### Troop Formations Within An Arena + +`BattleArenaDefinition` optionally accepts `enemySlotsByTroop`, mapping troop +definition IDs to complete ordered lists of `BattlerSlot`. This keeps location +selection separate from enemy formation: the map/event still selects its arena, +then that arena resolves an override for the current troop. Historical troop +names are used only when the troop has no authored definition ID. For example, +an arena can retain its existing `enemySlots` and add: + +```php +enemySlotsByTroop: [ + 'troop.mixed-patrol' => [ + new BattlerSlot(350, 400, 160, 230), + new BattlerSlot(530, 470, 120, 100), + ], +], +``` + +Unmatched troops retain the arena's default slots. An override changes neither +the background, party slots, UI geometry nor feedback safe area. Resolution is +immutable and encounter-local; the same troop can use another arena's formation +elsewhere, and an encounter without an arena remains without one. Slot limits, +image bounds and participant coverage are checked by the existing graphical +preflight. An empty or incomplete selected override is not silently replaced +with the default formation. No save schema, encounter weights, combat identity +or renderer protocol changes are involved. + +This is runtime metadata support. Editor TUI selection/editing of battle arenas +and these troop-slot mappings is not implemented. Future authoring must use +existing troop selectors and source-preserving transactions/round trips; it must +not flatten the authored PHP catalog. No Editor capability is claimed here. + +Verification, 22 September 2026: focused graphical tests pass 65 cases / 386 +assertions; the full Engine suite passes 2,905 tests with one existing skip on +PHP 8.4 (44,010 assertions) and PHP 8.5 (44,009 assertions). Scoped PHPStan and +whitespace checks pass. Synthetic fixtures cover stable/legacy identities, +location and UI preservation, invalid/incomplete formations, detached authoring +references, restored encounters, and survivor placement after target reordering +and removal. These are headless checks, not native visual or cross-platform +acceptance. The change remains local and uncommitted. + +### Battler Placement + `BattlerArtwork` width/height and pivot are source pixels relative to its crop, -or the whole PNG when no crop is supplied. Dimensions must match that selected -source exactly. `BattlerSlot(x, y, width, height)` gives the graphical pivot +or its authored whole-image bounds when no crop is supplied. The metadata must +be internally consistent; it does not lock the dimensions of the file on disk. +Preparation reconciles stale bounds and pivots with the current image as described +above. `BattlerSlot(x, y, width, height)` gives the graphical pivot destination and maximum contain dimensions. PHP uniformly scales the selected source to those limits, then subtracts the scaled pivot to resolve the destination rectangle. No snapping to terminal cells occurs. Art-supplied shadows belong to @@ -200,10 +293,13 @@ Supply party slots for the configured active formation and enemy slots in the troop's actual member order. The existing Party reserve fallback remains live: all roster images and their possible party-slot placements are preflighted, but only the actual frontline is drawn. Enemy removal never renumbers a surviving -instance's authored slot. All configured participants must have art; individual -ASCII fallback is prohibited. Missing files, escaping symlinks, non-PNG headers, -oversized PNGs, mismatched dimensions/crops, invalid placement and source budgets -fail before battle-entry effects. This PHP preflight reads headers without PNG +instance's authored slot. All configured participants must have usable art for +that graphical arena; individual ASCII battlers are not mixed into it. Missing +files, escaping symlinks, non-PNG headers, oversized PNGs, invalid placement and +exceeded source budgets reject graphical preparation before battle-entry effects. +The scene diagnoses that failure and retains playable terminal combat instead of +aborting the battle. Stale battler crop bounds alone are reconciled, not rejected. +This PHP preflight reads headers without PNG decoding. Native full decoding and atomic image preparation remain asynchronous; header preflight does not claim to validate compressed pixel data or await paint. @@ -251,7 +347,11 @@ An optional project-owned `BattleUiSkin` uses read-only snapshots of existing command, context, name, status and message windows. It projects current values, affordability, paging and selection without choosing commands or calculating combat. Hidden windows disappear in the next complete canvas. Terminal windows -and unskinned G1 retain their existing paths. +and unskinned G1 retain their existing paths. For a UI-only battle, the existing +field window has its own canvas text layer at layer 0; skinned controls remain +above it and explicit modals/results above those. Legacy footer cells are not +readmitted over the native controls. Fully illustrated battles still suppress +terminal battler drawing before it reaches Console. Nine-slice panels preserve corners. Gauges clip their full-width fill rather than squeezing it. Party names and resource rows share baselines; ATB remains @@ -293,6 +393,12 @@ formations still fail preflight. Old skins keep their appearance. Cursor images add no protocol extension or extra text layers. Active-actor, selected-target and queued-target ownership remain distinct. +Every submenu action enters target confirmation before it is queued, including +self-only skills, magic and items. Self-only actions highlight only the caster; +group actions highlight the eligible group. Cancel returns to the same submenu +option without spending resources or applying effects. Traditional and active-time +battles share this PHP selection flow in both graphical and terminal renderers. +Direct top-level Guard and Escape commands retain their existing behavior. Andrew accepted the active actor underline for this testing slice only. The finished presentation must use an above-head actor cursor and animated target cursors; additional bounce/spin polish is future scoped work. Reference videos @@ -301,11 +407,27 @@ and screenshots are not assets to copy into the game. ## Current Integration The accepted build is integrated into the normal local Game, not a separate -playtest runtime. Its production `assets/Data/battle-presentation.php` references +playtest runtime. Its production `assets/Data/Presentation/battle.php` references `assets/Data/battle-ui.php`; production must never load test-fixture metadata. Use the existing `ichiloto play --renderer=gpui` entry point from the normal Game checkout. Native Terminal remains available through the same command with `--renderer=terminal`. No temporary game copy or renderer path override is needed. +The shared native controls apply to every authored encounter. Approved PNG +arena/combatant coverage remains separate; other fields retain their existing +ASCII artwork until their art and placement metadata are ready. Recurring troops +can appear in different locations, so authored `battleArena` bindings select +their setting rather than assuming one background per troop. Art production runs in +parallel with Engine and story work, not after them. + +The approved 16 September art expansion adds four creature images and six +location backgrounds to the normal Game. Fourteen authored random/scripted +encounter contexts select the appropriate arena and formation; Great Wolf and +Practicum Great Wolf share one approved image. Existing G1 artwork is unchanged. +Cryptic Ruins and Loch Ness remain deferred, using the existing ASCII field +under the shared native controls. No additional runtime copy or native rebuild +is needed. This batch's unavailable-packaging-tool and missing-separate-license +exceptions were explicitly approved; source and admitted-byte hashes are retained +in Game's asset receipts, without inventing a license grant or formal tool result. The canonical macOS package contains the already validated optimized executable SHA-256 `8f946ccac39d1f6aa50e1edb4712300197ad6bfcea361b221dac060f3a52bbc5`. @@ -335,15 +457,30 @@ negotiation failures; strict inbound JSON/filesystem rejection remains the Renderer boundary, not a duplicate Engine decoder. Change frozen corpora only through coordinated re-freezing. -Latest full Engine verification on the accepted source: PHP 8.5 passed 2079 tests -/ 10728 assertions; the prior PHP 8.4 run passed 2079 tests / 10729 assertions, each with one -existing skip. The focused correction suite passed 67 / 1351 and full-source -PHPStan passed. Renderer recorded 106 optimized tests and 32 separate +Latest full Engine verification, including encounter-specific arena selection: +PHP 8.4 and 8.5 each passed 2097 tests / 10827 assertions, with one existing skip. +Focused presentation/event-continuation checks passed 103 / 756, including arena +selection and reset between maps, invalid-key rejection, configuration round trips, +and terminal isolation. The earlier shared-UI presentation/HUD checks passed +43 / 302 for skin inheritance, layer ownership and pre-entry failures. +Full-source PHPStan passed. Renderer recorded 106 optimized tests and 32 separate actual-size/density admission cases; those are not gameplay FPS measurements. -Game's bounded suite passed 293 tests / 561205 assertions, excluding battle +The approved-art Game presentation checks pass 54 tests / 2884 assertions on +both PHP 8.4 and 8.5. These verify all fourteen encounter contexts, unchanged +terminal frames, source hashes, crop-relative pivots, formation/reserve/cursor +bounds and pre-entry rejection. The largest covered unique-source set is +61,625,432 bytes, below the 64 MiB limit; only the active background is counted. +This is static and automated scene coverage, not a new native/GPU playthrough. +The bounded Game presentation and affected story regression set passes 146 tests / +42713 assertions on both PHP versions, excluding the broad battle-simulation baseline. + +Earlier Game bounded verification passed 293 tests / 561205 assertions, excluding battle simulations. Production-binding checks passed 11 / 392 after integration, plus -3 / 41 for the accepted bedside correction. Earlier isolated-runtime execution +3 / 41 for the accepted bedside correction. The all-battle shared-UI change +passes 22 Game presentation tests / 599 assertions, including all 11 authored +troops, preserved PNGs for the illustrated encounter and the existing field art +below native controls elsewhere. Earlier isolated-runtime execution initially failed two source-unskinned catalogue assertions (9 passed); the appropriate runtime filter passed 5 / 231. Those historical failures were not presented as successful full-suite coverage. @@ -372,5 +509,162 @@ do not invent a gameplay delay. G2/G3 and distribution remain separate backlog. The earlier output-only slice made no renderer protocol or game-content changes. This UI slice adds the explicit optional capabilities above but no gameplay content; the result-placement/cursor correction adds neither another protocol -extension nor gameplay changes. Automated native launches must verify music and +extension nor gameplay changes. Making that UI project-wide adds no renderer +protocol, native binary or gameplay-content changes; its new coverage is automated, +not a fresh interactive playthrough of every encounter. Automated native launches must verify music and SFX are muted; Andrew's applied mute must be preserved. + +The approved-art expansion changes graphical assets and encounter bindings, not +combat rules, rewards, encounter weights or save schemas. Its arena selection +uses existing battle settings and adds no renderer protocol or binary changes. + +## Battle Results integration + +Bounded integration was approved on 16 September 2026 for the normal Game, with +no publishing or new build. `BattleResult::rewards` now carries detached award +facts through `BattleRewards`/`BattleProgression`. The shared EXP award path +captures before/after levels, thresholds, stats and actual newly learned skills. +Battle resolution captures rolled drops and inventory retention separately; +presentation cannot grant rewards. The existing full per-member EXP policy, +including reserves, is unchanged. + +`BattleResultsPlayback` owns a delta-driven Primary -> per-character Level Up -> +Learned Ability -> explicitly supplied Special Reward sequence. Confirm finishes +the current reveal; a later input advances. Overflow is manually paged, never +silently truncated. Reduced motion keeps facts and order. Terminal uses the same +snapshot/stage model; graphical Results retain the final real battlefield and +omit battle command/target/feedback overlays. Resume does not catch up elapsed +presentation time from a blocked scene. A new battle discards the prior Results +session. All configured PNGs receive pre-entry path/crop validation; resource +budgets apply to concurrently displayed families, not the whole catalog. + +The existing `BattlePresentationCatalog` has optional `results` skin metadata, +separate menu/bust portrait families and category icons. The normal Game reuses +its admitted panel/track/selector, adds the eleven explicitly approved unchanged +UI images, and now uses separately approved menu/bust pairs for all four +starting-party actors, as recorded below. Actors without configured art retain +neutral initials. No rarity or +demo outcomes are inserted into Game content. Reserved hover/pressed/disabled +and unknown-state artwork is not active input behavior. + +Current native treatment uses existing integer text grids and per-child alpha, +not browser font families, proportional shaping, CSS filters or isolated group +blending. Page transitions are a short input-locked hold then incoming reveal, +not a claim of browser crossfade equivalence. Event-only input has an 80 ms repeat +guard but cannot prove physical release when repeats are slower; the queued +controller-ready input slice owns that missing native transition contract. + +Before the button refinements below, Engine full-suite checks passed on PHP 8.4 and 8.5: 2,188 tests and one +existing skip each (12,816 and 12,823 assertions respectively), using the same +1 GiB test-memory setting as CI. PHPStan and whitespace checks pass. The Game's +Results/graphical-battle integration checks pass 64 tests / 4,953 assertions on +each PHP version, including asset hashes, ordinary/heavy/capped progression, +replay, reduced motion and unchanged award outcomes. Renderer +headless composition/resource regressions pass nine canvas tests using the +existing protocol; these synthetic fixtures are not Game resource measurements. +Before portrait admission, the tested Service Road battle/HUD/Results source +union was 26 unique PNGs / 48,421,880 decoded bytes, below the unchanged 64 MiB +budget. The expanded all-encounter residency check is recorded below. + +Silent macOS checks with the existing installed renderer inspected settled +ordinary/heavy Primary, Level Up and Learned Ability frames made by the normal +Game integration tests. They exposed and corrected low-contrast exterior hints +and an unnecessary second stat page. All nine ordinary stats now fit together; +unbounded content still pages. Confirmation text is centered independently of +its selector, and level/stat changes use an arrow glyph; both were rechecked +natively along with the final overlay-free battlefield. The silent test process +closed successfully. These are composed-frame +visual checks, not a completed real-battle playthrough or native input/animation +acceptance. Browser Art approval does not establish those runtime gates. +Andrew's subsequent gameplay recording exposed an unwanted `Returning` label +swap at exit. The action now retains `Continue` throughout its locked fade; +`Complete` remains the reveal action. Regression samples cover exit from all +four Results stage types, unchanged label geometry, repeated-input rejection +and overlay removal. The focused Results/terminal checks pass 27 tests / 359 +assertions on PHP 8.4 and 8.5. + +Andrew then requested a visual handover for `Complete` -> `Continue`. Shared +playback now fades the entire button assembly out over 120 ms, leaves a 60 ms +empty beat, and fades the incoming assembly in over 140 ms. Natural completion +and explicit fast-completion use the same timeline. Confirm is ignored until +the incoming action is fully visible; the old press cannot advance it. Captured +reward facts and the surrounding panels are unchanged. Reduced motion switches +directly to the stable label; terminal hides the action hint while input is +locked rather than pretending to render alpha fades. The focused native action +uses the already-admitted selected fill/border. Andrew's 21 September list-only +cursor refinement removes the button cursor in both motion modes; the existing +button handover fades and centered label remain unchanged. The browser kit's +additional FocusRing asset and other button +states are not newly imported or wired by this correction. + +Updated Results/terminal checks pass 33 tests / 439 assertions on PHP 8.4 and 8.5, +and normal Game Results/battle integration remains 64 tests / 4,953 assertions +on both. PHPStan and whitespace checks pass. An isolated silent native check +using the existing renderer and demonstration reward values inspected outgoing, +empty and incoming button phases over the captured Game battlefield. It closed +successfully; this is not a full real-battle/input acceptance playthrough. +The full Engine suite was then rerun on PHP 8.5: 2,198 passed, one existing skip, +13,011 assertions. Art completed the matching Results preview/spec in place +(`output/results-ui-v1-20260916/package/Results-Review.html` in the Art workspace). +Its 37 browser checks cover the handover, focus appearances, input locks and +callback cleanup; the focus comparison was inspected by the coordinator. +No new package variant or asset admission was needed, and all existing PNGs +remain unchanged. Browser evidence does not replace the native boundaries above. + +Andrew approved the kit's focus distinction and explicitly answered "Approve the +two-file integration" for Kaelion's Menu.png (512x512) and Dialogue/Neutral.png +(768x960). After the whole-party scope was reconciled with Art, he answered +"Approve six-file integration" for the same two families for Liora, Drazek and +Seraphis. These separate approvals accept the unavailable packaging tool and +absent explicit licence field only for their respective unchanged PNGs. The +closed Art rosters passed 34/34 and 35/35 hash checks. Exact approvals, source +hashes, provenance and import exceptions remain in Game's existing +`tests/Fixtures/Rendering/battle-results-ui-v1.json`, separate from the eleven +UI-image admission. All eight portraits are imported and mapped in the normal +Game checkout. Primary uses menu art; Level Up/Learned Ability use the distinct +neutral bust. Full-source contain preserves proportions and transparency; no +extra resizing, mirroring, battle-sprite substitution or new build was used. + +Shared `CanvasImagePreflight` validates full decoded sources and prepared crops, +including the native two-pixel guard on each edge. Canonical paths deduplicate +sources, crop identity deduplicates regions, and opacity/clipping do not exclude +referenced images. All catalog assets are still checked for invalid paths/crops; +pre-entry budget checks distinguish battle/HUD, up to four consecutive Primary +menu portraits, and one event bust. Both graphical fields and shared native UI +over text fields use this boundary. Regression cases cover oversized catalogs +that fit per page, invalid unused art, sliding Primary page windows, oversized +concurrent sources, guarded-region overflow, and terminal asset independence. +Current stage replacement has no portrait-family overlap; future crossfades +must account for both stages. Per-snapshot source and region limits remain +64 MiB independently, with 1024 sources and 4096 regions. Cache eviction drops +cache ownership, not queued/displayed snapshot references: these are not +total-live CPU/GPU memory ceilings or immediate page-boundary release guarantees. + +The whole catalog union is 77,690,200 decoded bytes and is deliberately not used +as a concurrent-frame budget. Game checked 2,296 actual scene snapshots across +all fourteen encounter contexts, normal/reduced motion, reserve formations, +entry/hold/reveal/page/exit/return. Independent maxima were 54,887,260 source +bytes, 47,568,300 guarded-region bytes, 16 sources and 50 regions. Game's scoped +Results/battle suites pass 69 tests / 27,888 assertions on PHP 8.4 and 8.5, +including identity after reordering, unknown/no-art fallback and unchanged +outcomes. Engine's full PHP 8.5 suite passes 2,205 tests with one existing skip +(13,055 assertions), using CI's 1 GiB test-memory setting; PHP 8.4 focused checks +pass 93 tests / 828 assertions. Five changed runtime files pass scoped PHPStan; +lint and whitespace checks pass. All nine Game saves, user configuration, +narrative work and refs were preserved. No commits or publishing were performed. + +Silent native macOS inspection covered all eleven freshly exported normal-Game +frames: ordinary/heavy Primary, each actor's Level Up and Learned Ability, and +overlay-free return. All four menu portraits and four busts display with the +correct identity and fit without clipping/stretching. Export SHA-256 was +`cf7f63be19a697d6363fbe83be8308a027beb613e0113c2fddc2093740d36aa5`; +the installed renderer remained unchanged. The isolated inspector initialized +no game audio, save manager or user configuration. Both inspection processes +closed with status 0, the final one explicitly after checking overlay removal; +no test window remains open. This is native composed-frame evidence, not a new +real-battle/input/motion acceptance playthrough. + +The broad Last Legend suite was not completed because of the unrelated +180,000-battle simulation baseline. Linux/WSLg remains untested. The +dark-player-on-dark-field issue is an art/background concern. This Results slice +does not change the renderer protocol or binary, combat policy or save schema. diff --git a/docs/rendering/input-sources.md b/docs/rendering/input-sources.md index ebce4523..2148ad9d 100644 --- a/docs/rendering/input-sources.md +++ b/docs/rendering/input-sources.md @@ -25,6 +25,15 @@ bounded reads and do not call gameplay, change bindings, or own game-loop timing `KeyboardEvent` with the key's value, including recognized repeated keys. The gameplay-facing `Input` facade is unchanged. +`InputManager::init()` and `setBindings()` supply a missing `info` action for +older projects: `i` and `I` are offered only when not already bound elsewhere. +An explicit `info` entry, including empty keys, is preserved unchanged. If both +aliases conflict, Info remains discoverable as Unbound in Controls for explicit +rebinding. Info cycles two-line menu descriptions and wraps to the first page; +it does not change selection. Boot captures these effective bindings for Restore +Defaults. Loading never writes configuration; the existing explicit Controls +rebind/restore workflow retains persistence ownership. + ### Approved compatibility correction S3 uses canonical comparison for `isKeyPressed()` with maintainer approval. @@ -41,6 +50,48 @@ axes, `isButtonDown()`, and `isAnyKeyPressed()` remain edge-triggered. Repeating the same key is pressed but not a new down edge. Changing A directly to B is not an A release; `isKeyUp()` still requires a following no-input sample. +## Action hint presentation + +`ActionHints` resolves semantic actions to display-only `ActionHint` and +`ControlHint` values. By default it reads the live `InputBindings` on every +redraw. Compact hints show one bound control: Enter for confirm and Escape for +cancel/back when those keys are actually bound, otherwise the first bound key. +This does not remove aliases or change input handling. The Controls screen keeps +the complete binding list through `describeKeys()`. + +The shared menu hint painter uses optional theme icon roles such as +`input.keyboard.ENTER`, with readable keycap labels when no matching image is +supplied. Action labels, control identity and artwork remain separate. Main Menu, +Equipment and Status consume the same mechanism; legacy window help strings +elsewhere have not all been migrated. + +Themes may set `showInputHints` to false to omit persistent graphical helper +strips and their reserved layout space without changing actions or bindings. +The default remains true for compatibility. Last Legend uses the dedicated +Controls lookup instead, retaining useful action descriptions in menus and +dialogs. Controls still exposes all aliases, not only the compact primary key. + +A PHP input context can supply an `ActionHintProvider` and replace its display +profile without changing menu composition or dispatching input. That is a +presentation boundary, **not implemented gamepad detection or controller input**. +The current native and terminal sources still report keyboard identities only. +The approved PC/Desktop Controls Art example uses Xbox glyphs (`gamepad.xbox`): +A/B/X/Y, shoulders/triggers, D-pad, Menu and View. PlayStation and Nintendo +families are later variants. This is a manually selected preview family, not +approved shipping mappings, device detection or authorization to implement a +controller backend; semantic actions and keyboard bindings remain unchanged. +Physical-controller work must drive the profile from meaningful active-device +input, handle focus/disconnection and keyboard/controller coexistence, and keep +device glyph changes independent of actions, focus and selection. Merely having +a controller connected must not make hints unusable for a keyboard player. + +Remaining rollout belongs with semantic input migration: audit each legacy help +owner against the keys/actions its controller actually accepts, then reuse these +descriptors for terminal text and native glyphs. Do not globally replace strings +with misleading remappable hints for a controller that still reads fixed keys. +Editor authoring of icon-role paths must use the same project asset selection and +safe round-trip contract as other presentation artwork. + ## TerminalInputSource The terminal source contains the former manager's pending-byte buffer, escape @@ -151,3 +202,95 @@ Lifecycle diagnostics go to stderr. The tool generates no frames or game actions See the [S3 validation and handoff record](s3-validation.md) for tested boundaries and the approved compatibility correction. Runtime selection, presentation, and Last Legend integration remain out of scope. + +## Planned controller-ready input and normalized movement + +**Not implemented.** The sections above describe the current event-only source +contract. This extension is queued in the [integration roadmap](integration-roadmap.md#controller-ready-input-and-normalized-movement) +for G4 field readiness after the current cinematic ownership work; capture does +not change current APIs or authorize an immediate implementation. + +### Ownership and compatibility + +Gameplay and UI consume semantic actions, navigation/focus and input values. +Control hints remain separate from action identity and artwork, preserving +keyboard bindings while permitting controller glyphs and remapping. Shared +contracts must accommodate device/control identity, press/release/held state, +simultaneous controls, analog magnitude, focus/connection changes and coexistence. +Future gamepads must not be permanently modeled as synthetic keyboard presses. +PHP owns bindings, action contexts, movement and timing; native sources report +normalized controls. No render callback may drive gameplay. + +Renderer must inspect the pinned GPUI key-down/up and focus APIs before Engine +and Renderer agree the smallest negotiated transition/reset extension. Preserve +legacy event semantics; do not reinterpret them silently or assume an upgrade. +Track stable control identity separately from text/case so modifier changes do +not strand held keys. Repeated down events create neither new physical edges nor +extra movement. GPUI's previously unreliable `is_held` repeat flag is not enough. +Clear held state on focus loss, connection failure and shutdown; elapsed silence +is not release evidence. Preserve platform shortcuts and text-entry separation. + +Replace single-current-key state for stateful sources with bounded event/held +processing before the normal gameplay update. Preserve taps that press and +release between updates, multiple bindings for one action and event ordering. +Do not run gameplay once per input event or drain unboundedly. Keep held actions, +pressed/released edges and UI navigation repeat distinct; do not redefine every +existing `isButtonDown()` consumer as held. Terminal keeps an explicit event-only +adapter and cannot claim reliable physical key release. + +### Movement and world semantics + +- Derive one intent from all held movement actions. Opposing directions cancel + per axis; Down plus Right produces a diagonal without alternating key presses. +- Use elapsed time and explicit travel distance, not OS repeat rate or painted + frames. The stable PHP field metric must account for rectangular horizontal and + vertical step dimensions; equal cell frequency is not equal apparent speed. + Native scaling, DPI and resize must not alter gameplay speed. Avoid scattered + axis-specific speed corrections. Future analog input retains partial magnitude. +- Keep integer cell commits initially, using a distance/time accumulator or + scheduled step duration rather than truncating a normalized fractional vector. + Direction changes grant no free step; blocked movement banks no burst; catch-up + after stalls is bounded. Leave presentation-position interpolation separate: + this does not deliver smooth subcell FIELD rendering. +- Commit a diagonal through one validated operation, not two cardinal moves. + The destination and both orthogonal clearances must be valid against walls, + map edges, NPCs and authored gates. Clearance probes have no movement events, + triggers or encounter RNG. Only the occupied destination receives existing + movement/event effects, exactly once. +- Document how a committed diagonal counts for step-based systems before + implementation. Do not silently rebalance encounter frequency; unresolved + gameplay policy goes through the Engine coordinator, not Renderer. +- Follow both camera axes using the existing policy and only the necessary + completed field recomposition, retaining T1's gains. Use existing four-direction + art with a documented deterministic facing rule, independent of movement vector; + a new eight-direction art batch is not a prerequisite. +- Preserve authored route `secondsPerStep`, completion and ownership. Reuse the + validated movement boundary without replacing route timing with the new free- + movement clock. Dialogue, menus, cinematics and battles cancel pending walking; + returning cannot replay stale presses or confirm twice. Coordinate Player, + input, Camera and session edits with real-subject cinematic takeover. + +### Acceptance and exclusions + +Deterministic tests must cover held Down/add Right/release Right/release Down; +all diagonals and opposing pairs; multiple bindings; repeat-down; quick taps and +modifier changes; focus/reset/disconnect/failure/context changes. Compare equal +travel over equal simulated time, with a documented one-step quantization bound, +under different update subdivisions and both rectangular and square metrics. +Verify resize independence, blocked/stalled timing, corner/NPC/gate/map-edge +collision, both-axis camera tracking and exactly-once destination effects. +Retain keyboard menu, terminal, cinematic route, T1 composition and graphical +exclusion regression coverage. + +Native acceptance uses one bounded ordinary-Game GPUI pass: simultaneous keys, +partial/all release, unobstructed horizontal/vertical/diagonal travel, corners, +scrolling, focus loss and dialogue/menu/cinematic entry and return. Measure +travel relative to the field, not only a camera-followed on-screen Player. +Verify silence before launch without changing Andrew's normal configuration; +no scratch-runtime family or user-data changes. State tested platforms honestly. + +Physical controllers, controller libraries, enhanced terminal key reporting, +continuous subcell animation, renderer upgrades, broad platform ports, new art +admission and publishing are not part of this queued first delivery. Before +implementation, read the original canonical and workspace policies and establish +shared-file ownership; preserve existing work, author files, saves and settings. diff --git a/docs/rendering/integration-roadmap.md b/docs/rendering/integration-roadmap.md index 651af1c1..1f082f78 100644 --- a/docs/rendering/integration-roadmap.md +++ b/docs/rendering/integration-roadmap.md @@ -1,10 +1,13 @@ # Dual-Presentation Integration This programme follows the accepted GPUI feasibility spike. It does not reopen -the spike or extend its S-numbering. Only T1 and G1 are authorized to begin; -later milestones are backlog, not implementation permission. Andrew's subsequent +the spike or extend its S-numbering. T1 and G1 have implementation authorization; +the cinematic schedule below has planning authorization dated 16 September 2026. +Other milestones remain backlog, not implementation permission. Andrew's subsequent cross-platform correction request covers the existing startup defect and the installation audit, not blanket authorization for those milestones or releases. +On 16 September Andrew additionally approved bounded Battle Results integration +in the existing game alongside cinematic work, with no publishing or new build. Engine coordination owns shared Engine APIs and integration. Last Legend - Game Design owns presentation priorities and coordinates with Last Legend - Lore. @@ -14,13 +17,1001 @@ and acceptance stay in the private game-docs repository, not this document. | Entry | Scope and owner | Dependencies | Status, acceptance evidence and remaining work | | --- | --- | --- | --- | | T1 | Retained-cell native terminal composition and scrolling. Engine coordinator. | Accepted overlay/Unicode/output fixes; matched Garden fixture. | Merged at `d0c4f0c`. Matched medians H 0.575-0.580 ms / V 0.491-0.565 ms, exact payload/style equality on 216 frames, full Engine and bounded integrations pass; about 2.1 MiB additional retained process memory. Andrew subsequently playtested and confirmed it was "absolutely brilliant and fast"; [author acceptance receipt](https://github.com/ichiloto/engine/pull/94#issuecomment-5662733694). This is observed responsiveness, not measured end-to-end latency or Linux/WSLg acceptance. See [validation](t1-retained-cells-validation.md). | -| G1 | Independent graphical battle layout, static arena and graphical combatants. Engine integration with Renderer; Game Design and Art. | Agreed canvas contract and matching fixtures; native implementation; approved graphical layout/assets. | Accepted and integrated into the normal local Game, including native UI, right-aligned resources, recipient-side feedback and target cursors. [Current contract and validation](graphical-battle-g1.md) is authoritative; superseded temporary runtimes are removed. Latest accepted Engine source passes 2079 tests on PHP 8.4/8.5 with one existing skip each. Game production-binding checks pass 11 tests / 392 assertions; native macOS encounter and corrected Cure placement passed. Broader platform/outcome coverage and future actor-cursor polish are explicitly limited there. Local integration is not remote publication. | -| G2 | Battler animation and combat feedback. Engine/Renderer with Game Design and Art. | G1. | Backlog, not authorized. Needs scoped brief and animation/feedback acceptance evidence. | -| G3 | Graphical summon presentation. Engine/Renderer with Game Design and Art. | G1/G2. | Backlog, not authorized. Needs scoped summon presentation and terminal parity gates. | -| G4 | Complete graphical field actors, objects and environment. Engine/Renderer with Game Design and Art. | Shared field presentation contract and asset coverage. | Backlog, not authorized. Needs coverage inventory and field acceptance. | +| G1 | Independent graphical battle layout, static arena and graphical combatants. Engine integration with Renderer; Game Design and Art. | Agreed canvas contract and matching fixtures; native implementation; approved graphical layout/assets. | Accepted in the normal local Game, including right-aligned resources, recipient-side feedback and target cursors. Shared native controls apply to all 11 authored troops. The approved four-creature/six-background expansion covers 14 authored encounter contexts with explicit location bindings; Cryptic Ruins/Loch Ness remain deferred. [Current contract and validation](graphical-battle-g1.md) is authoritative; superseded temporary runtimes are removed. Engine passes 2097 tests on PHP 8.4/8.5 with one existing skip each; Game artwork/presentation checks pass 54 / 2884 on both. Prior native macOS acceptance covers one illustrated encounter and corrected Cure placement, not a new all-encounter playthrough. Platform/outcome limits and actor-cursor polish remain explicit. Local integration is not remote publication. | +| G2 | Battler animation and combat feedback. Engine/Renderer with Game Design and Art. | G1. | Backlog, not authorized. Scoped brief: the [effect animation plan](../effect-animation.md) (Phases 0-2). Still needs animation/feedback acceptance evidence. | +| G3 | Graphical summon presentation. Engine/Renderer with Game Design and Art. | G1/G2. | Backlog, not authorized. Scoped brief: the [effect animation plan](../effect-animation.md) (Phase 2 renders summons through the unified session, making this a content and acceptance gate). Still needs terminal parity gates. | +| G4 | Complete graphical field actors, objects and environment. Engine/Renderer with Game Design and Art. | Shared field presentation contract and asset coverage. | Backlog, not authorized. Needs coverage inventory and field acceptance. Includes the queued [controller-ready input and normalized movement](#controller-ready-input-and-normalized-movement) work, not started. | | G5 | Graphical interface and game-wide presentation coverage. Engine/Renderer with Game Design and Art. | G1-G4. | Backlog, not authorized. Needs interface coverage and complete presentation audit. | | G6 | Packaging and supported-platform readiness. Engine/Console/Renderer owners. | Accepted presentation coverage. | Full delivery remains backlog. Shared GPUI maximize/restore corrects the blanket non-macOS rejection; the accepted optimized build is installed locally through the existing Engine manifest. This is macOS validation only. Players should receive a verified platform package, not compile Rust; published artifacts, a Console installer, runtime dependencies and Linux/WSLg validation remain undelivered. Native Windows also needs a correct process transport. No release or distribution publication is authorized. | T1/G1 completion will not mean the programme is complete. Publication follows existing develop -> main PR flow. No remote working branches, release branches, tags or releases are authorized by this roadmap. + +## Approved Battle Results integration + +The accepted Results artwork is now connected to the normal Game catalog, +not a separate runtime. Engine captures immutable EXP, level, stat and skill +facts at the shared award boundary, plus rolled drops and actual inventory/gold +retention. Playback owns only reveal timing, pages and completion; repainting, +re-entry and skipping a reveal never award anything. EXP remains the existing +full per-travelling-member award, including reserves, not the Art demo's split. + +The optional Results skin projects Primary, Level Up, Learned Ability and +explicit Special Reward events over the actual final battlefield. No special +rarity is inferred and current Game content supplies no special-event metadata. +Terminal results expose the same facts and event order with explicit overflow +pages. Shared source-budget checks run before battle-entry effects. The existing +renderer protocol and installed binary are unchanged. + +Andrew approved verified admission of eleven unchanged one-density UI images +despite the unavailable packaging tool/noncanonical sealed package and absent +licence field. The exact Game receipt records that narrow exception. Character +portraits are not included in that particular batch. The formerly empty portrait +mapping was an unfinished integration step, not a rejection of earlier artwork. +Kaelion's completed Menu.png (512x512) and Dialogue/Neutral.png (768x960) exist +in Art's `output/kaelion-portraits-v1-20260915/package`; all 34 sealed files pass +hash verification. Do not reopen the existing portrait visual/finishing review. +Andrew explicitly answered "Approve the two-file integration" to importing and +wiring this pair with verified hashes despite the unavailable packaging tool +and missing licence field, limited to these two files and no build or publishing. +The existing Game owner has imported the unchanged pair and mapped Kaelion's +menu portrait to Primary Results and neutral bust to Level Up/Learned Ability. +The separate two-file approval, hashes and exceptions are recorded in the +existing Game Results receipt. No permission is pending for this pair. Silent +macOS composed-frame checks verified both families and overlay removal; this +is not a new real-battle playthrough. Other batch exceptions do not silently +extend to it. +The approved portrait scope is the whole starting party: eight distinct menu +and neutral dialogue/bust files. Kaelion-first established the format; it did +not reduce that scope. Andrew's correction, "All portraits should have been +settled", was reconciled with his remaining-three instruction in the existing +Last Legend - Art chat (user turn `b6f1714a-755b-4b03-8ce4-0f741e50b5c1`). +The existing Art producer delivered the remaining six portraits in +`output/starting-party-portraits-v1-20260916/package`; the closed 35-file seal, +individual PNG hashes and coordinator contact-sheet checks passed. Andrew's +exact answer, "Approve six-file integration", accepts the unavailable packaging +tool and missing explicit licence field for these six files only. Game imported +them unchanged and activated both families for Liora, Drazek and Seraphis in the +normal checkout. All eight starting-party portraits are now accounted for; +later main-cast descriptions are outside this batch. No further production, +design or import approval is pending. Neutral initials remain only for actors +without configured portrait art, not as a substitute for this delivery. + +Engine now separates catalog path/crop validation from concurrent-frame budgets +through shared `CanvasImagePreflight`: battle/HUD, Primary's at most four menu +portraits and each event's single bust are checked separately before entry +effects. This also covers native UI over a text battlefield. The renderer's +unchanged limits independently bound each snapshot's unique decoded sources +(64 MiB / 1024) and guarded prepared regions (64 MiB / 4096). Crops, clipping and +zero opacity do not remove source cost. Cache eviction drops cache ownership, +not queued/displayed snapshot references; this is not a total-live-memory ceiling. +Results currently replaces stages without overlapping portrait families. Future +crossfades must budget their actual union. No limits were raised, no artwork was +shrunk, and no renderer/protocol or binary change was required. Whole-party Game +frame checks and native inspection are recorded in the integration record below. +Four admitted hover/pressed/disabled/unknown-state assets remain reserved, not +falsely represented as active input or unknown-progression features. + +Andrew has approved the Art kit's focused/unfocused/disabled distinction: +"The art kit's focus distinction is approved." Preserve steady focused fill, +bright edge, quiet unfocused appearance without a cursor, +and muted disabled appearance without a cursor. This is design acceptance, +not a claim that every kit state or additional asset is already wired natively. +Andrew's 21 September refinement reserves cursors for list items, not buttons; +the Results confirmation retains its steady fill/border without a cursor. + +Engine/Game tests, Renderer headless checks and silent native frame inspections +are recorded in the +[battle integration record](graphical-battle-g1.md#battle-results-integration). +Full native battle playtest acceptance remains a separate gate. This does not authorize all +G5 work. After Results verification, resume captured-entry cinematic routes and +the representative rescue, without displacing independent Game milestones. + +## Approved Pause upgrade + +**Next after the first playable rescue.** On 17 September Andrew directed, +"Then after that the Pause menu." Engine owns this next bounded runtime slot, +before the remaining representative cinematics and controller-input work. +Independent Game M3 work continues alongside it. Reuse the already-approved +delivery below; do not reopen design approval or commission another Art batch. + +The first rescue technical gate closed on 17 September after the corrected +ordinary native recheck below. Pascal, the existing Engine subagent, completed +the Pause implementation and focused checks; the coordinator reviewed it and +ran the full Engine suites. Four-option focus, confirmations, shared Config +interaction, retained-frame composition and scoped Run suspend/resume are +implemented. Runtime edits and Game's serialized muted live checks use separate +windows. The bounded terminal and macOS native technical checks below are now +complete; this is not a claim of Andrew's personal playtest acceptance. + +Validation checkpoint after the Config opacity correction: 2,411 Engine tests +pass on each of PHP 8.4.21 and 8.5.10, with one existing skip (16,466 and 16,465 +assertions respectively). Full PHPStan on both versions and the whitespace check +pass. Full suites use the established process-local 1 GiB memory budget; an +initial default-128 MiB run exhausted memory, not an assertion. No persistent +configuration was changed. These include real ATB retention, Config lifecycle, +ordinary battle completion, partial-entry disposal, title/quit abandonment and +failing cleanup callbacks. Linux/WSLg remains untested. + +Sibling compatibility checks on both PHP versions also pass: Console ran all +eight unchanged scripts (six pass, two Game-dependent skips); Editor reports +919 passed / 50 skipped / 4,664 assertions. These headless checks used the +current sibling Engine classes/helpers through a temporary test bootstrap, +without vendor edits or pinning normal Game data. Game-dependent optional paths +are not covered by this compatibility run. + +The isolated 135x36 terminal check has completed with normal exit 0. An authored +Service Road Vermin battle played through Victory/Results and returned to the +field without Pause or forced outcomes. A second battle verified Resume, the +pause shortcut, Config return focus, Cancel-default confirmations and return to +Title. Live paused combat state remained unchanged; the longer deterministic +freeze/remaining-duration cases are covered by the headless suite. + +The first native pass also completed an ordinary victory and field return, +Resume/button shortcut, Config return focus, both Cancel-default confirmations +and confirmed Exit with normal exit 0. Root Pause/confirmations were centered +and opaque without a full-screen dimmer. It exposed one visual defect: Config's +owned Console runs retained null backgrounds when projected into the canvas, +where null means transparent. The fix reuses the existing battle adapter's +opaque-owned-cell conversion, preserving explicit selection colors and the +uncovered battlefield. Both live sessions closed before corrective edits; +normal config plus all nine saves matched their pretest hashes. + +The corrected native recheck passed in reduced-motion mode using the same normal +checkout and existing renderer. Config's main and description panels were fully +opaque, with no battlefield/HUD bleed-through. Selection and description updates +worked; Back restored Pause with Config focused. Root Pause retained centered +labels and visible selection without a full-screen dimmer. The Title confirmation +defaulted to Cancel; deliberate confirmation returned to a clean Title screen, +then normal Exit closed the process with exit 0. Combat context/gauges remained +unchanged through the pause; all ten normal config/save hashes still matched. +No test window remains open. Captures were inspected inline, not saved as native +PNG evidence. These checks are macOS only and do not complete the broad Game +suite or the pending graphical Config redesign. + +Game owns the normal-checkout kit import and catalog wiring. Andrew approved +the two remaining unchanged images, `Controls/ButtonNormal.png` and +`Controls/FocusRing.png`, with "Game can go ahead and import the approved kit." +This answered the exact two-file unavailable-packaging-tool/missing-licence-field +exception; it is not a blanket exception for other assets. The approval and +typed catalog contract have been relayed to the existing Game owner. Game has +completed the two-image import and typed `BattlePauseSkin` catalog wiring. Both +copied hashes match the approved package; all 21 previously admitted UI images +are unchanged. Six focused asset/catalog/Results tests pass with 271 assertions +on each of PHP 8.4.21 and 8.5.10. The existing Game UI receipt records the exact +exception and provenance; this is not formal packaging-tool admission or native +acceptance. No new build, publishing, substitute assets or further design +approval is authorized. + +At 06:15:45 Africa/Lusaka on 17 September, Andrew's running game crashed on +ordinary post-victory cleanup at `BattlePauseState::exit()`: its loaded Console +class lacked the newly added `removeLayer()` method. Shared Engine edits during +his playtest exposed mixed loaded/new source. Fresh Engine and Game processes +now resolve the same Console class with that method present. Composer autoload +regeneration does not reload classes in an existing process. The coordinator +accepted responsibility; Andrew then explicitly lifted the incident hold and +waited for implementation/validation. Coordinate shared runtime edits with +playtest windows instead of adding missing-method or hot-reload workarounds. +Cleanup must release only resources actually owned by an entered Pause state; +ordinary battle completion and repeated disposal have dedicated regressions. + +The existing Art producer has delivered the root overlay, both confirmations +and interactive browser preview in `output/pause-ui-v1-20260916/package` under +the Art workspace. The corrected delivery passes 23 browser checks, including +72 button-centering samples. The Pause delivery is complete; the producer's +next menu delivery is the Config extension below. No new Pause Art dispatch +or design approval is needed. Browser checks are not +native/runtime acceptance. + +The compact centered root overlay uses Resume, Config, To Title and Exit from +the approved UI kit. Resume starts focused. Preserve the steady highlight/cursor, +restrained 140-180 ms opening fade/scale and reduced motion. Keep the suspended +scene visible without a new full-screen dimmer. No new character/background art +or replacement Config design. Return previews through the Engine coordinator. + +Runtime wiring is Engine-owned, separate from Art's browser proof. Extend the +existing pause state/input path, never a parallel pause manager. Pause at the +root resumes; Config returns to Pause with Config focused. Back cancels a +confirmation, and Cancel is initially focused for both To Title and Exit. Use +the approved unsaved-progress warning, existing title-return/normal shutdown +paths and no autosave. Consume opening/closing input; pause-menu input and UI +motion continue while ATB, actions and scene progression remain suspended. +Andrew subsequently rejected the redundant "Confirm / Back" footer. Omit it +from both terminal and graphical Pause/confirmation presentation and the Art +preview; do not replace it with another hint strip. Existing controls and the +actual menu/confirmation choices remain unchanged. +This footer-only follow-up passes 26 Pause presentation/lifecycle tests with +2,359 assertions on each of PHP 8.4 and 8.5. No additional native window was +launched for the text removal; the earlier live validation above predates it. + +Implementation acceptance checks from the original source review: +- Resume restores the existing running state rather than calling battle entry + and `engine->start()` again, preserving active-time context and queued actions. +- Opening Pause stops the current Run execution before it advances gameplay. +- Config reuses the settings interaction with an explicit return context, not + a duplicate implementation or a detour through live field gameplay. +- Resize repaints without re-entering Pause or resetting focus/confirmation. +- Paused alert/feedback deadlines preserve their remaining duration, and Config + removes only its owned cells on return, resize or abandonment. + +Preserve Results integration and the active cinematic/Game milestone work. +Runtime/native/terminal acceptance remains Engine-owned +within the bounded upgrade, not blanket G5 scope or publishing permission. + +### Config and shared menu Art + +On 17 September Andrew asked Art to provide Config assets and assets for the +menu system generally. The existing `Create Last Legend UI art kit` producer +accepted the Config-first extension: inspect actual settings/controls, reuse +the approved kit, and deliver composable assets, state/sizing specifications +and an interactive preview through the Engine coordinator. Shared main-menu +list/detail components follow, then secondary-screen adaptations based on the +existing menu inventory. Preserve the separately active title-screen proposals. +Update the existing Art specification rather than creating competing plans or +flattened screen images. This production scope does not authorize runtime +integration, new imports/licence exceptions, paid providers or publication. +The current Config readability correction does not wait on Art or replace its +design work; settings behavior and presentation remain separate. + +Art's source inspection confirmed eleven settings in one ordered list, with +descriptions below and immediate persistence. Preserve that interaction rather +than inventing categories, Apply/Reset actions or settings. Existing frames, +selectors, focus states, slider and scrollbar assets cover the first delivery; +the Config composition extends the existing kit without changing its sealed +base or the accepted Pause/Results assets. Text, values and control hints remain +independently composable. + +The first Config Art delivery is complete in the existing Art workspace at +`output/ui-art-v1-20260915/config/Config-Review.html`. Its adjacent `spec.json`, +component reference and asset-reuse inventory describe the native adaptation; +the existing `HANDOFF-NOTES.md` holds the wider menu sequence. No new raster +assets were generated: eighteen existing identities are reused. Sixty silent +headless-browser checks pass, including complete visible-row counts at three +canvas sizes, opacity, focus, reduced motion and centered labels. This is a +browser/spec delivery, not native integration or Andrew's design acceptance. +The shared Main Menu/list-detail Art foundation is also delivered at +`output/ui-art-v1-20260915/menus/Main-Menu-Review.html`, with the same adjacent +specification/component/source-inventory structure. It preserves the established +Main Menu arrangement, all source fields and approved portrait apertures, while +distinguishing Order's source mark from current input focus. Fifty-seven silent +browser checks pass; the coordinator inspected the Main Menu and scrolling +specimens and verified all 23 extension checksums. The list/detail specimen is +component demonstration data, not a completed inventory/shop runtime. Existing +sealed packages remain unchanged; no new raster assets or runtime changes were +part of this delivery. Source-specific Items, Equipment and Status compositions +are the next Art slot under the existing inventory, not a new visual direction. + +Source inspection during those adapters found an existing Items paging gap: +`ItemSelectionPanel` advertises page controls and exposes counts, but writes the +entire item array without a page slice, counts rows using outer window height, +and refreshes its page title only when items are assigned. The Use, Discard and +Key Items modes only handle vertical item navigation, not page actions. Runtime +adoption must resolve visible capacity, selection/page ownership and input +together for all consumers, with terminal and graphical regressions for empty, +overflowing and changing inventories. Art may show supplied page metadata, not +claim working gameplay pagination. Effective character/equipment example values +must come from the existing isolated Game actor setup, not raw authored base +arrays or an Art-side reimplementation of stat curves. This records integration +work; no runtime paging fix was made in the Art delivery. + +**Current menu integration checkpoint (21 September):** shared, theme-driven +Main Menu, Equipment and Status presentation is implemented locally. Character +focus uses the same selected background as commands across the card interior, +including padding, with no per-name cursor. Party-order source marking remains +distinct from destination focus. Optional frame `borderWidths` (left/top/right/ +bottom source pixels, separate from corner `cuts`) partition opaque straight-edge +backing below selection and decorative edges above it; corners are painted once. +Widths reconcile against current artwork and density, not historical image bytes. +Shared Alert, Confirm and +Select modal presentation keeps supported graphical menus underneath instead +of replacing the whole screen with terminal cells. Existing modal controllers +retain input and outcomes; unsupported text-entry/dialogue or oversized content +still uses diagnosed terminal fallback. This is not complete menu coverage. +Single-confirmation alerts center their message and a modest-width confirmation +button below it; labels remain centered independently of cursors. Andrew's later +21 September correction supersedes the earlier bottom-right half-width placement. +Choice dialogs retain their separate layout. Last Legend sets `showInputHints` +to false: keep useful descriptions, remove persistent helper strips and use the +existing Controls entry for full binding lookup. The shared hint mechanism stays +available to other themes (default true); this does not change input bindings. +Location and the bottom help panel share a measured row height. Removing hints +must not shrink help below Location or leave a gap beneath the fourth party card; +longer content in either bottom panel grows both together. +Oscillating cursors belong to list items, not buttons. Shared row composition +distinguishes command-list entries from buttons while reusing centered labels +and theme treatments. Equipment actions, alert/confirmation buttons, Results +confirmation and Pause confirmation buttons use steady focus without a cursor; +Main Menu, vertical choice lists and Pause root navigation retain list cursors. +Earlier focused validation on PHP 8.4 and 8.5 passed Engine 276 tests / 6,776 +assertions and Game 474 tests / 39,897 assertions. After the footer/list-button +refinements, the four shared menu suites pass 135 tests / 3,931 assertions and +the actual Game Main Menu suite passes 18 tests / 476 assertions on both PHP +versions. Shared presentation static analysis passes. A fresh silent macOS GPUI +replay visually confirms aligned bottom panels in command/character focus, +full-card highlighting, absent helpers, and the prior alert placement (superseded +above). This was exported-frame visual +validation, not a fresh interactive Game playthrough. The replay shut down +cleanly; no Game audio/config/save changes or new build were used. +Linux/WSLg remains untested. The broad Last +Legend suite was not completed because of the unrelated 180,000-battle +simulation baseline. + +Andrew approved the current UI kit for local integration on 21 September; the +exact approval and current specifications are in the existing Art +`output/ui-art-v1-20260915/HANDOFF-NOTES.md`. No repeat visual approval is pending. +Engine owns shared adapters and native acceptance; Game owns theme admission and +source-bound validation. This does not grant publication or waive admission gates. +Items now has a shared graphical adapter over its existing command, inventory, +target and quantity modes. Terminal paging slices actual inner capacity and +follows a clamped selection; horizontal navigation changes pages in Use, Discard +and Key Items. Use and Discard share the existing bounded quantity selector in a +modal, followed by the same Confirm dialog; a singleton skips only the amount +picker, never confirmation. The graphical quantity modal centers the amount and +Continue button independently of a vertically aligned up/down chevron pair immediately +right of the number. There are no left/right quantity arrows. Cancelling either step +leaves inventory untouched. +Fine/coarse axes remain shared with Shop, and the owner revalidates the current +stack and target before applying the confirmed amount exactly once. +The graphical list follows that same selected item without hiding later records. +The regular-item view consistently excludes equipment and quest-critical key +items, including after sorting, discarding and stack depletion. Empty Key Items +remains a reachable read-only view. Descriptions retain the complete source text. +Items, Equipment, Abilities, Magic and Config now use a shared PHP-owned two-line +Info reading model. The panel height is fixed; the semantic Info action advances +whole pages and wraps after the final partial page. Selection/source changes +reset reading, rendering never advances it, and descriptions plus status remain +reachable. Terminal ranges belong in Window help; graphical ranges occupy frame +padding (or Config's Description heading), not extra prose rows. Focused +Items/quantity/consumption checks pass on PHP 8.4 and 8.5. Silent macOS native +frame replay checked commands, late inventory, quantity and the item-use alert; +the alert restores the item description instead of retaining the inactive +quantity prompt. No new Game artwork or separate build is required. + +Config now uses the existing shared `ConfigMenu` owner with one graphical +composition for Main Menu and Battle Pause, using the same centered 1100x700 +menu envelope in the 1350x720 logical canvas as the other full menu screens. +Engine checks cover lifecycle, +capability fallback, bounded canvas geometry and complete setting values; +Game checks cover all 11 settings, isolated persistence, Main return focus and +Pause -> Config -> Pause without resetting the suspended battle. Silent macOS +native replay checked first/last settings and the retained battlefield. This is +not a full native gameplay playtest. Config uses theme-neutral slider, +scrollbar and left/right chevron fallbacks. Andrew's later six-file technical +import approval is recorded with the shared-controls correction below. Andrew +approved the unchanged up/down chevron PNG pair on 22 September; Game binds +them through the existing shared navigation roles alongside the admitted Divider. +The existing Game receipt retains the pair-only tooling/licence exception and +historical import evidence, not runtime byte locks. No protocol changes, new +builds or normal-game configuration/save changes were part of this slice. + +The combined Engine regression selection passes 274 tests on each of PHP 8.4 +and 8.5. Game's focused Main/Items/Config selection passes 30 tests on each, +and its six presentation/rescue suites pass 465 on each. The broad Last Legend +suite was not completed because of the unrelated 180,000-battle simulation +baseline. Linux/WSLg remains untested. Oversized non-Info structures can still +diagnose a terminal fallback; the shared Info paging described above replaces +that limitation for descriptions/status. Editor presentation authoring remains +explicit follow-on work, not a completed runtime feature. + +Abilities and Magic now share a graphical composition over their existing +owners, with all tabs, learned/ready counts, learning requirements, +sorting and field-spell target selection. Actor portraits retain stable role +bindings; optional skill icons reuse the game's already-admitted book icon. +Magic's displayed requirement progress/status now receives the same story flags +as learning. The shared terminal ability/magic list no longer double-offsets +scrolled rows. Learning rules, casting outcomes and field target ownership are +unchanged. Magic's graphical detail panel no longer displays Source; authored +source notes and learning data remain intact. Andrew's 22 September correction +separates directional controls from numeric comparisons: `navigation.previous`, +`navigation.next`, `navigation.up` and `navigation.down` supply chevrons for +Config values, scrolling and quantity controls; `comparison.*` supplies stemmed +arrows for current-to-preview and stat-change indicators. Neither role borrows +the other's artwork or the Unknown item icon. Themes that previously bound +comparison art under `navigation.*` should move those bindings to `comparison.*`. +Shared slider metrics (`sliderTrackHeight`, `sliderFillHeight`, `sliderThumbSize`) +keep the thin level fill inside the track and the thumb centered on its endpoint; +track and thumb artwork remain separately replaceable, with the thumb painted +above the fill. Config and quest detail now use one shared themed scrollbar, +replacing the journal's separate flat strip. Theme metrics (`scrollbarWidth`, +`scrollbarTrackWidth`, `scrollbarMinThumbHeight`, `scrollbarArrowGap`) provide a +24-pixel rail, 12-pixel artwork strip, 28-pixel minimum thumb and 4-pixel chevron +gaps by default. Extents come from the existing PHP row/reading owner; the +display-only control neither scrolls nor changes selection, and terminal +pagination is unchanged. Andrew subsequently replied "Yes import them" after +the exact six Config assets and missing packaging-tool/explicit-licence-record +limitations were explained. That approval covers unchanged SliderTrack, +SliderThumb, ScrollTrack, ScrollThumb, ArrowLeft and ArrowRight PNG imports with +verified hashes and local image checks; it does not broaden the earlier +up/down-pair exception or authorize other assets. Game now binds these through +the existing shared frame and navigation roles. Import integrity and native +artwork verification remain separate checks. The shared-controls correction passes +234 Engine tests / 10,209 assertions and 69 Game Main Menu tests / 7,858 assertions +on each of PHP 8.4 and 8.5, with scoped static analysis clean. Silent macOS +renderer-only replay checked Config and quest detail at the first and last +positions in a 960x540 canvas; chevrons, text clearance and thumb endpoints were +visually inspected. This used the existing admitted up/down icons and fallback +track/thumb surfaces, not the pending artwork. Both preview windows closed; +normal audio configuration and saves were untouched. Linux/WSLg was not tested. +The subsequent approved six-file import passed the closed source roster, +destination SHA-256 and local decode checks. Game's image-backed integration +selection passes 71 tests / 7,977 assertions on each of PHP 8.4 and 8.5; +the focused 150 Engine tests / 5,844 assertions also pass on both versions. +Silent macOS renderer-only inspection then verified the actual imported slider +at 0% and left/right chevrons in the normal 1350x720 canvas, plus the rounded +brass scrollbar at both ends in a 960x540 overflow canvas. The additional Quest +image-backed screenshot timed out; the Mac locked before the 75%/100% slider +screenshots, so those checks remain PHP composition/geometry evidence only. +The isolated filled-slider capture checks pass 1 test / 27 assertions per PHP +version. All preview processes exited cleanly and closed their windows; normal +audio/input configuration, saves and unrelated credits work were preserved. +No six-file import permission remains pending. This bounded verification is not +whole-screen pixel parity, Linux/WSLg validation or final artistic acceptance. +Shared word-aware wrapping +keeps authored notes and hyphenated item +names intact where they fit, using the same Canvas cell budget for measurement +and painting without discarding text. + +The latest consistency regression selection passes 609 Engine tests / 13,917 +assertions and 504 Game tests / 46,904 assertions on each of PHP 8.4 and 8.5. +After the stacked quantity-chevron correction, the affected Engine selection passes +139 tests / 4,505 assertions. With the approved image pair bound, Game Main Menu +passes 69 tests / 7,857 assertions and the focused Engine quantity geometry/opacity +selection passes 6 tests / 102 assertions on both versions. Scoped static analysis +and diff checks pass. Tests cover item use and +discard quantity/cancel/confirmation, complete Info paging and binding defaults, +all actor books, learning charges, story gates and retained menu behavior. +Silent macOS exported-frame inspection verified quantity with aligned open up/down +chevrons to its right; the 22 September image-backed replay confirmed wider shapes +replacing the narrow font fallback without moving the value or Continue button. +Earlier inspection verified the shared +Confirm dialog, centered alert OK, Equipment comparison arrows, Magic without +Source, and full-size Config both standalone and over the paused battlefield. +Every replay closed with exit 0; no Game audio/config/save changes or new build +were used. This is not a full native gameplay, Linux/WSLg, physical-controller or +Editor-authoring acceptance; the earlier broad-suite limitation remains unchanged. + +Quests, Records and Controls now have shared graphical adapters over their +existing state/input owners. Andrew's 21 September correction supersedes the +graphical Quest list-or-journal layout: navigation remains visible on the left, +with independently styled identity, Description, Objectives and Rewards on the +right. Semantic sections supply text and icon roles together; the painter does +not infer meaning by parsing formatted strings. The game reuses its admitted +book and inventory art through theme bindings, with neutral objective ring/check +fallbacks rather than Unknown artwork. The detail viewport uses a scrollbar and +steady reading focus without a redundant text footer. Records keeps the existing +Info panel with dedicated reading focus. Both use a PHP-owned source-text +position and measured pages for complete terminal/native scrolling, +including long objectives, rewards and earned reports. List previews explicitly +indicate shortening and retain complete text in the detail view. Back restores +the same list selection; tab changes retain the existing owner semantics. +Secret achievements and undiscovered/unearned knowledge remain filtered before +presentation. Structured quest-item rewards now use existing reference/name +resolution and quantity semantics without instantiating reward objects merely +to display them; granting and progression are unchanged. + +Terminal Quests and Records reuse the existing horizontal tab component and +retain their established navigation. Pagination belongs in the corresponding +Window help/border field, never in its content rows. The current Window supports +bottom-left help, so that approved fallback is used without fake right-padding. +Quest list/detail ranges and Records list/reading ranges use this path; content +now uses the freed rows. Native composition batches document text and objective +markers, and measures wrapped tab headers rather than assuming one text line. + +Controls is the dedicated binding lookup even when a theme hides inline hints. +It shows the primary owner-supplied control and every selected keyboard alias, +supports existing rebinding/defaults/cancellation, and distinguishes a failed +save from a successful session-only rebind. Consumed action edges cannot also +navigate or bind themselves. Control glyph roles remain replaceable through +the existing theme registry; absent art uses readable labels, not Unknown icons. +Current keyboard controls and replaceable display providers do not constitute +physical gamepad detection or support. No new controller mapping is installed. + +Final combined Engine regressions pass 450 tests / 12,683 assertions on each of +PHP 8.4 and 8.5, including horizontal tabs, border pagination, dense/wrapped +themes and complete journal text. Scoped static analysis is clean. Game's +focused menu checks pass 68 cases / 6,079 assertions; its six scoped suites pass +503 cases / 45,149 assertions on each PHP version. Silent native frame inspection +checked Controls lookup/listening and an explicitly supplied Xbox-family display +example, plus the corrected real Quest layout and Records/Field Index. These are +renderer-frame checks, not a full native gameplay or physical-controller test. +Normal configuration and saves were untouched; no renderer protocol or game +content/progression changes are part of this UI slice. Linux/WSLg remains +untested, and the earlier broad-suite limitation remains unchanged. +Main Menu Quit currently routes its chooser directly to title/exit; confirmation +parity with Pause is an existing behavior gap, not part of this presentation fix. +Editor TUI authoring of the theme and shared artwork-role bindings remains +planned in the Editor roadmap, not implemented by runtime composition. + +## Title presentation + +**22 September 2026: local implementation and measured motion trial; acceptance pending.** +The approved reference is Art's current `title-ui-v1-20260917/package/HANDOFF.md`, +`Title-Review.html`, `title.js` and `motion.js`, approved on 21 September with +"This will do for now. Lock it in." The older ZIP and superseded cloud/water +atlases are not the implementation reference. The list-only cursor refinement +applies to all five title buttons. + +Engine now has an optional `Data/Presentation/title.php` catalog with schema +`ichiloto.title/1`: shared menu theme, day/night backgrounds and finite atlas +subjects, plain effect registrations, logo/gleam, menu bounds and button layout. No Game drawing +callbacks or runtime image-hash locks are introduced. PNG dimensions come from +the current assets. The PHP-owned title clock selects local day from 06:00 +inclusive to 18:00 exclusive, checks the next boundary with at most one ordinary +clock check per second, crossfades with smoothstep over 1 second (0.16 seconds +for reduced motion), and retains its initial 0.28-second entry across destination +returns. Suspension pauses decorative elapsed time without later catch-up; +stop releases presentation ownership. Reduced motion retains original scenery +and atlas frame zero while omitting travelling birds. + +Host-local wall time comes from the shared `LocalClock`, not PHP's configured +logging/application timezone. It resolves `TZ`, the OS zoneinfo link or timezone +file, then optional ICU host detection, without shelling out or changing global +PHP state. Region identifiers retain daylight-saving rules. If no host source +is available, it reports the fallback through Debug and uses PHP's timezone. +The existing `get_local_timezone()` helper shares the same resolver. + +The graphical adapter projects the existing TitleMenu command labels, +availability and selection. Buttons remain centered without oscillating cursors. +Title Options and Config share `SettingsMenuPresentation` over their existing +settings owners; title still exposes seven options and Back. In-game Save and +title Load share `SaveLoadMenuPresentation`, using existing save-slot metadata +with full-card selection, empty/incompatible states and complete wrapped +descriptions. Save now participates in `GameScene` canvas delegation and the +shared themed-modal lifecycle; its earlier terminal-only state was not covered +by the title Load integration. Success and failure use the theme's distinct +status colors, and re-entry clears stale status without changing persistence. +Existing commands continue to own New Game, +load validation, authored Credits, Exit, music and persistence. Resuming after +Credits no longer resets the selected title command. No normal Game settings +or saves were modified during these Engine checks. + +Credits now has its own title destination rather than reusing typed character +dialogue. `CreditsContent` retains the authored `Data/credits.php` sections and +wording. GPUI uses a centered, clipped upward roll over the title backdrop and +a steady, centered Back button; a capable renderer without title artwork uses +the shared default menu theme. `CreditsPlayback` advances only in PHP updates, +pauses on suspension/native focus loss or an obscuring modal without catch-up, +and returns to the same title selection after completion or semantic +confirm/cancel/back. Reentry creates a fresh roll. Terminal and reduced-motion +playback reuse centered alert pages, including bounded wrapping and cancellation +of the entire sequence; no new artwork or renderer rebuild is required. + +Unnamed text-box prose defaults to top-center box placement with left-aligned +text, like named dialogue. Box placement and text alignment are independent; +callers' explicit positions remain authoritative. Map dialogue retains configured +positions. Timed cinematic narration likewise uses left-aligned text in a +top-center box; title cards retain middle-center placement and centered text. +Neither change alters authored text, typing/advance behavior or cinematic timing. +After the prose-alignment correction, the full Engine suite passes 2,905 tests +with one skipped on each of PHP 8.4 and 8.5. Regression checks cover left-aligned +typing and wrapped lines independently of box placement, with centered title +cards unchanged. Targeted PHPStan checks cover the presenters, modal/title owners and +text forwarding APIs. Coverage includes authored credit order, alternate themes, +long-list clipping, semantic dismissal, focus preservation, reentry, completion, +pause timing, terminal alert geometry and narration/speech placement. These are +headless composition and behavioral checks, not native visual acceptance. +Game's Title/Main Menu/Narrative integration selection passes 95 tests / 10,255 +assertions per PHP version, plus the focused authored Pokete acknowledgement +check (one test / six assertions). Actual event prose, map dialogue, five timed +narration cues and the Garden arrival title card are covered. Normal Game +configuration and save hashes are unchanged; this slice remains local and +uncommitted with no native launch or publishing action. + +Andrew confirmed proceeding with the five approved title PNGs after the +missing explicit licence field and unavailable `game-dev` packaging tool were +restated: "I approved the artwork I'm the one who asked you to implement the +background." The coordinator acknowledged that repeating the import question +was unnecessary. Game imported the five unchanged PNGs with closed-roster, hash +and local-decode evidence in its existing admission receipt; no approval is pending for these files or the +previously completed six-file Config import. Andrew also approved shared bounded +compositing for masked cloud movement, water refraction, screen-blended lantern +light and the logo-alpha gleam: "Recommendation Approved. Lets see how it goes... +if it doesn't work well we pivot to prepared animation assets". This authorizes +implementation and performance checks, not a claim that motion parity or +performance acceptance has already been achieved. Game's current registration +contains the approved cloud, waterfall, flame and gleam values. Shared V2 +`canvas_compositing` carries bounded offscreen image/fill/stroke operations, +source-over/screen blending, gradients, polygon/ellipse/current-image-alpha masks +and displacement grids. PHP owns time and emits resolved drawing data; Rust has +no Game paths, effect recipes or gameplay clocks. Replacing current PNGs remains +supported. Generic cold-work, source, raster and cache limits are enforced at +the shared boundaries, not relaxed for this title. Renderer README is the exact +wire/resource contract. + +Negotiated V2 `window_activation` now reports initial and changed native focus +after ready. Runtime retains that state and Title pauses behind focus loss, +showing modals, destinations and scene suspension without catching up hidden +time. Native focus observer verification remains pending. Activation is not +visibility/occlusion and must not be presented as such; actual OS-hide/occlusion +suspension remains incomplete. Native hover/pressed button +states, end-to-end title input/visual acceptance and Editor TUI authoring of the +new catalog also remain incomplete. + +Engine full suites pass 2,864 tests with one existing skip on each PHP 8.4 and +8.5 (28,207 / 28,206 assertions). PHP 8.5 needed a 512 MiB test-process limit: +its default 128 MiB exhausted memory in the existing full-field tile-limit test. +Scoped static analysis passes. The additional shared checks exercise operation +validation, finite values, resource/work caps, reference detachment, overlay +ordering, negotiated focus events and runtime input preservation. Existing title +checks cover local-clock boundaries, suspension/reconciliation, replaceable +atlases, distinct themes, centered buttons, shared Options, save cards and focus. +Game reports 87 title/main-menu tests / 8,701 assertions on each PHP version; +Renderer reports 119 tests plus separately executed actual-packet replay. + +All 240 real PHP packets (60 each Day, Night, transition and reduced motion) +passed native CPU preparation. Warm Day mean/p95 were 21.46/23.98 ms and Night +20.82/24.64 ms, excluding PHP, wire parsing and GPU presentation. The corrected +transition had 40.26 ms first-Night-image and 138.06 ms first-Night-composite +stalls; initial scene preparation was about 223-227 ms. Compositor peak during +transition was 42,340,486 bytes; decoded sources were 34,871,296 bytes in their +separate pool. These results do not meet smooth 60 Hz acceptance. No hidden +cadence reduction or renderer installation was made to disguise that result. + +Andrew's conditional prepared-animation fallback has a bounded Art technical +proof using current paintings/motion, not superseded atlases or a redesign. +Its four atlas pages total 21.153 MiB decoded and cover Day cloud poses/water, +Night lantern and gleam. Night sky/water are not included; full-title fit within +the 23 MiB additional-source target is not established. The proof is not admitted +or integrated. Intermediate cloud/gleam softness and weaker gleam are explicit +quality differences, not accepted equivalents. +Finite loops and interpolated cloud poses are visual tradeoffs requiring actual +time-separated/seam inspection, not claimed procedural equivalence. The source +masters remain unchanged. No new import question is pending; candidate quality, +native input/visual verification and Linux/WSLg testing remain unaccepted. +Current implementation is local and uncommitted. Silent title runtime harnesses +ran with audio backends disabled and save writes blocked, then closed at their +safety timeout. Protected settings/save hashes were unchanged. The raw executable +could not be bound by native UI automation; the later uniquely named candidate +bundle also timed out in native inspection. Neither run is visual/input acceptance. + +Andrew reported a vertical logo nudge at shimmer entry and exit. The authored +logo destination is unchanged, but native layout reproduction found that guarded +static images and composites round differently at their different texture sizes. +Renderer now uses the existing destination-device-pixel sampler and exact content +mask for images, composites and effected text, not a title offset. The corrected +release build has SHA-256 +`781046dfb2c57b0b472e75a0d625bd65606ff7dfaf30224d8914abc08d20d6a8`; +the existing package builder and Console installer verified and installed it at +the normal ignored local Engine boundary for development playtesting. The previous +installation was retained under `.backups/gpui-darwin-arm64-20260922T065300Z`; +no release was published. Its 121-test suite passes with three +existing ignored tests; regression coverage compares both texture resolutions +at five viewport fits and five display scales, clipping and cache lifetime. +This device-raster work is additional to the earlier CPU-preparation timings; +native performance and visual acceptance remain pending. Engine's 240-test +targeted suite passes on both PHP versions (1,048 assertions), including unchanged +registration over two shimmer cycles and three replaceable logo aspect ratios. + +The 22 September chamfer correction removes the shared rectangular panel backing +that obscured transparent frame corners. Artwork now owns both the opaque interior +and alpha silhouette across Title, save cards, prompt/Info panels, Config, Controls, +journals, skills and dialogs; only themes without frame artwork use a solid fallback. +Existing nine-slice corner/edge partitioning and selected-card insets are preserved. +No artwork edits or renderer rebuild are required. Synthetic cutout coverage checks +all four corners, selected/unselected saves, quiet panels, stacked dialogs, alternate +palettes and replaced image dimensions. The affected Engine selection passes 273 +tests / 8,247 assertions on PHP 8.4 and 8.5; the full suite passes 2,870 tests with +one skipped on each version, and UI PHPStan passes. Game's Title/Main Menu selection +passes 87 tests / 8,785 assertions per version, including real theme consumers. +These are composition/geometry checks, not a new native visual acceptance run. +Changes remain local and uncommitted. + +## Controller-ready input and normalized movement + +**Queued for G4 field readiness; scheduling only, not started.** Andrew requested +capture on 16 September 2026 without displacing the G-series or Last Legend's +remaining gameplay milestones. The implementation requirements are consolidated +in [the existing input contract](input-sources.md#planned-controller-ready-input-and-normalized-movement), +not another planning package. + +Use the next coordinated Engine/Renderer field-input slot after the current +cinematic movement/subject-ownership changes have passed acceptance, respecting +already-scheduled Results and next-slot Pause commitments. Complete before G5's whole-game input/UI +acceptance and G6 platform/package acceptance. G2/G3 and independent Game +milestones continue in parallel; this is not a prerequisite for their unrelated +work or a reason to pause the programme. No calendar deadline is invented. + +Engine owns semantic input state, bindings, movement timing and the shared +Player/Camera/session boundaries. Renderer owns native key transitions and +focus/reset reporting. Game validates real maps, events and cinematic routes. +Before dispatch, agree shared-file ownership and freeze the smallest compatible, +negotiated transition contract against the pinned GPUI APIs. Planning records +no chosen wire fields, dependency or renderer upgrade. + +First delivery is reliable GPUI keyboard held/edge state, simultaneous directions +and distance/time-normalized diagonal grid movement with safe collision and +context cleanup. Preserve terminal event-only compatibility, existing menus, +authored route timing and T1 composition. Physical controllers, enhanced terminal +key reporting and continuous subcell animation remain separate later work. +Future controller compatibility is a standing design constraint now. This queue +entry grants no implementation dispatch, art admission, publishing or release +authority; the coordinator resumes it at the stated milestone checkpoint. + +## Battle presentation direction + +Andrew's production direction is living combatants, expressive magic and summons +that are battle highlights: animated idle breathing, moving hair/robes, visible +weapons, glowing glyphs, energy streams, orbs, bloom and dramatic animated set +pieces. Begin with intentional, presentable simpler treatments and enrich them +incrementally. Static G1 artwork is the current delivery, not the final ceiling. +This direction informs G2/G3 and shared foundations; it does not release their +entire backlog for implementation. + +**Art acceptance, 21 September:** Andrew approved the revised Kaelion v3 idle +and eight Victory poses (Kaelion, Liora, Drazek, Seraphis, Nivira, Aeryn, Orwin, +Thalric). Drazek's subsequent crossed-arm/no-visible-knife choice supersedes his +knife-in-hand Victory only; use Art's updated export/receipt, not the original +batch entry. The existing Art workspace's `output/cowork-art-review-20260921/ +receipts.json` records the exact approval and provenance. Separate ready-pose +and portrait corrections are outside this approval. Game owns the bounded +admission/readiness assessment; visual acceptance is not a new import exception +or a claim of native playback. The approximately 85.5 MiB, 144-frame APNG is a +review master, not a runtime asset to import wholesale. Establish game-ready +packing, stable registration, shared playback and reduced-motion behavior under +the existing effect-animation/G2 sequence. Do not start a duplicate animation +system or treat approval as authorization for the entire G2 backlog. + +The bounded Game assessment is complete: all eight current Victory PNGs pass +Engine limits and local decoding; no images were imported. Aeryn's existing +Victory is the source of the approved artistic correction, not a byte-identical +or resize-only replacement. Shared actor pose-role selection and Results' final +battlefield capture lifecycle must be implemented before Victory can be wired; +Editor role selection/round trips are also absent. Adding art must not add party +members. The idle APNG exceeds the 16 MiB encoded limit; its 144 full-size frames +would consume 546.75 MiB decoded. It needs bounded atlas preparation against the +whole-scene resource budget, stable anchors and PHP-owned playback with pause, +cleanup and reduced-motion semantics, not an APNG or walk-animation shortcut. +Package/licence admission remains unresolved: `game-dev` is unavailable and the +Art directory has no sealed admission package or licence declaration. No limit +bypass, unknown-licence exception, asset overwrite or full G2 work is authorized +by this assessment. Keep this behind current UI and audited Phase 0 sequencing. + +- Separate combatant identity, targeting and combat state from the current pose, + animation frame and any weapon/effect attachments. Maintain stable registration + through pose changes without fixing the art pipeline to one animation technique. +- Keep presentation timing and semantic impact cues separate from frame drawing. + Reuse the existing summon compiler/playback boundary rather than making each + spectacle a bespoke runtime. Repaint, dropped frames and presentation fallback + must not lose or duplicate gameplay effects, resource spending or completion. +- Give concurrent effects independent lifetimes and ownership. Compose character, + magic, summon, camera, UI and audio contributions without one effect clearing + another or hiding essential target/impact feedback. Renderer capabilities such + as bloom remain explicit future work, not assumptions baked into game logic. +- Author deliberate simpler alternatives: static or limited-frame poses, compact + summon sequences and reduced effect density, with meaningful terminal and + reduced-motion counterparts. Preserve the action's identity, readable impact + and outcomes; shorter choreography may differ. Select a supported treatment + before playback, not by concealing invalid content or silently dropping cues. + +Current source boundaries: `BattlerArtwork` describes one image/crop, not a full +battler animation controller. `SummonPlaybackSession` already separates elapsed +time and cue traversal from rendering; the existing battle host still uses the +blocking `SummonCutscenePlayer` adapter. Animated battlers, graphical summon +choreography and bloom are not delivered by those foundations alone. Extend +these boundaries when implementing the relevant slice, retaining shared lifecycle +and cleanup behavior rather than adding scene-specific exceptions. + +## Cinematic gap schedule + +Andrew requested scheduling with **Ichiloto - Renderer Planning** on +16 September 2026, then replied "Approved." to the planner's proposed schedule. +The planner, Renderer, Game and Art owners supplied the dependencies below. +This is the next bounded presentation work queue, not a +new cinematic framework or blanket G2-G6 implementation approval. Scene-specific +decisions remain in the private Game presentation roadmap and existing milestone +handoff. The [cinematic contract](../cinematics.md#current-graphical-boundary) +records the current limitations separately from this planned work. + +| Slot | Owner and deliverable | Start condition and completion gate | +| --- | --- | --- | +| 0: preparation complete | Engine coordinates Game, Renderer and Art. Freeze one existing rescue segment, subject identities, entry/continuation, visual takeover, route/facings and minimum poses. | Game supplied source-checked routes, subject/facing and root-trigger entry proposals with outcome constraints. Engine's captured-entry walking/return and real-subject transform recovery are implemented and headless-tested, separately from visual suppression. No nested cinematic inside an active common event. Already-approved narrative corrections continue independently. | +| 1: Engine seam implemented | Engine preserves graphical field continuity, adds optional staged graphical providers and scoped presentation takeover for existing subjects. Renderer owns regression coverage against actual emitted frames. | Field continuity, staged sprites/sheets, real-subject leases, paired suppression, transform recovery, pre-finalizer restoration, captured-entry walking/return and handled application teardown pass local regression tests. Temporary battles suspend/resume the caller; inactive fields cannot repaint over battle. The first rescue acceptance is recorded below; later scene integrations are not implied. No production art dependency for ownership tests. | +| Parallel Art slot: complete | Art prepared and registered only the first rescue's required figures, support pose and visible prop using existing approved references. | Andrew approved integration of the exact corrected 13-image batch on 17 September, including its scoped technical admission exceptions. Game rechecks hashes and records provenance when copying; this is not blanket approval for later batches. Full cast animation sets are not prerequisites. | +| 2: First rescue technically accepted | Game imported the approved art and wired the existing scene through its three root callers; Engine reviewed actual native captures. | All 21 legal entries pass normal/reduced-motion live-script checks, plus expanded interruption/retry cases. Ordinary terminal and ordinary/reduced native functional walkthroughs passed. Corrected companion spacing passes the ordinary native recheck, with exact return/cleanup and untouched author files. No fresh reduced native run after that spacing correction or Andrew aesthetic acceptance is claimed. Use the normal Game checkout, not another build. | +| Complete: Pause menu technical gate | Pascal completed the existing pause state/input upgrade; the Engine coordinator reviewed it and passed full dual-PHP/static checks. Game completed the approved two-image import/catalog wiring and serialized muted terminal/native checks, including the corrected opaque Config surface. | Ordinary battle completion, suspended gameplay, Config return focus, safe confirmations, reduced motion, Title and normal exit verified on macOS. All test windows closed; normal saves/config unchanged. Andrew's personal playtest remains separate. Graphical Config/shared-menu Art proceeds in parallel with independent M3 work; coordinate any further runtime edits with playtesting. No new build or publishing. | +| 3: remaining representative scenes | Game and Art reuse the rescue foundation for the distant sighting and environmental response/evacuation. Engine adds only demonstrated missing graphical effect adapters. | Rescue gate passed and each scene's staging/art scope settled. Content may proceed in parallel; native tests remain serialized under one window/input owner. | +| 4: integrated acceptance | Engine consolidates Game state checks, Renderer native observations and Art review. | One normal pass per representative scene, terminal counterparts and applicable legal-skip/reduced-motion checks. Present one consolidated acceptance request, not questions scattered across tasks. | + +### Contract and scope gates + +- Apply the project-wide production-direction rule to each implementation, not + just its demo. The [cinematic extension boundaries](../cinematics.md#production-extension-boundaries) + cover replaceable poses, independently owned effects and future controlled + voice playback. Account for these in subject/session ownership now without + treating the approved rescue as authorization for a speculative framework. +- Reuse the existing interpreter, routes, camera, animation timing and cleanup. + PHP owns state; graphical providers observe it. Do not create a second route + for a visual clone or a second collidable entity. +- Graphical field availability must follow presentation ownership and authored + visibility/cover, not simply remove the current active-cinematic exclusion. + Keep dialogue and transition covers above the world, and clear old content + when menus, battles or map transfers replace it. +- Presentation suppression is map- and session-scoped, independent of authored + NPC eligibility, wandering and collision. In particular, do not filter + `visibleNpcs()` merely to hide artwork: it also supplies `npcAt()`. Normal and + direct movement/facing redraws must respect takeover. Release re-evaluates + current world state rather than forcing an evacuated NPC visible. A paired + pose must also suppress the corresponding ordinary player representation. +- Existing field sprites, source rectangles, explicit layers and complete + snapshots suffice for the first cell-aligned slice. Renderer source audit + found no required protocol/runtime change. Add a regression sequence using + existing renderer tests, not a new framework or rebuild by default. +- Smooth sub-cell movement, precise hand/head attachments, per-instance alpha + and canvas-over-field composition are real gaps, not delivered capabilities. + Do not bake the field into a canvas to conceal them. Scope any required + extension separately; the first slice uses supported cell movement and covers. +- Complete, legal skip, partial-start failure, controlled failure, transfer and + shutdown must release temporary cast/bindings/effects. Test re-entry, same-ID + NPCs on another map, camera/input restoration, pause/resume, resize, reduced + motion and no duplicate outcome writes. Preserve save guards and deferred + autosave; do not relax irreversible skip rules or undo legitimate story state. +- Temporary movement of real subjects requires scoped position/facing recovery + on failure, not just revealing their old artwork. Keep it map/session-bound. + Captured-entry walking and return must not become teleportation or duplicated + scripts per start cell. Game retry guards must distinguish a recorded choice + from completed scene outcomes; test failure before and after outcome writes. + +### Scheduling and evidence + +The coordinator owns remaining Engine implementation and dependent Game/Renderer +handoffs; Andrew is not expected to watch each task. No implementation release +is implied by a queue position. +Battle Results art has a separately approved next-slot reservation in the +existing private Game presentation roadmap: it follows the first bounded +priority rescue-art handoff, not every later cinematic or G2-G6 milestone. +Andrew subsequently accepted the missing character-appearance proposal with +"Love it. Approved." The existing Art producer owns the approved reference; +runtime pose/crop validation and production admission are separate from that +appearance acceptance. Exact character/staging details stay in private docs. +The first bounded rescue-art batch has delivered registered frames and previews. +On 17 September Andrew answered "Approve this 13-image integration", accepting +the unavailable packaging/formal-validation tool and missing explicit licence +field for this exact corrected batch only, with verified hashes and local image +checks. The Game and Art owners received the approval. Game must recheck the +current manifest and preserve the scoped exception/provenance at import. No new +build or publishing is authorized, and static registration is not native rescue +acceptance. The existing Art +producer subsequently delivered the approved Battle Results and Pause Menu +packages; the current Pause delivery is recorded in [Approved Pause upgrade](#approved-pause-upgrade). +Those Art deliveries are complete; the separately authorized Config/shared-menu +extension is recorded above. Art delivery is not runtime integration or +permission to change mechanics. Previews return through +the coordinator; no further design decision is pending. Andrew +approved the rescue batch's local resize, stray-transparency trimming and +sprite-cell alignment, then added +"And approved for similar future requests." Preserve originals and make no +design changes; this is not blanket appearance, admission or publishing approval. + +The first Engine implementation tranche passes 2,130 tests on each of PHP 8.4 +and 8.5 (12,364 assertions and one existing skip each in the final run), plus +PHPStan. Coverage includes staged route-frame progression, independent actor +identity, terminal provenance, explicit overlay/cover ordering, unchanged field +tiles during cinematics, completion/failure/legal-skip cleanup and same-ID +re-entry. Renderer state/composition regression passes 19 tests against nine +actual PHP-emitted FIELD snapshots, including mixed terrain/cast/effects/covers +and full scene replacement. Xcode's installed Metal toolchain became available +after its component-status check; normal compiler commands and the unchanged +headless build then succeeded with approved compiler-service access. No download, +shader workaround, protocol change or native window was needed. These Renderer +tests are macOS headless checks, not GPU/native acceptance. Real-subject takeover +and recovery are not delivered by these +tests. Game separately reports its retry fix passing 66 targeted cases / 5,334 +assertions, with combined narrative checks 157 / 7,788 on both PHP versions; +these are interpreter/state checks, not staged rescue or native acceptance. +At that earlier validation point, the rescue art had only preview cells. +Paired contact/release registration and the batch-specific approval have since +been completed as recorded above. Do not alter world coordinates or renderer +semantics to conceal a visual alignment mismatch. + +The original planning pass inspected source and received owner reports; it did not run +new runtime tests, generate assets or open a game window. Preserve current +uncommitted Engine/Game work and author saves/configuration. Automated native +launch requires verified silence without changing Andrew's normal settings. +Linux/WSLg remains untested; macOS evidence cannot establish other-platform +acceptance. The broad Last Legend suite was not completed because of the +unrelated 180,000-battle simulation baseline. The dark-player-on-dark-field +issue remains an art/background concern. No renderer protocol or game-content +changes were part of this planning pass. + +Local checkpoint verification on 16 September 2026: the combined Engine work +passes 2,205 tests (13,058 assertions, one existing skip) on PHP 8.5.10 and full +PHPStan analysis. Battle openings, cinematic foundations and Results are separate +local commits. This does not close the remaining cinematic or native acceptance +gates, run the broad Last Legend simulation suite, or authorize publication. + +Closure validation on 17 September 2026: the complete Engine suite passes +**2,272 tests / 13,418 assertions, one existing skip**, on each of PHP 8.4 and +8.5, with full PHPStan clean. These full-suite runs used a test-process-only +1 GiB memory allowance; the default 128 MiB runner exhausted memory in the +large combined suite. Product configuration and planner bounds are unchanged. +Coverage includes actual inverse walking/facing, stale/duplicate history, +blocked routes, reduced motion, subject exclusivity, real-subject rollback, +throwing/reentrant cleanup, caught startup/loop errors, battle suspension, +return, abandonment and inactive-field presentation isolation. Handled crashes +are covered; OS termination and power loss are not recoverable guarantees. +Renderer reran four existing full-replacement tests using its unchanged local +test executable; that is headless regression evidence, not fresh compilation +or native rescue acceptance. No renderer protocol change or new build was +needed. Game's regular non-simulation suite separately passes 468 tests after +integrating its accepted narrative, presentation and passage-reveal checkpoints +into local `develop`. The broad Last Legend suite was not completed because of +the unrelated 180,000-battle simulation baseline. Linux/WSLg remains untested; +no native/game/audio window was launched by this closure pass. + +Subsequent Game integration on 17 September imported and rehashed the exact 13 +approved images, added the three real cinematic root callers and shared rescue +choreography, and updated the persistent clear-position endpoint. The candidate +passes 112 route/production tests with 5,941 assertions, including all 21 legal +entries in normal/reduced-motion modes, pre-outcome interruption/retry, +post-outcome failure convergence for all three callers and task-order hydration. +These are headless checks, not native acceptance. Shared Editor/Console +validation now follows common events in their actual cinematic or legacy +context, retaining malformed-route, missing-reference and cycle failures. Strict +Last Legend validation passes on PHP 8.4 and 8.5. A route-constructor diagnostic +regression was corrected in Engine to retain the missing subject identity and +map; the original Editor preview assertion was preserved. Full Editor suites +pass 915 tests / 4,564 assertions with 54 skips on each PHP version; full Engine +suites, including the subsequent shared shop ownership and shutdown fixes, pass +2,370 tests / 13,985 assertions with one existing skip on each; full PHPStan is +clean. + +Game completed the muted ordinary-timing terminal walkthrough at 135x36, +including visible contact/withdrawal, captured position/facing restoration, +correct caller completion, resumed movement and menu/field reconstruction. +Its owned PTY exited normally with no staged actors left. Game's before/after +hashes matched for configuration and all nine normal saves. Native graphical +verification was initially deferred after a locked-Mac result. Andrew subsequently +confirmed the Mac was unlocked on 17 September; fresh coordinator and Game +computer-use inventories succeeded. The stale lock status is no longer a blocker. +Game owns the resumed muted native ordinary/reduced-motion validation; native +registration remains separate from functional acceptance. Both native runs have +now completed with ordinary and reduced-motion timing: exact entry position and +facing restored, camera following, no staged actors left, correct one-time +outcomes, resumed movement/menu use and normal Quit with exit code 0. The +ordinary run also confirmed that re-interaction does not replay the rescue. +Configuration and all nine current saves matched before/after each run; all +owned windows closed. Native captures revealed crowded companion blocking: +one companion obscures the brace holder, and the approach crowds the paired +rescue subjects. Art verified the approved hashes, crops and anchors; overlapping +opaque-content bounds and same-layer author order explain the obstruction. +Game corrected actual staged positions and collision-checked routes, preserving +the brace relationship, rescue outcomes and captured return. Expanded route and +retry checks pass 413 tests / 16,198 assertions on each PHP 8.4 and 8.5; the +coordinator independently reran that suite on PHP 8.5. One corrected ordinary +native recheck passed: the coordinator viewed the waiting, withdrawal and +release captures and confirmed both diagnosed occlusions were resolved. Exact +entry position/facing, camera follow, zero staged actors, correct one-time +outcomes, resumed movement/menu use and normal Quit with exit code 0 also pass. +Configuration plus all nine current saves match the immediate prelaunch baseline, +and the owned window/process closed. No image, crop, scale, opacity, layer, +renderer offset, collision bypass or protocol change was needed. This closes +the bounded first-rescue technical gate, not Andrew's aesthetic acceptance. +There was no fresh reduced native run after the spacing correction; distinguish +the earlier reduced native functional pass from current both-mode route tests. +Game's legacy retry fixtures now +use the actual production roots and a shared tests/Support fixture, preserving +all 66 cross-caller/write-count cases without test-file order dependence. The +combined route/retry run passes 178 tests / 12,913 assertions on PHP 8.5; the +full non-simulation Game suite passes 601 tests / 602,720 assertions. The +combined rescue/retry/equipment selection passes 200 tests / 13,417 assertions +on PHP 8.4, including the exact 13-image hashes and presentation bounds. This is +not a full Game PHP 8.4 suite or completion of the 180,000-battle simulation +baseline. The exact 13-image approval and verified copy are no longer pending +user decisions. + +The independent M3 terminal equipment retest now passes purchase/sale/repurchase +and role-aware equipment selection, with its configuration and all nine current +saves unchanged. Unlike the earlier rescue walkthrough, that later run did not +exit cleanly: confirmed Quit restored terminal modes but left the process alive +until interrupted. Engine reproduced cached confirmation input triggering a +post-quit field interaction and corrected the shared shutdown/input continuation +boundary, including both blocking modal implementations. The full Engine checks +above include this fix. Editor's full suites and Console's eight-script suites +and strict Last Legend validation were rerun successfully on PHP 8.4 and 8.5 +against it. Game's one subsequent muted 110x35 terminal exit retest at the same +Waymeet interaction also passes: Q then Enter returns from normal Game::run with +exit code 0, without another input or forced interruption. Configuration and all +nine current saves match before/after, and no owned game/renderer process remains. +This closes the observed terminal exit defect. The first rescue's native +functional and corrected-spacing checks also pass as recorded above. diff --git a/docs/roadmap.md b/docs/roadmap.md index ffe0ac25..7f1879a3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -92,6 +92,69 @@ story feature depends on persistent game state existing. > `TurnBasedEngine::$turnQueue`, `Skill::execute()` stubs) is quarantined for > Phase 3's battle work. +#### Equipment and shop integration follow-through (2026-09-17) + +Game bootstrap loads the existing `assets/Data/equipment-optimization.php` +contract rather than introducing a second policy declaration in `system.php`. +Its flat array accepts `statWeights`, `roleStatWeights`, `slotStatWeights`, +`roleSlotStatWeights`, `elementOutcomeWeights`, `specialPropertyWeights`, +`excludedDefinitionIds`, `excludedAvailabilities` and +`excludedAcquisitionPolicies`. Weights are integers; exclusion fields are lists +of non-empty strings. Stat precedence remains base, role, slot, then role-slot, +with later weights replacing matching earlier keys. Compatibility and available +equipment checks still precede scoring. + +An absent file resets to legacy equal-weight scoring; an explicit empty array +declares zero weights. A present invalid file rejects startup with its path and +cause, never silently retaining another project's policy. Headless regression +tests exercise actual Game configuration and ordinary Character Optimize calls. + +Sale eligibility is shared by inventory values, the sell menu and the shop +transaction. The transaction resolves the owned stack by stable ID before +checking flags, quantity and price, so supplied offer metadata cannot bypass +an unsellable definition. Shop rates and transaction rounding remain unchanged. + +The subsequent terminal equipment walkthrough found a separate catalogue/owned +stack alias in purchases: selling the last copy also depleted shop merchandise, +so buying it again admitted a zero-quantity stack. Purchases now create owned +unit copies, independent of merchandise quantity, and preflight all inventory +categories and capacity before charging. Full inventories may still top up +existing stacks; rejected new entries do not block later existing-stack inputs. +The sale quantity prompt now names the sale action rather than a purchase. + +Engine validation, including purchase ownership and the subsequent shutdown fix: +the full suite passes on PHP 8.4 and 8.5 with 2,370 tests, 13,985 assertions and +one existing skip on each; full PHPStan is clean. These +headless checks do not establish native gameplay or Linux/WSLg acceptance. + +Game's muted 110x35 terminal retest also passes purchase, sale of the last copy, +and repurchase: 10,000G becomes 8,100G after the three approved weapons, 8,425G +after selling the staff, then 7,775G with one owned staff after repurchasing. +Manual equip and role-aware Optimize retain the expected weapons; the sale +prompt says "sell". Configuration and all nine current save hashes match before +and after that retest. Exit confirmation exposed a separate shared shutdown +defect: terminal modes restore, but the process needs interruption to finish. +That run is not evidence of clean shutdown. The shared fix clears cached +confirmation input and stops field/frame continuation when shutdown begins; +both blocking modal implementations unwind without repainting or resuming a +stopped scene. Startup modals remain supported before the main loop begins. +Headless regressions cover terminal and renderer input sources, repeated quit, +failed source reset and quit from callbacks. Game's one subsequent muted 110x35 +terminal retest at the same Waymeet interaction passes: Q opens Exit, Enter +invokes normal quit, restores the terminal and returns from Game::run with exit +code 0, without further input or forced interruption. Configuration and all nine +current saves remain unchanged, and no owned game/renderer process remains. + +Remaining boundaries, not claims of completion: Editor diagnostics still need +parity for malformed top-level policy data and failed file loads. Shops retain +their existing total-held-quantity behavior; protecting copies currently equipped +needs coordinated quantity-picker and transaction handling. Item-level sale-rate +metadata and shop-rate precedence also remain distinct and must not be combined +into an invented double-discount or silently different rounding policy. +The quantity UI also still truncates some fractional totals and checks purchase +affordability against the base price rather than custom shop rates; a shared +quote calculation is follow-up work, not a claim of this ownership fix. + ### Phase 1 — The persistence spine (switches, variables, world state) ✅ *shipped 2026-08* > Status: `Core\GameState` ships switches, variables, story events, and > per-map/per-marker completion, serialized through `GameConfig::$gameState` @@ -194,8 +257,10 @@ A pure consumer of Phase 1: > Howl). **Random encounters**: maps opt in with an `encounters` block > (weighted troop table + step rate); `EncounterManager` burns a > randomized step counter on ENCOUNTER tiles (or every tile via -> `'tiles' => 'any'`), with 5% preemptive-strike and 5% ambush rolls that -> drop the surprised side's opening-round turns, and +> `'tiles' => 'any'`). Battle opening now uses one shared, configurable +> pre-emptive/ambush decision (defaults 8%/6%); see +> [battle opening settings](story-events.md). Traditional battles drop the +> surprised side's opening-round turns. The > `encounterRateMultiplier` as the repel/lure hook (demo: the overworld's > grass). **Level-up beat**: victory now reports each member's new level > and any `CharacterRole::$skillsToLearn` grants crossed this battle @@ -722,7 +787,15 @@ transitions, field animations, authored safe skipping/finalizers, and a non-blocking summon preview/runtime session while retaining this same interpreter. See [cinematics.md](cinematics.md) and [summons.md](summons.md). -Still deferred: active-session save serialization, pathfinding, party +Authored cinematic waypoint routes now use bounded cardinal planning through +the existing collision model. Session-local approach histories support exact +walking returns and entry-facing restoration; they are not saved story state. +Battle interruption suspends the caller, while true unload, quit and handled +crash paths release its transient ownership. See the current contract and +validation in [cinematics.md](cinematics.md#captured-entry-walking-and-return) +and the [integration roadmap](rendering/integration-roadmap.md#cinematic-gap-schedule). + +Still deferred: active-session save serialization, autonomous pathfinding, party followers, NPC patrol routes, the first-class Editor cinematic authoring surface, boss phases, and scheduled/delayed battle actions. diff --git a/docs/story-events.md b/docs/story-events.md index ab0803d2..72af2856 100644 --- a/docs/story-events.md +++ b/docs/story-events.md @@ -188,6 +188,7 @@ path: 'resultVariable' => 'training_result', // optional 'defeatPolicy' => 'game_over', // default; or continue 'escapePolicy' => 'forbidden', // optional; allowed or forbidden + 'firstStrike' => 'normal', // optional; normal, party, or troop ], ``` @@ -202,6 +203,26 @@ Victory rewards, quest tracking, achievements, bestiary recording, audio restoration, party cleanup, and persistent-state handling stay on the existing battle paths. +`firstStrike` pins the opening for a scripted encounter: `normal` (or explicit +`null`) disables advantage rolls, `party` forces a pre-emptive strike, and +`troop` forces an ambush. Omission uses the project's opening chances. Both +battle modes honor the same decision; the field does not make an additional +roll. Traditional battles skip the surprised side's first round; ATB starts +the favored side ready and the surprised side empty. + +In `Data/system.php`, configure these mode-independent chances under +`battle.opening.preemptiveChancePercent` and `battle.opening.ambushChancePercent` +(defaults 8 and 6; their sum must not exceed 100). Existing +`battle.activeTime.surpriseAttackChancePercent` / `backAttackChancePercent` +remain fallbacks for older projects; the shared settings take precedence. + +Normal ATB openings use a random gauge contribution (`openingVariance`, default +70) plus a speed bonus (`openingSpeedFactorPercent`, default 50). The bonus is +capped at 20 gauge points and the starting total at 90, preserving variation +at high levels. Thereafter gauges reset to zero and refill at the existing +speed-based rate. Ready battlers act in threshold-crossing order, with random +ties, rather than being reordered by gauge overflow or a delayed frame. + `escapePolicy` overrides the troop's optional policy for this launch. If both are omitted, escape remains allowed for backward compatibility. A forbidden battle omits the Escape command and rechecks the rule at resolution, so stale diff --git a/resources/renderers/README.md b/resources/renderers/README.md index 36acf905..fce31036 100644 --- a/resources/renderers/README.md +++ b/resources/renderers/README.md @@ -4,25 +4,32 @@ This directory is an Engine packaging boundary, not project configuration or an end-user installation interface. `PackagedRendererExecutableResolver` alone owns its layout. Console and game scripts pass renderer identities, never paths. -A packager stages implementations under `installed/` and writes -`installed/manifest.json`. That generated installation directory is ignored by -Git; binaries and machine-specific manifests must not enter source commits. -There is no default download, checkout-relative search or PATH fallback. +The Console's `ichiloto renderer:install` command provisions this boundary +from a verified renderer package: it checks every payload file's SHA-256 +against the package's `renderer-package.json`, backs up any existing +installation, stages the payload under `installed/` and writes +`installed/manifest.json`. Each renderer implementation's own repository +produces those packages (the GPUI renderer's `scripts/package.php` is the +first); the package format carries its renderer and platform identities, so +additional renderer implementations install through the same command. That +generated installation directory is ignored by Git; binaries and +machine-specific manifests must not enter source commits. There is no default +download, checkout-relative search or PATH fallback. A clean Engine checkout therefore contains this README, not a native renderer or a Rust project. Cargo commands belong in the separate `ichiloto/gpui-renderer` source repository and are developer instructions, not a player setup workflow. -As audited on 14 September 2026, there is no published renderer release or -Console installation command to provision this boundary. Player delivery remains -unfinished: it must supply a compatible, verified platform package without -requiring players to install Rust or build tools. See the +There is no published renderer release yet: players must eventually receive a +compatible, verified platform package without installing Rust or build tools, +and publishing those packages remains unfinished. See the [integration roadmap](../../docs/rendering/integration-roadmap.md). GPUI gameplay, packaging and performance validation must use the optimized release renderer built with `cargo build --release --locked` in its own repository. Debug builds are for development and diagnostics, not representative -gameplay performance. Stage the release executable with its required platform -bundle resources; this boundary does not build or download it automatically. +gameplay performance. Packages stage the release executable with its required +platform bundle resources; this boundary does not build or download anything +automatically. Manifest version 1 maps renderer IDs and platform IDs to executable paths relative to the manifest. For the currently validated Apple Silicon build: diff --git a/src/Battle/BattleResult.php b/src/Battle/BattleResult.php index 47c1e509..0b43b5d2 100644 --- a/src/Battle/BattleResult.php +++ b/src/Battle/BattleResult.php @@ -3,6 +3,7 @@ namespace Ichiloto\Engine\Battle; use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\Battle\Presentation\BattleRewards; /** * Represents the outcome of a battle. @@ -24,6 +25,7 @@ public function __construct( protected(set) array $lines = [], protected(set) array $items = [], protected(set) array $entries = [], + protected(set) ?BattleRewards $rewards = null, ) { } diff --git a/src/Battle/EncounterAdvantage.php b/src/Battle/EncounterAdvantage.php new file mode 100644 index 00000000..3c440ba7 --- /dev/null +++ b/src/Battle/EncounterAdvantage.php @@ -0,0 +1,57 @@ +nextInt(1, 100); + + return match (true) { + $roll <= $preemptiveChance => self::PARTY, + $roll <= $preemptiveChance + $ambushChance => self::TROOP, + default => self::NORMAL, + }; + } + + public static function validateChances(int $preemptiveChance, int $ambushChance): void + { + if ($preemptiveChance < 0 || $ambushChance < 0 || $preemptiveChance + $ambushChance > 100) { + throw new InvalidArgumentException('Opening advantage chances must be non-negative and total at most 100.'); + } + } +} diff --git a/src/Battle/Engines/ActiveTime/ActiveTimeBattleConfig.php b/src/Battle/Engines/ActiveTime/ActiveTimeBattleConfig.php index 12eed514..a88b734e 100644 --- a/src/Battle/Engines/ActiveTime/ActiveTimeBattleConfig.php +++ b/src/Battle/Engines/ActiveTime/ActiveTimeBattleConfig.php @@ -14,6 +14,8 @@ */ class ActiveTimeBattleConfig extends BattleConfig { + public const float DEFAULT_OPENING_VARIANCE = 70.0; + public const float DEFAULT_OPENING_SPEED_FACTOR = 0.5; /** * Creates a new active-time battle configuration. * @@ -38,8 +40,8 @@ public function __construct( protected(set) string $mode = 'wait', protected(set) float $baseFillRate = 35.0, protected(set) float $speedFactor = 1.0, - protected(set) float $openingVariance = 24.0, - protected(set) float $openingSpeedFactor = 2.5, + protected(set) float $openingVariance = self::DEFAULT_OPENING_VARIANCE, + protected(set) float $openingSpeedFactor = self::DEFAULT_OPENING_SPEED_FACTOR, protected(set) int $surpriseAttackChancePercent = 8, protected(set) int $backAttackChancePercent = 6, array $settings = [], @@ -47,4 +49,4 @@ public function __construct( { parent::__construct($party, $troop, $events, $settings); } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/ActiveTime/ActiveTimeBattleEngine.php b/src/Battle/Engines/ActiveTime/ActiveTimeBattleEngine.php index 6e706909..0e6f209d 100644 --- a/src/Battle/Engines/ActiveTime/ActiveTimeBattleEngine.php +++ b/src/Battle/Engines/ActiveTime/ActiveTimeBattleEngine.php @@ -3,6 +3,8 @@ namespace Ichiloto\Engine\Battle\Engines\ActiveTime; use Ichiloto\Engine\Battle\BattleAction; +use Ichiloto\Engine\Battle\EncounterAdvantage; +use Ichiloto\Engine\Battle\BattlerBattleView; use Ichiloto\Engine\Battle\Engines\ActiveTime\States\ActiveTimeFlowState; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\ActionExecutionState; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnResolutionState; @@ -26,9 +28,12 @@ class ActiveTimeBattleEngine extends TurnBasedEngine { protected const READY_GAUGE = 100.0; protected const GAUGE_CAP = 150.0; - protected const ADVANTAGE_NORMAL = 'normal'; - protected const ADVANTAGE_SURPRISE = 'surprise_attack'; - protected const ADVANTAGE_BACK_ATTACK = 'back_attack'; + protected const MAX_OPENING_GAUGE = 90.0; + protected const MAX_OPENING_SPEED_BONUS = 20.0; + + /** @var array Simulated time each battler crossed the ready threshold. */ + protected array $readyTimes = []; + protected float $gaugeTime = 0.0; /** * @var CharacterInterface[] Battlers whose gauges have filled and are ready to act. @@ -46,9 +51,9 @@ class ActiveTimeBattleEngine extends TurnBasedEngine protected array $initiativeSeeds = []; /** - * @var string The resolved encounter advantage state. + * @var EncounterAdvantage The resolved encounter advantage state. */ - protected string $encounterAdvantage = self::ADVANTAGE_NORMAL; + protected EncounterAdvantage $encounterAdvantage = EncounterAdvantage::NORMAL; /** * @var string|null Pending encounter alert text. @@ -115,7 +120,9 @@ public function stop(): void $this->readyBattlers = []; $this->gaugeValues = []; $this->initiativeSeeds = []; - $this->encounterAdvantage = self::ADVANTAGE_NORMAL; + $this->readyTimes = []; + $this->gaugeTime = 0.0; + $this->encounterAdvantage = EncounterAdvantage::NORMAL; $this->openingAlertPending = null; } @@ -158,6 +165,9 @@ public function progressGauges(TurnStateExecutionContext $context): void $deltaTime = 1 / 60; } + $frameStart = $this->gaugeTime; + $this->gaugeTime += $deltaTime; + foreach ($this->getBattleParticipants($context) as $battler) { $id = spl_object_id($battler); @@ -175,13 +185,15 @@ public function progressGauges(TurnStateExecutionContext $context): void continue; } + $previousGauge = $this->gaugeValues[$id] ?? 0.0; + $fillRate = $this->getFillRate($battler); $this->gaugeValues[$id] = min( self::GAUGE_CAP, - ($this->gaugeValues[$id] ?? 0.0) + ($this->getFillRate($battler) * $deltaTime), + $previousGauge + ($fillRate * $deltaTime), ); if ($this->gaugeValues[$id] >= self::READY_GAUGE) { - $this->enqueueReadyBattler($battler); + $this->enqueueReadyBattler($battler, $frameStart + (self::READY_GAUGE - $previousGauge) / $fillRate); } } } @@ -302,10 +314,12 @@ protected function resetBattleState(TurnStateExecutionContext $context): void $this->readyBattlers = []; $this->gaugeValues = []; $this->initiativeSeeds = []; + $this->readyTimes = []; + $this->gaugeTime = 0.0; $this->encounterAdvantage = $this->determineEncounterAdvantage(); $this->openingAlertPending = match ($this->encounterAdvantage) { - self::ADVANTAGE_SURPRISE => 'Surprise Attack!', - self::ADVANTAGE_BACK_ATTACK => 'Back Attack!', + EncounterAdvantage::PARTY => 'Preemptive strike! The party moves first.', + EncounterAdvantage::TROOP => 'Ambushed! The enemy strikes first.', default => null, }; @@ -332,13 +346,14 @@ protected function resetBattleState(TurnStateExecutionContext $context): void * @param CharacterInterface $battler The battler to enqueue. * @return void */ - protected function enqueueReadyBattler(CharacterInterface $battler): void + protected function enqueueReadyBattler(CharacterInterface $battler, float $readyAt = 0.0): void { if ($this->isBattlerReady($battler)) { return; } $this->readyBattlers[] = $battler; + $this->readyTimes[spl_object_id($battler)] = $readyAt; } /** @@ -349,6 +364,7 @@ protected function enqueueReadyBattler(CharacterInterface $battler): void */ protected function removeReadyBattler(CharacterInterface $battler): void { + unset($this->readyTimes[spl_object_id($battler)]); $this->readyBattlers = array_values(array_filter( $this->readyBattlers, fn(CharacterInterface $readyBattler): bool => $readyBattler !== $battler, @@ -399,57 +415,37 @@ protected function resolveInitialGauge(TurnStateExecutionContext $context, Chara return 0.0; } - $variance = $this->battleConfig->openingVariance > 0 - ? $this->randomFloat(0.0, $this->battleConfig->openingVariance) - : 0.0; - - $startingValue = $variance - + ($this->getBattlerSpeed($battler) * $this->battleConfig->openingSpeedFactor) - + $this->resolveAdvantageBias($context, $battler); - - return clamp($startingValue, 0.0, self::GAUGE_CAP); - } + if ($this->encounterAdvantage !== EncounterAdvantage::NORMAL) { + $favored = $this->isPartyBattler($context, $battler) + === ($this->encounterAdvantage === EncounterAdvantage::PARTY); + return $favored ? self::READY_GAUGE : 0.0; + } - /** - * Resolves the opening bias introduced by the encounter advantage state. - * - * @param TurnStateExecutionContext $context The turn context. - * @param CharacterInterface $battler The battler to inspect. - * @return float - */ - protected function resolveAdvantageBias(TurnStateExecutionContext $context, CharacterInterface $battler): float - { - $isPartyBattler = $this->isPartyBattler($context, $battler); + // Leave room for variation even at maximum speed; normal openings never start ready. + $speedBonus = min(self::MAX_OPENING_SPEED_BONUS, + max(0.0, $this->getBattlerSpeed($battler) * $this->battleConfig->openingSpeedFactor)); + $variance = min(self::MAX_OPENING_GAUGE - $speedBonus, max(0.0, $this->battleConfig->openingVariance)); - return match ($this->encounterAdvantage) { - self::ADVANTAGE_SURPRISE => $isPartyBattler ? 55.0 : -15.0, - self::ADVANTAGE_BACK_ATTACK => $isPartyBattler ? -15.0 : 55.0, - default => 0.0, - }; + return $speedBonus + $this->randomFloat(0.0, $variance); } /** * Determines the encounter advantage state for the battle. * - * @return string + * @return EncounterAdvantage */ - protected function determineEncounterAdvantage(): string + protected function determineEncounterAdvantage(): EncounterAdvantage { if (! $this->battleConfig instanceof ActiveTimeBattleConfig) { - return self::ADVANTAGE_NORMAL; - } - - $roll = $this->randomFloat(0.0, 100.0); - - if ($roll < $this->battleConfig->surpriseAttackChancePercent) { - return self::ADVANTAGE_SURPRISE; - } - - if ($roll < ($this->battleConfig->surpriseAttackChancePercent + $this->battleConfig->backAttackChancePercent)) { - return self::ADVANTAGE_BACK_ATTACK; + return EncounterAdvantage::NORMAL; } - return self::ADVANTAGE_NORMAL; + return EncounterAdvantage::forBattle( + $this->battleConfig->settings, + $this->random, + $this->battleConfig->surpriseAttackChancePercent, + $this->battleConfig->backAttackChancePercent, + ); } /** @@ -472,9 +468,7 @@ protected function isPartyBattler(TurnStateExecutionContext $context, CharacterI */ protected function getBattlerSpeed(CharacterInterface $battler): int { - return $battler instanceof Character - ? $battler->effectiveStats->speed - : $battler->stats->speed; + return new BattlerBattleView($battler)->stats->speed; } /** @@ -486,18 +480,11 @@ protected function getBattlerSpeed(CharacterInterface $battler): int */ protected function compareReadyBattlers(CharacterInterface $left, CharacterInterface $right): int { - $leftGauge = $this->getReadyOrderingValue($left); - $rightGauge = $this->getReadyOrderingValue($right); - - if ($leftGauge !== $rightGauge) { - return $rightGauge <=> $leftGauge; - } - - $leftSpeed = $this->getBattlerSpeed($left); - $rightSpeed = $this->getBattlerSpeed($right); + $leftTime = $this->readyTimes[spl_object_id($left)]; + $rightTime = $this->readyTimes[spl_object_id($right)]; - if ($leftSpeed !== $rightSpeed) { - return $rightSpeed <=> $leftSpeed; + if ($leftTime !== $rightTime) { + return $leftTime <=> $rightTime; } $leftSeed = $this->initiativeSeeds[spl_object_id($left)] ?? 0.0; @@ -510,17 +497,6 @@ protected function compareReadyBattlers(CharacterInterface $left, CharacterInter return spl_object_id($right) <=> spl_object_id($left); } - /** - * Returns the numeric ready-ordering value for one battler. - * - * @param CharacterInterface $battler The battler to inspect. - * @return float - */ - protected function getReadyOrderingValue(CharacterInterface $battler): float - { - return $this->gaugeValues[spl_object_id($battler)] ?? 0.0; - } - /** * Returns a random float within the provided range. * @@ -534,6 +510,6 @@ protected function randomFloat(float $minimum, float $maximum): float return $minimum; } - return $minimum + ((mt_rand() / mt_getrandmax()) * ($maximum - $minimum)); + return $minimum + (($this->random->nextInt(0, 1_000_000) / 1_000_000) * ($maximum - $minimum)); } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php index 37a3b396..5dc1f2c3 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php @@ -450,7 +450,7 @@ protected function averageSpeed(array $battlers): float } /** - * Confirms the active submenu option and either moves to targeting or queues it directly. + * Moves the active submenu option to target confirmation, including self-only actions. * * @param TurnStateExecutionContext $context The turn context. * @return void @@ -476,11 +476,6 @@ protected function selectSubmenuOption(TurnStateExecutionContext $context): void return; } - if ($option->targetSide === ItemScopeSide::USER) { - $this->queueActionForActiveCharacter($context); - return; - } - $targetIndexes = $this->getSelectableTargetIndexes($context); if (empty($targetIndexes)) { @@ -669,7 +664,7 @@ protected function applyTargetingVisuals(TurnStateExecutionContext $context): vo ? $this->getSelectableTargetIndexes($context) : [$this->activeTargetIndex]; match ($this->getSelectedOption()?->targetSide) { - ItemScopeSide::ALLY => $context->ui->fieldWindow->focusPartyBattlers($indexes, blink: true), + ItemScopeSide::ALLY, ItemScopeSide::USER => $context->ui->fieldWindow->focusPartyBattlers($indexes, blink: true), default => $context->ui->fieldWindow->focusTroopBattlers($indexes, blink: true), }; } diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php index b2e92afe..308d6f15 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php @@ -3,6 +3,7 @@ namespace Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Turn; +use Ichiloto\Engine\Battle\EncounterAdvantage; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; @@ -28,24 +29,30 @@ public function update(TurnStateExecutionContext $context): void // A preemptive strike or ambush drops the surprised side's turns for // the opening round only. + $settings = $this->engine->battleConfig->settings; $firstStrike = $context->roundNumber === 1 - ? strval($this->engine->battleConfig->settings['firstStrike'] ?? '') - : ''; + ? EncounterAdvantage::forBattle( + $settings, + $this->engine->random, + intval($settings['activeTime']['surpriseAttackChancePercent'] ?? 8), + intval($settings['activeTime']['backAttackChancePercent'] ?? 6), + ) + : EncounterAdvantage::NORMAL; $this->determineTurnOrder($context, match ($firstStrike) { - 'party' => 'troop', - 'troop' => 'party', + EncounterAdvantage::PARTY => 'troop', + EncounterAdvantage::TROOP => 'party', default => null, }); - if ($firstStrike === 'party') { + if ($firstStrike === EncounterAdvantage::PARTY) { $context->ui->alert('Preemptive strike! The party moves first.'); - } elseif ($firstStrike === 'troop') { + } elseif ($firstStrike === EncounterAdvantage::TROOP) { $context->ui->alert('Ambushed! The enemy strikes first.'); } $this->updateUI($context); - $this->setState($firstStrike === 'troop' ? $this->engine->enemyActionState : $this->engine->playerActionState); + $this->setState($firstStrike === EncounterAdvantage::TROOP ? $this->engine->enemyActionState : $this->engine->playerActionState); } /** diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php index 53fa26e6..4389293e 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php @@ -4,6 +4,7 @@ use Ichiloto\Engine\Audio\Enumerations\SystemSound; use Ichiloto\Engine\Battle\BattleResult; +use Ichiloto\Engine\Battle\Presentation\BattleRewards; use Ichiloto\Engine\IO\Enumerations\Color; use Ichiloto\Engine\Quests\QuestManager; use Ichiloto\Engine\Scenes\Battle\BattleScene; @@ -59,11 +60,23 @@ public function update(TurnStateExecutionContext $context): void static fn($result): bool => $result->levelledUp(), )); + // Inventory insertion can coalesce quantities; snapshot the actual drop batch first. + $rewardItems = BattleRewards::snapshotItems($items); + $heldBefore = array_column($context->party->inventory->all->toArray(), 'quantity', 'id'); + $goldBefore = $context->party->accountBalance; $context->party->credit($gold); if (! empty($items)) { $context->party->addItems(...$items); } + $heldAfter = array_column($context->party->inventory->all->toArray(), 'quantity', 'id'); + foreach ($rewardItems as &$rewardItem) { + $rewardItem['received'] = max(0, ($heldAfter[$rewardItem['id']] ?? 0) - ($heldBefore[$rewardItem['id']] ?? 0)); + } + unset($rewardItem); + $rewardSummary = ['Enemies defeated' => (string)count($context->troop->members->toArray())]; + $goldReceived = $context->party->accountBalance - $goldBefore; + if ($goldReceived !== $gold) { $rewardSummary['Gold at capacity'] = (string)($gold - $goldReceived); } $questManager = QuestManager::current(); $gameScene = $context->game->sceneManager->findScene(GameScene::class); @@ -111,7 +124,8 @@ public function update(TurnStateExecutionContext $context): void } } - $scene->result = new BattleResult('Victory', $lines, $items, $entries); + $scene->result = new BattleResult('Victory', $lines, $items, $entries, + new BattleRewards($experience, $goldReceived, $progressionResults, $rewardItems, $rewardSummary)); $scene->setState($scene->victoryState); return; } diff --git a/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php b/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php index 436f1096..54be65d3 100644 --- a/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php +++ b/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php @@ -11,6 +11,8 @@ use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnState; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnStateExecutionContext; use Ichiloto\Engine\Battle\Interfaces\BattleEngineInterface; +use Ichiloto\Engine\Battle\Resolution\CombatRandomSource; +use Ichiloto\Engine\Battle\Resolution\NativeCombatRandomSource; use Ichiloto\Engine\Core\Game; use Ichiloto\Engine\Scenes\Battle\BattleConfig; @@ -21,6 +23,7 @@ */ abstract class TurnBasedEngine implements BattleEngineInterface { + protected(set) CombatRandomSource $random; /** * @var BattleConfig|null The battle configuration. */ @@ -64,9 +67,11 @@ abstract class TurnBasedEngine implements BattleEngineInterface * @param Game $game */ public function __construct( - protected Game $game + protected Game $game, + ?CombatRandomSource $random = null, ) { + $this->random = $random ?? new NativeCombatRandomSource(); $this->turnQueue = new Queue(Turn::class); $this->initializeTurnStates(); } @@ -136,4 +141,4 @@ protected function positionBattlers(): void $this->battleConfig->ui->refreshField(); } } -} \ No newline at end of file +} diff --git a/src/Battle/Presentation/BattleArenaDefinition.php b/src/Battle/Presentation/BattleArenaDefinition.php index 175f4982..bd8e03f9 100644 --- a/src/Battle/Presentation/BattleArenaDefinition.php +++ b/src/Battle/Presentation/BattleArenaDefinition.php @@ -4,51 +4,74 @@ namespace Ichiloto\Engine\Battle\Presentation; -use Ichiloto\Engine\Battle\UI\BattleScreen; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasRectangle; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasValidation; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; -use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use InvalidArgumentException; -final readonly class BattleArenaDefinition +final readonly class BattleArenaDefinition extends BattleCanvasLayout { /** @var list */ public array $partySlots; /** @var list */ public array $enemySlots; - public RendererGridConfig $uiGrid; + /** @var array> Stable troop identities within this arena. */ + public array $enemySlotsByTroop; - /** @param list $partySlots @param list $enemySlots */ + /** + * @param list $partySlots + * @param list $enemySlots + * @param array> $enemySlotsByTroop + */ public function __construct( - public int $width, - public int $height, + int $width, + int $height, public CanvasImage $background, array $partySlots, array $enemySlots, int $uiCellWidth = 10, int $uiCellHeight = 20, - public ?BattleUiSkin $skin = null, - public ?CanvasRectangle $feedbackArea = null, + ?BattleUiSkin $skin = null, + ?CanvasRectangle $feedbackArea = null, + array $enemySlotsByTroop = [], ) { + parent::__construct($width, $height, $uiCellWidth, $uiCellHeight, $skin, $feedbackArea); new PresentationCanvas($width, $height, [$background]); - $feedbackArea?->assertWithin($width, $height); - if ($skin !== null && $feedbackArea === null) { - throw new InvalidArgumentException('A skinned battle requires an explicit feedback safe area.'); - } if ($background->layer >= 100) { throw new InvalidArgumentException('Arena background must be below battler layer 100.'); } - $this->partySlots = self::slots($partySlots); - $this->enemySlots = self::slots($enemySlots); - $this->uiGrid = new RendererGridConfig(BattleScreen::WIDTH, BattleScreen::HEIGHT, $uiCellWidth, $uiCellHeight); - if ($this->uiGrid->columns * $uiCellWidth > $width || $this->uiGrid->rows * $uiCellHeight > $height) { - throw new InvalidArgumentException('The temporary battle UI grid must fit inside the graphical canvas.'); + $this->partySlots = self::getValidatedSlots($partySlots); + $this->enemySlots = self::getValidatedSlots($enemySlots); + $formations = []; + foreach ($enemySlotsByTroop as $troopId => $slots) { + if (!is_string($troopId) || !is_array($slots)) { + throw new InvalidArgumentException('Troop formations require string identities and lists of BattlerSlot entries.'); + } + CanvasValidation::id($troopId); + $formations[$troopId] = self::getValidatedSlots($slots); } + $this->enemySlotsByTroop = $formations; + } + + public function getForTroop(string $troopId): self + { + if (!array_key_exists($troopId, $this->enemySlotsByTroop)) { return $this; } + + return new self($this->width, $this->height, $this->background, + $this->partySlots, $this->enemySlotsByTroop[$troopId], $this->uiGrid->cellWidth, $this->uiGrid->cellHeight, + $this->skin, $this->feedbackArea); + } + + public function withDefaultUi(BattleCanvasLayout $ui): self + { + return $this->skin !== null ? $this : new self($this->width, $this->height, $this->background, + $this->partySlots, $this->enemySlots, $this->uiGrid->cellWidth, $this->uiGrid->cellHeight, + $ui->skin, $this->feedbackArea ?? $ui->feedbackArea, $this->enemySlotsByTroop); } /** @return list */ - private static function slots(array $slots): array + private static function getValidatedSlots(array $slots): array { if (!array_is_list($slots) || count($slots) > 64) { throw new InvalidArgumentException('Battle slots must be a list of at most 64 entries.'); diff --git a/src/Battle/Presentation/BattleCanvasLayout.php b/src/Battle/Presentation/BattleCanvasLayout.php new file mode 100644 index 00000000..932c795c --- /dev/null +++ b/src/Battle/Presentation/BattleCanvasLayout.php @@ -0,0 +1,36 @@ +assertWithin($width, $height); + if ($skin !== null && $feedbackArea === null) { + throw new InvalidArgumentException('A skinned battle requires an explicit feedback safe area.'); + } + $this->uiGrid = new RendererGridConfig(BattleScreen::WIDTH, BattleScreen::HEIGHT, $uiCellWidth, $uiCellHeight); + if ($this->uiGrid->columns * $uiCellWidth > $width || $this->uiGrid->rows * $uiCellHeight > $height) { + throw new InvalidArgumentException('The battle UI grid must fit inside the graphical canvas.'); + } + } +} diff --git a/src/Battle/Presentation/BattleCanvasUiAdapter.php b/src/Battle/Presentation/BattleCanvasUiAdapter.php index 82895669..ad3a97d6 100644 --- a/src/Battle/Presentation/BattleCanvasUiAdapter.php +++ b/src/Battle/Presentation/BattleCanvasUiAdapter.php @@ -9,13 +9,12 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationColor; use Ichiloto\Engine\Rendering\Presentation\PresentationTextRun; use Ichiloto\Engine\Scenes\Battle\BattleScene; -use Ichiloto\Engine\Scenes\Battle\States\BattlePauseState; /** Transitional adapter: explicit window footprints, never battlefield glyph inference. */ final class BattleCanvasUiAdapter { /** @return list */ - public static function collect(BattleScene $scene, BattleArenaDefinition $arena): array + public static function collect(BattleScene $scene, BattleCanvasLayout $arena, bool $includeField = false): array { $screen = $scene->ui; if ($screen === null) { return []; } @@ -35,31 +34,55 @@ public static function collect(BattleScene $scene, BattleArenaDefinition $arena) ], $owned); $layers = []; foreach (Console::presentationSnapshot()->textLayers as $layer) { - $runs = []; - $regions = $layer->id === 'world' ? $windows : [[$left, $top, $grid->columns, $grid->rows]]; - foreach ($layer->runs as $run) { - // Named Console overlays already carry explicit ownership. Base cells - // are admitted only inside currently visible battle UI windows. - foreach ($regions as [$x, $y, $width, $height]) { - if ($run->row < $y || $run->row >= $y + $height || $run->row < $top || $run->row >= $top + $grid->rows) { continue; } - $start = (int)max($x, $left, $run->column); - $end = (int)min($x + $width, $left + $grid->columns, $run->column + mb_strlen($run->text, 'UTF-8')); - if ($end <= $start) { continue; } - $runs[] = new PresentationTextRun((int)($run->row - $top), (int)($start - $left), - mb_substr($run->text, $start - $run->column, $end - $start, 'UTF-8'), - $run->foreground, $run->background ?? PresentationColor::rgb(15, 23, 30)); + if ($includeField && $layer->id === 'world') { + $field = $screen->fieldWindow; + $runs = self::clipRuns($layer->runs, [[ + $field->getPosition()->x, $field->getPosition()->y, $field->getWidth(), $field->getHeight(), + ]], $left, $top, $grid->columns, $grid->rows); + if ($runs !== []) { + $layers[] = new CanvasTextLayer('battle-field', 0, $originX, $originY, $grid, $runs); } } + $regions = $layer->id === 'world' ? $windows : [[$left, $top, $grid->columns, $grid->rows]]; + $runs = self::clipRuns($layer->runs, $regions, $left, $top, $grid->columns, $grid->rows); if ($runs !== []) { $layers[] = new CanvasTextLayer('battle-ui-' . count($layers), ($arena->skin === null ? 1000 : 1500) + count($layers), $originX, $originY, $grid, $runs); } } - if ($scene->state instanceof BattlePauseState) { - $layers[] = new CanvasTextLayer('battle-pause', 2000, $originX, $originY, $grid, [ - new PresentationTextRun(intdiv($grid->rows - 1, 2), intdiv($grid->columns - strlen(BattlePauseState::PAUSE_TEXT), 2), - BattlePauseState::PAUSE_TEXT, background: PresentationColor::rgb(15, 23, 30)), - ]); - } return $layers; } + + /** + * Console-owned cells cover their underlay, unlike transparent canvas defaults. + * @param list $source + * @return list + */ + public static function opaqueRuns(array $source): array + { + return array_map(static fn(PresentationTextRun $run): PresentationTextRun => $run->background !== null ? $run + : new PresentationTextRun($run->row, $run->column, $run->text, $run->foreground, PresentationColor::rgb(15, 23, 30)), $source); + } + + /** + * Named Console overlays own their cells; base cells require an explicit window footprint. + * @param list $source + * @param array $regions + * @return list + */ + private static function clipRuns(array $source, array $regions, int $left, int $top, int $columns, int $rows): array + { + $runs = []; + foreach ($source as $run) { + foreach ($regions as [$x, $y, $width, $height]) { + if ($run->row < $y || $run->row >= $y + $height || $run->row < $top || $run->row >= $top + $rows) { continue; } + $start = (int)max($x, $left, $run->column); + $end = (int)min($x + $width, $left + $columns, $run->column + mb_strlen($run->text, 'UTF-8')); + if ($end <= $start) { continue; } + $runs[] = new PresentationTextRun($run->row - $top, $start - $left, + mb_substr($run->text, $start - $run->column, $end - $start, 'UTF-8'), + $run->foreground, $run->background); + } + } + return self::opaqueRuns($runs); + } } diff --git a/src/Battle/Presentation/BattlePauseMenu.php b/src/Battle/Presentation/BattlePauseMenu.php new file mode 100644 index 00000000..74c65bc3 --- /dev/null +++ b/src/Battle/Presentation/BattlePauseMenu.php @@ -0,0 +1,127 @@ +clock = $clock ?? static fn(): float => hrtime(true) / 1_000_000_000; + } + + public function open(int $selection = 0): void + { + $this->selection = $this->rootSelection = max(0, min(3, $selection)); + $this->confirmation = $this->pending = $this->nextView = null; + $this->begin('enter'); + } + + public function close(): void + { + $this->phase = 'closed'; + $this->pending = $this->nextView = null; + } + + public function time(): float { return ($this->clock)(); } + public function isReady(): bool { return $this->phase === 'idle'; } + public function isClosed(): bool { return $this->phase === 'closed'; } + public function isPressed(): bool { return $this->phase === 'exit' || $this->phase === 'out'; } + + public function tick(): ?PauseAction + { + $elapsed = $this->time() - $this->started; + if ($this->phase === 'enter' && $elapsed >= 0.16 + || $this->phase === 'in' && $elapsed >= 0.07) { + $this->phase = 'idle'; + } elseif ($this->phase === 'out' && $elapsed >= 0.07) { + $this->confirmation = $this->nextView; + $this->selection = $this->confirmation === null ? $this->rootSelection : 0; + $this->nextView = null; + $this->begin('in'); + } elseif ($this->phase === 'exit' && $elapsed >= 0.12) { + $action = $this->pending; + $this->close(); + return $action; + } + return null; + } + + /** @return list */ + public function labels(): array + { + return $this->confirmation === null + ? array_map(static fn(PauseAction $action): string => $action->value, PauseAction::cases()) + : ['Cancel', $this->confirmation->value]; + } + + public function heading(): string + { + return match ($this->confirmation) { + PauseAction::TITLE => 'Return to title?', PauseAction::EXIT => 'Exit the game?', default => 'PAUSED', + }; + } + + public function navigate(int $step): void + { + if ($this->isReady()) { $this->selection = max(0, min(count($this->labels()) - 1, $this->selection + $step)); } + } + + public function back(bool $pauseShortcut = false): void + { + if (!$this->isReady()) { return; } + if ($this->confirmation === null) { $this->leave(PauseAction::RESUME); } + elseif (!$pauseShortcut) { $this->nextView = null; $this->begin('out'); } + } + + public function confirm(): void + { + if (!$this->isReady()) { return; } + if ($this->confirmation !== null) { + if ($this->selection === 0) { $this->back(); } + else { $this->leave($this->confirmation); } + return; + } + $action = PauseAction::cases()[$this->selection]; + if ($action === PauseAction::TITLE || $action === PauseAction::EXIT) { + $this->rootSelection = $this->selection; + $this->nextView = $action; + $this->begin('out'); + } else { $this->leave($action); } + } + + public function opacity(): float + { + $elapsed = max(0, $this->time() - $this->started); + return match ($this->phase) { + 'closed' => 0.0, 'enter' => min(1, $elapsed / 0.16), 'in' => min(1, $elapsed / 0.07), + 'out' => max(0, 1 - $elapsed / 0.07), 'exit' => max(0, 1 - $elapsed / 0.12), default => 1.0, + }; + } + + public function scale(): float + { + if ($this->reducedMotion) { return 1.0; } + return match ($this->phase) { + 'enter' => 1 - 0.02 * (1 - $this->opacity()) ** 3, + 'exit' => 0.99 + 0.01 * $this->opacity(), default => 1.0, + }; + } + + private function leave(PauseAction $action): void { $this->pending = $action; $this->begin('exit'); } + private function begin(string $phase): void { $this->phase = $phase; $this->started = $this->time(); } +} diff --git a/src/Battle/Presentation/BattlePauseSkin.php b/src/Battle/Presentation/BattlePauseSkin.php new file mode 100644 index 00000000..2a5c667e --- /dev/null +++ b/src/Battle/Presentation/BattlePauseSkin.php @@ -0,0 +1,32 @@ + $textures + * @param array $colors + */ + public function __construct(public array $textures, public array $colors) + { + foreach ([[self::TEXTURES, $textures, CanvasNineSlice::class], [self::COLORS, $colors, PresentationColor::class]] as [$roles, $entries, $type]) { + if (count($entries) !== count($roles)) { throw new InvalidArgumentException('Pause skin requires exactly its supported roles.'); } + foreach ($roles as $role) { + if (!($entries[$role] ?? null) instanceof $type) { throw new InvalidArgumentException("Pause skin requires a typed {$role}."); } + } + } + if (count(array_unique(array_map(static fn(CanvasNineSlice $art): int => $art->density, $textures))) !== 1) { + throw new InvalidArgumentException('Pause skin must use one PNG density.'); + } + } +} diff --git a/src/Battle/Presentation/BattlePresentationCatalog.php b/src/Battle/Presentation/BattlePresentationCatalog.php index ea320288..414c5d94 100644 --- a/src/Battle/Presentation/BattlePresentationCatalog.php +++ b/src/Battle/Presentation/BattlePresentationCatalog.php @@ -6,27 +6,47 @@ use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasValidation; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; +use Ichiloto\Engine\Scenes\Battle\BattleConfig; use InvalidArgumentException; use RuntimeException; /** Current project configuration, deliberately outside all save payloads. */ final readonly class BattlePresentationCatalog { - public const string FILE = 'Data/battle-presentation.php'; - /** @var array Troop definition ID, or historical catalog name when no ID exists. */ + public const string FILE = 'Data/Presentation/battle.php'; + /** @var array Authored arena keys; legacy entries use troop IDs/names. */ public array $arenas; /** @var array Actor IDs. */ public array $actors; /** @var array Existing EnemyStore catalog keys. */ public array $enemies; - public function __construct(array $arenas, array $actors, array $enemies) + public function __construct(array $arenas, array $actors, array $enemies, public ?BattleCanvasLayout $ui = null, + public ?BattleResultsSkin $results = null, public ?BattlePauseSkin $pause = null) { + if ($ui !== null && $ui->skin === null) { + throw new InvalidArgumentException('Project-wide battle UI requires a skin.'); + } $this->arenas = self::catalog($arenas, BattleArenaDefinition::class); $this->actors = self::catalog($actors, BattlerArtwork::class); $this->enemies = self::catalog($enemies, BattlerArtwork::class); } + public function arenaFor(BattleConfig $battle): ?BattleArenaDefinition + { + $key = $battle->settings['battleArena'] ?? null; + $troopId = $battle->troop->definitionId ?? $battle->troop->name; + if ($key === null) { + return ($this->arenas[$troopId] ?? null)?->getForTroop($troopId); + } + if (!is_string($key)) { + throw new InvalidArgumentException('battleArena must be a string arena key.'); + } + CanvasValidation::id($key); + $arena = $this->arenas[$key] ?? throw new RuntimeException("Unknown graphical battle arena: {$key}"); + return $arena->getForTroop($troopId); + } + public static function exists(string $assetRoot): bool { $path = $assetRoot . '/' . self::FILE; @@ -37,7 +57,7 @@ public static function exists(string $assetRoot): bool public function requiredCapabilities(): array { return [RendererSessionConfig::GRAPHICAL_CANVAS, - ...(array_any($this->arenas, static fn($arena) => $arena->skin !== null) + ...($this->pause !== null || $this->results !== null || $this->ui !== null || array_any($this->arenas, static fn($arena) => $arena->skin !== null) ? [RendererSessionConfig::CANVAS_CLIP_OPACITY, RendererSessionConfig::CANVAS_GLYPH_EFFECTS] : [])]; } diff --git a/src/Battle/Presentation/BattleProgression.php b/src/Battle/Presentation/BattleProgression.php new file mode 100644 index 00000000..7103a358 --- /dev/null +++ b/src/Battle/Presentation/BattleProgression.php @@ -0,0 +1,37 @@ + $learnedDetails */ + public function __construct( + public int $experienceAwarded, + public ProgressionSnapshot $before, + public ProgressionSnapshot $after, + public array $learnedDetails = [], + ) { + $this->oldLevel = $before->level; + $this->newLevel = $after->level; + } + + public static function fromAward(ExperienceAwardResult $award): self + { + if ($award->before === null || $award->after === null) { + throw new InvalidArgumentException('Battle progression requires snapshots from the EXP award boundary.'); + } + return new self($award->experienceAwarded, $award->before, $award->after, $award->learnedDetails); + } + + public function levelledUp(): bool { return $this->newLevel > $this->oldLevel; } +} diff --git a/src/Battle/Presentation/BattleResultsContent.php b/src/Battle/Presentation/BattleResultsContent.php new file mode 100644 index 00000000..ca3a9df8 --- /dev/null +++ b/src/Battle/Presentation/BattleResultsContent.php @@ -0,0 +1,144 @@ +currentStage()['kind'] !== 'primary') { + return max(1, (int)ceil(count(self::eventLines($playback)) / self::eventPageSize($playback))); + } + return max(1, (int)ceil(count(self::partyRows($playback->rewards)) / 4), + (int)ceil(count(self::itemLines($playback->rewards)) / 4), + (int)ceil(count(self::summaryLines($playback->rewards)) / 3)); + } + + public static function eventPageSize(BattleResultsPlayback $playback): int + { + return $playback->currentStage()['kind'] === 'special' ? 6 : 11; + } + + /** Very long identities continue on another card instead of losing text. */ + public static function partyRows(BattleRewards $rewards): array + { + $rows = []; + foreach ($rewards->progression as $actor => $award) { + $gainWidth = max(98, strlen('+' . $award->experienceAwarded) * 13); + $nameColumns = min(34, (int)floor((582 - $gainWidth - 8) / 14)); + foreach (array_chunk(self::wrap($award->after->name, $nameColumns), 2) as $part => $name) { + $rows[] = ['actor' => $actor, 'name' => $name, 'continued' => $part > 0, + 'nameColumns' => $nameColumns, 'gainWidth' => $gainWidth]; + } + } + return $rows; + } + + public static function itemLines(BattleRewards $rewards): array + { + $lines = []; + foreach ($rewards->items as $item) { + $quantity = 'x' . $item['quantity']; + $width = max(1, 31 - mb_strlen($quantity) - 1); + foreach (self::wrap($item['name'], $width) as $part => $label) { + $lines[] = ['name' => $label, 'quantity' => $part === 0 ? $quantity : '', 'continued' => $part > 0]; + } + if (isset($item['received']) && $item['received'] !== $item['quantity']) { + foreach (self::wrap('Retained: ' . $item['received'] . ' of ' . $item['quantity'], 29) as $label) { + $lines[] = ['name' => $label, 'quantity' => '', 'continued' => true]; + } + } + } + return $lines; + } + + public static function summaryLines(BattleRewards $rewards): array + { + $lines = []; + foreach ($rewards->summary as $label => $value) { + array_push($lines, ...self::wrap($label . ': ' . $value, 37)); + } + foreach ($rewards->specialRewards as $event) { + array_push($lines, ...self::wrap('Special: ' . $event['title'], 37)); + } + return $lines; + } + + /** @return list */ + public static function eventLines(BattleResultsPlayback $playback): array + { + $stage = $playback->currentStage(); + $lines = []; + $add = static function (string $text, string $tone = 'text') use (&$lines): void { + foreach (self::wrap($text, 50) as $line) { $lines[] = ['text' => $line, 'tone' => $tone]; } + }; + if ($stage['kind'] === 'special') { + $event = $playback->rewards->specialRewards[$stage['detail']]; + $add($event['title'], 'accent'); + $add($event['description']); + return $lines; + } + $actor = $stage['actor']; + if ($actor === null) { return []; } + $award = $playback->rewards->progression[$actor]; + $before = $award->before; + $after = $award->after; + assert($before !== null && $after !== null); + $add($after->name, 'accent'); + if ($stage['kind'] === 'level') { + $add('Level ' . $before->level . " \u{2192} " . $after->level, 'accent'); + foreach ($after->stats as $stat => $value) { + if (!array_key_exists($stat, $before->stats)) { continue; } + $old = $before->stats[$stat]; + $delta = $value - $old; + $label = match ($stat) { + 'maxHp' => 'HP', 'maxMp' => 'MP', + default => ucfirst((string)preg_replace('/([a-z])([A-Z])/', '$1 $2', $stat)), + }; + $numbers = $old . " \u{2192} " . $value . ' (' . ($delta > 0 ? '+' : '') . $delta . ')'; + $tone = $delta > 0 ? 'positive' : ($delta < 0 ? 'negative' : 'muted'); + if (mb_strlen($label . $numbers) < 49) { + $add($label . str_repeat(' ', 50 - mb_strlen($label . $numbers)) . $numbers, $tone); + } else { + $add($label, $tone); + $add($numbers, $tone); + } + } + } else { + $entries = $playback->abilities($actor); + $entry = $entries[$stage['detail']]; + $add($entry['name'], 'accent'); + $add(ucfirst($entry['kind']) . ' ' . ($stage['detail'] + 1) . '/' . count($entries), 'muted'); + if (isset($entry['cost'])) { + $add('Cost: ' . $entry['cost'] . ' MP'); + } + $add($entry['description']); + } + return $lines; + } + + /** Scalar-cell wrapping matches CanvasTextLayer, with no truncation or ANSI leakage. */ + public static function wrap(string $text, int $columns): array + { + $text = str_replace(["\r", "\t"], ['', ' '], TerminalText::stripAnsi($text)); + $text = (string)preg_replace('/(?!\n)\p{Cc}/u', '', $text); + $lines = []; + foreach (explode("\n", $text) as $line) { + if ($line === '') { $lines[] = ''; continue; } + while (mb_strlen($line, 'UTF-8') > $columns) { + $chunk = mb_substr($line, 0, $columns, 'UTF-8'); + $space = mb_strrpos($chunk, ' ', 0, 'UTF-8'); + $cut = $space !== false && $space >= intdiv($columns, 2) ? $space : $columns; + $lines[] = mb_substr($line, 0, $cut, 'UTF-8'); + $line = ltrim(mb_substr($line, $cut, null, 'UTF-8')); + } + if ($line !== '') { $lines[] = $line; } + } + return $lines === [] ? [''] : $lines; + } +} diff --git a/src/Battle/Presentation/BattleResultsPlayback.php b/src/Battle/Presentation/BattleResultsPlayback.php new file mode 100644 index 00000000..4b918703 --- /dev/null +++ b/src/Battle/Presentation/BattleResultsPlayback.php @@ -0,0 +1,205 @@ + */ + private array $stages = [['kind' => 'primary', 'actor' => null, 'detail' => null]]; + private int $stageIndex = 0; + private float $elapsed = 0; + private float $clock = 0; + private float $lastConfirm = -INF; + private float $transitionRemaining = 0; + private ?float $exitRemaining = null; + private ?float $confirmationChangedAt = null; + private ?int $scrollLimit = null; + public private(set) int $scrollOffset = 0; + + public function __construct(public readonly BattleRewards $rewards, public readonly bool $reducedMotion = false) + { + foreach ($rewards->progression as $index => $award) { + if ($award->after->actorId !== $award->before->actorId) { + throw new InvalidArgumentException('Results progression snapshots must describe the same actor.'); + } + if ($award->after->level > $award->before->level) { + $this->stages[] = ['kind' => 'level', 'actor' => $index, 'detail' => null]; + } + foreach ($this->abilities($index) as $detail => $_) { + $this->stages[] = ['kind' => 'ability', 'actor' => $index, 'detail' => $detail]; + } + } + foreach ($rewards->specialRewards as $detail => $_) { + $this->stages[] = ['kind' => 'special', 'actor' => null, 'detail' => $detail]; + } + if ($reducedMotion) { $this->elapsed = $this->duration(); } + } + + public function update(float $delta): void + { + if (!is_finite($delta) || $delta < 0) { + throw new InvalidArgumentException('Results delta must be finite and nonnegative.'); + } + $this->clock += $delta; + if ($this->exitRemaining !== null) { + $this->exitRemaining = max(0, $this->exitRemaining - $delta); + return; + } + $held = min($delta, $this->transitionRemaining); + $this->transitionRemaining -= $held; + $remaining = $this->duration() - $this->elapsed; + $activeDelta = $delta - $held; + $this->elapsed = min($this->duration(), $this->elapsed + $activeDelta); + if ($remaining > 0 && $activeDelta >= $remaining) { + // Preserve the actual completion boundary even when a frame crosses it. + $this->confirmationChangedAt = $this->clock - ($activeDelta - $remaining); + } + } + + /** A semantic press: callers must reject native repeats where that information exists. */ + public function confirm(): bool + { + if ($this->isFinished()) { return true; } + if ($this->exitRemaining !== null) { return false; } + // Refresh on every attempted repeat, rather than letting a held stream advance every 80ms. + $armed = $this->clock - $this->lastConfirm >= self::REARM; + $this->lastConfirm = $this->clock; + if (!$armed || !$this->confirmation()['enabled']) { return false; } + if (!$this->isComplete()) { + $this->elapsed = $this->duration(); + $this->confirmationChangedAt = $this->clock; + return false; + } + if ($this->stageIndex + 1 < count($this->stages)) { + $this->stageIndex++; + $this->scrollOffset = 0; + $this->scrollLimit = null; + $this->elapsed = $this->reducedMotion ? $this->duration() : 0; + $this->transitionRemaining = $this->reducedMotion ? 0 : self::TRANSITION; + $this->confirmationChangedAt = null; + } else { + $this->exitRemaining = $this->reducedMotion ? 0 : self::EXIT; + } + return $this->isFinished(); + } + + public function navigate(int $direction): void + { + if ($this->exitRemaining !== null || $this->transitionRemaining > 0 || !$this->isComplete()) { return; } + $this->scrollOffset = max(0, min($this->pageCount() - 1, $this->scrollOffset + ($direction <=> 0))); + } + + /** @return array{kind: string, actor: ?int, detail: ?int} */ + public function currentStage(): array { return $this->stages[$this->stageIndex]; } + public function isComplete(): bool { return $this->elapsed >= $this->duration() && $this->transitionRemaining === 0.0; } + public function isFinished(): bool { return $this->exitRemaining === 0.0; } + public function time(): float { return $this->clock; } + public function stageTime(): float { return $this->elapsed; } + public function isExiting(): bool { return $this->exitRemaining !== null; } + + /** + * The whole action changes together; input cannot overtake its visible label. + * @return array{label: string, opacity: float, enabled: bool} + */ + public function confirmation(): array + { + $label = $this->isComplete() ? 'Continue' : 'Complete'; + $opacity = 1.0; + $changing = false; + if (!$this->reducedMotion && $this->confirmationChangedAt !== null) { + $time = $this->clock - $this->confirmationChangedAt; + $incomingAt = self::BUTTON_FADE_OUT + self::BUTTON_GAP; + $changing = $time < $incomingAt + self::BUTTON_FADE_IN; + if ($time < self::BUTTON_FADE_OUT) { + $label = 'Complete'; + $opacity = max(0.0, 1 - $time / self::BUTTON_FADE_OUT); + } else { + $opacity = max(0.0, min(1.0, ($time - $incomingAt) / self::BUTTON_FADE_IN)); + } + } + return ['label' => $label, 'opacity' => $opacity, + 'enabled' => !$changing && !$this->isExiting() && $this->transitionRemaining === 0.0]; + } + + public function pageCount(): int { return ($this->scrollLimit ?? (BattleResultsContent::pageCount($this) - 1)) + 1; } + /** Each presentation declares its own page geometry, without changing stage or reward facts. */ + public function setScrollLimit(int $maxOffset): void + { + if ($maxOffset < 0) { throw new InvalidArgumentException('Results scroll limit cannot be negative.'); } + $this->scrollLimit = $maxOffset; + $this->scrollOffset = min($this->scrollOffset, $maxOffset); + } + + /** Counts character ability groups once, while preserving their inner detail index. */ + public function eventCounter(): array + { + $groups = []; + foreach ($this->stages as $index => $stage) { + if ($stage['kind'] === 'primary') { continue; } + $key = $stage['kind'] . ':' . ($stage['actor'] ?? $stage['detail']); + $groups[$key] ??= count($groups) + 1; + if ($index === $this->stageIndex) { $current = $groups[$key]; } + } + return ['current' => $current ?? 0, 'total' => count($groups)]; + } + + /** Existing facts only, detached from live characters at the award boundary. */ + public function abilities(int $actor): array + { + return $this->rewards->progression[$actor]->learnedDetails; + } + + public function opacity(): float + { + if ($this->exitRemaining !== null) { return $this->exitRemaining / self::EXIT; } + if ($this->reducedMotion) { return 1; } + return min(1, max(0, ($this->elapsed - ($this->stageIndex === 0 ? 0.18 : 0)) / 0.32)); + } + + public function reveal(float $delay = 0, float $duration = 0.4): float + { + return $this->reducedMotion || $this->isComplete() ? 1 : max(0, min(1, ($this->elapsed - $delay) / $duration)); + } + + /** @return array{level: int, current: int, needed: int, ratio: float, maximum: bool, experience: int} */ + public function progress(int $actor): array + { + $award = $this->rewards->progression[$actor]; + $before = $award->before; + $after = $award->after; + assert($before !== null && $after !== null); + if ($this->currentStage()['kind'] !== 'primary' || $this->reducedMotion || $this->isComplete()) { + return [...$after->progressAt($after->experience), 'experience' => $after->experience]; + } + $thresholds = array_values(array_filter($after->thresholds, + static fn(int $value, int $level): bool => $level <= $after->maxLevel + && $value > $before->experience && $value <= $after->experience, ARRAY_FILTER_USE_BOTH)); + $points = [$before->experience, ...$thresholds, $after->experience]; + $segments = count($points) - 1; + $position = $this->reveal(0.9, 1.3) * $segments; + $segment = min($segments - 1, (int)$position); + $fraction = min(1.0, ($position - $segment) / 0.85); + $experience = (int)floor($points[$segment] + ($points[$segment + 1] - $points[$segment]) * $fraction); + $progress = $after->progressAt($experience); + // Hold a full gauge at each crossed threshold before the next segment resets it. + if ($segment < count($thresholds) && $fraction === 1.0) { + $progress = $after->progressAt(max(0, $points[$segment + 1] - 1)); + $progress['current'] = $progress['needed']; + $progress['ratio'] = 1.0; + } + return [...$progress, 'experience' => $experience]; + } + + private function duration(): float { return $this->currentStage()['kind'] === 'primary' ? 2.25 : 1.0; } +} diff --git a/src/Battle/Presentation/BattleResultsSkin.php b/src/Battle/Presentation/BattleResultsSkin.php new file mode 100644 index 00000000..7589dbc2 --- /dev/null +++ b/src/Battle/Presentation/BattleResultsSkin.php @@ -0,0 +1,96 @@ + */ + public array $textures; + /** @var array */ + public array $colors; + /** @var array */ + public array $portraits; + /** @var array */ + public array $icons; + + public function __construct(array $textures, array $colors, array $portraits = [], array $icons = []) + { + $this->textures = self::roles($textures, CanvasNineSlice::class, + ['panel', 'quiet', 'track', 'selector', 'portrait', 'exp', 'divider', 'button']); + $this->colors = self::roles($colors, PresentationColor::class, + ['text', 'muted', 'accent', 'positive', 'negative', 'ink']); + $copy = []; + foreach ($portraits as $id => $families) { + if (!is_string($id) || trim($id) === '' || !is_array($families) + || array_diff(array_keys($families), ['menu', 'bust']) !== []) { + throw new InvalidArgumentException('Results portraits require actor IDs and separate menu/bust families.'); + } + foreach (['menu', 'bust'] as $family) { + $art = $families[$family] ?? null; + if ($art !== null) { self::plainImage($art); } + $copy[$id][$family] = $art; + } + } + foreach ($icons as $category => $art) { + if (!is_string($category) || trim($category) === '') { + throw new InvalidArgumentException('Results icon categories must be nonempty strings.'); + } + self::plainImage($art); + } + foreach ($textures as $texture) { + if ($texture->density !== 1) { + throw new InvalidArgumentException('Results use one-density PNG assets only.'); + } + } + $this->portraits = $copy; + $this->icons = $icons; + } + + /** Portraits and icons are whole images; their source sizes belong to the files, not the catalog. */ + public function withCurrentImages(string $root): self + { + $resolve = static function (CanvasNineSlice $art) use ($root): CanvasNineSlice { + $current = CanvasNineSlice::fromPng($root, $art->asset); + if ($current->source != $art->source) { + Debug::warn("Results whole-image metadata reconciled to the current PNG: {$art->asset}"); + } + return $current; + }; + $portraits = []; + foreach ($this->portraits as $id => $families) { + foreach ($families as $family => $art) { + $portraits[$id][$family] = $art === null ? null : $resolve($art); + } + } + return new self($this->textures, $this->colors, $portraits, array_map($resolve, $this->icons)); + } + + private static function plainImage(mixed $art): void + { + if (!$art instanceof CanvasNineSlice || $art->density !== 1 + || $art->left + $art->top + $art->right + $art->bottom !== 0) { + throw new InvalidArgumentException('Results portraits and icons require a plain one-density image.'); + } + } + + private static function roles(array $entries, string $type, array $roles): array + { + if (count($entries) !== count($roles)) { + throw new InvalidArgumentException('Results skin must declare exactly the supported roles.'); + } + foreach ($roles as $role) { + if (!($entries[$role] ?? null) instanceof $type) { + throw new InvalidArgumentException("Results skin requires a typed {$role} entry."); + } + } + return $entries; + } +} diff --git a/src/Battle/Presentation/BattleResultsText.php b/src/Battle/Presentation/BattleResultsText.php new file mode 100644 index 00000000..955c002c --- /dev/null +++ b/src/Battle/Presentation/BattleResultsText.php @@ -0,0 +1,39 @@ + */ + public static function lines(BattleResultsPlayback $playback): array + { + $stage = $playback->currentStage(); + $rewards = $playback->rewards; + if ($stage['kind'] !== 'primary') { + return array_column(BattleResultsContent::eventLines($playback), 'text'); + } + switch ($stage['kind']) { + case 'primary': + $lines = [sprintf('EXP per member: %d Gold: %d G', $rewards->experiencePerMember, $rewards->gold), 'Party Progress']; + foreach ($rewards->progression as $member) { + if ($member->after === null) { continue; } + $lines[] = sprintf('%s: Lv %d -> %d +%d EXP', $member->after->name, + $member->oldLevel, $member->newLevel, $member->experienceAwarded); + } + $lines[] = 'Rewards'; + foreach ($rewards->items as $item) { + $lines[] = sprintf('%s x%d', $item['name'], $item['quantity']) + . (isset($item['received']) && $item['received'] !== $item['quantity'] + ? sprintf(' (retained %d)', $item['received']) : ''); + } + if ($rewards->items === []) { $lines[] = 'No item drops.'; } + foreach ($rewards->summary as $label => $value) { $lines[] = $label . ': ' . $value; } + return $lines; + default: + return []; + } + } +} diff --git a/src/Battle/Presentation/BattleRewards.php b/src/Battle/Presentation/BattleRewards.php new file mode 100644 index 00000000..ff4417d1 --- /dev/null +++ b/src/Battle/Presentation/BattleRewards.php @@ -0,0 +1,46 @@ + */ + public array $progression; + /** + * @param list $progression + * @param list $items Drop quantities and actual inventory deltas. + * @param array $summary Only metrics actually supplied by combat. + * @param list $specialRewards Explicit authored rewards, never inferred rarity. + */ + public function __construct( + public int $experiencePerMember, + public int $gold, + array $progression, + public array $items = [], + public array $summary = [], + public array $specialRewards = [], + ) { + $this->progression = array_map(static fn(ExperienceAwardResult|BattleProgression $award): BattleProgression => + $award instanceof BattleProgression ? $award : BattleProgression::fromAward($award), array_values($progression)); + } + + /** @param InventoryItem[] $items */ + public static function snapshotItems(array $items): array + { + $rows = []; + foreach ($items as $item) { + if (!isset($rows[$item->id])) { + $rows[$item->id] = ['id' => $item->id, 'name' => $item->name, + 'description' => $item->description, 'quantity' => 0]; + } + $rows[$item->id]['quantity'] += $item->quantity; + } + return array_values($rows); + } +} diff --git a/src/Battle/Presentation/BattlerArtwork.php b/src/Battle/Presentation/BattlerArtwork.php index 82ebe33c..50d8da1d 100644 --- a/src/Battle/Presentation/BattlerArtwork.php +++ b/src/Battle/Presentation/BattlerArtwork.php @@ -26,4 +26,51 @@ public function __construct( throw new InvalidArgumentException('Artwork dimensions must match its crop and contain its finite pivot.'); } } + + /** + * Reconciles this authored artwork with the image as it exists right now. + * + * Artwork changes throughout development, and the image on disk is this + * moment's truth: supplying the right asset is the developer's job, and + * the engine renders best-effort against any revision of it. A crop that + * still overlaps the image clamps to it; a crop that no longer exists at + * all falls back to the whole image, so the developer always sees their + * art. The pivot clamps into whatever renders. Metadata that already fits + * passes through as this same instance. + * + * @param int $imageWidth The image's current width in pixels. + * @param int $imageHeight The image's current height in pixels. + * @return self The artwork to render. + */ + public function clampedTo(int $imageWidth, int $imageHeight): self + { + $authored = $this->sourceRect ?? new SpriteSourceRect(0, 0, $this->width, $this->height); + + if ($authored->x >= $imageWidth || $authored->y >= $imageHeight) { + return new self( + $this->asset, + $imageWidth, + $imageHeight, + min($this->pivotX, (float) $imageWidth), + min($this->pivotY, (float) $imageHeight), + new SpriteSourceRect(0, 0, $imageWidth, $imageHeight), + ); + } + + $width = min($authored->x + $authored->width, $imageWidth) - $authored->x; + $height = min($authored->y + $authored->height, $imageHeight) - $authored->y; + + if ($width === $authored->width && $height === $authored->height) { + return $this; + } + + return new self( + $this->asset, + $width, + $height, + min($this->pivotX, (float) $width), + min($this->pivotY, (float) $height), + new SpriteSourceRect($authored->x, $authored->y, $width, $height), + ); + } } diff --git a/src/Battle/Presentation/GraphicalBattleHud.php b/src/Battle/Presentation/GraphicalBattleHud.php index fa277a70..13d3959e 100644 --- a/src/Battle/Presentation/GraphicalBattleHud.php +++ b/src/Battle/Presentation/GraphicalBattleHud.php @@ -5,6 +5,7 @@ namespace Ichiloto\Engine\Battle\Presentation; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImagePreflight; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasRectangle; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasTextLayer; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; @@ -16,7 +17,15 @@ /** Composes owned, live battle windows. It never selects or evaluates commands. */ final class GraphicalBattleHud { - public static function compose(BattleArenaDefinition $arena, BattleHudSnapshot $hud, ?string $focus, float $now): PresentationCanvas + /** @return array Decoded source costs, shared with arena preflight. */ + public static function preflight(BattleCanvasLayout $layout, string $assetRoot): array + { + return CanvasImagePreflight::inspect(CanvasImagePreflight::textures(array_values([ + ...($layout->skin?->textures ?? []), ...($layout->skin?->targetCursor?->textures ?? []), + ])), $assetRoot)['sources']; + } + + public static function compose(BattleCanvasLayout $arena, BattleHudSnapshot $hud, ?string $focus, float $now): PresentationCanvas { $skin = $arena->skin; if ($skin === null) { return new PresentationCanvas($arena->width, $arena->height); } diff --git a/src/Battle/Presentation/GraphicalBattlePause.php b/src/Battle/Presentation/GraphicalBattlePause.php new file mode 100644 index 00000000..2c911149 --- /dev/null +++ b/src/Battle/Presentation/GraphicalBattlePause.php @@ -0,0 +1,122 @@ +scale = $menu->scale(); + $this->opacity = $menu->opacity(); + $this->x = ($canvasWidth - $width * $this->scale) / 2; + $this->y = ($canvasHeight - $height * $this->scale) / 2; + } + + /** @param list $battlefield */ + public static function preflight(BattlePauseSkin $skin, string $root, array $battlefield = []): void + { + CanvasImagePreflight::inspect([...$battlefield, ...CanvasImagePreflight::textures(array_values($skin->textures))], $root); + foreach (['panel' => [400, 320], 'normal' => [336, 40], 'selected' => [336, 40], + 'pressed' => [336, 40], 'focus' => [336, 40], 'selector' => [16, 16], 'divider' => [72, 12]] as $role => [$w, $h]) { + $skin->textures[$role]->images('pause-preflight', new CanvasRectangle(0, 0, $w, $h), 0); + } + } + + public static function frame(PresentationCanvas $battlefield, BattlePauseSkin $skin, BattlePauseMenu $menu): PresentationCanvas + { + if ($menu->isClosed()) { return $battlefield; } + $confirm = $menu->confirmation !== null; + $width = $confirm ? 520 : 400; + $height = $confirm ? 276 : 320; + if ($battlefield->width < $width || $battlefield->height < $height) { + throw new InvalidArgumentException('Pause panel exceeds the logical canvas; use the existing minimum-window/uniform-fit policy.'); + } + $view = new self($skin, $menu, $battlefield->width, $battlefield->height, $width, $height); + $view->fill('backing', 0, 0, $width, $height, 'ink', 10000); + $view->image('panel', 'panel', 0, 0, $width, $height); + $view->line('heading', $menu->heading(), 0, $confirm ? 28 : 24, $width, 14, 36); + $dividerY = $confirm ? 75 : 66; + $view->image('divider', 'divider', ($width - 72) / 2, $dividerY, 72, 12); + $view->fill('rule-left', 32, $dividerY + 6, ($width - 72) / 2 - 40, 1, 'accent', 10001); + $view->fill('rule-right', ($width + 72) / 2 + 8, $dividerY + 6, ($width - 72) / 2 - 40, 1, 'accent', 10001); + if ($confirm) { $view->line('warning', BattlePauseMenu::WARNING, 32, 104, $width - 64, 10, 28); } + foreach ($menu->labels() as $index => $label) { + $rowWidth = $confirm ? ($width - 76) / 2 : $width - 64; + $x = 32 + ($confirm ? $index * ($rowWidth + 12) : 0); + $y = $confirm ? 164 : 86 + $index * 44; + $rowHeight = $confirm ? 44 : 40; + $selected = $menu->selection === $index; + $view->image('row-' . $index, $selected ? ($menu->isPressed() ? 'pressed' : 'selected') : 'normal', $x, $y, $rowWidth, $rowHeight); + if ($selected) { + $view->image('focus', 'focus', $x, $y, $rowWidth, $rowHeight); + if (!$confirm) { + $offset = GraphicalBattleHud::cursorOffset($menu->time(), $menu->reducedMotion || !$menu->isReady()); + $view->image('cursor', 'selector', $x + 17 + $offset, $y + ($rowHeight - 16) / 2, 16, 16); + } + } + $view->line('label-' . $index, $label, $x, $y + ($rowHeight - 28) / 2, $rowWidth, 10, 28); + } + return new PresentationCanvas($battlefield->width, $battlefield->height, + [...$battlefield->images, ...$view->images], $battlefield->indicators, [...$battlefield->textLayers, ...$view->text]); + } + + private function image(string $id, string $role, float $x, float $y, float $width, float $height): void + { + foreach ($this->skin->textures[$role]->images('pause-' . $id, new CanvasRectangle($x, $y, $width, $height), 10002) as $image) { + $rect = $image->destination; + $this->images[] = new CanvasImage($image->id, $image->asset, $this->rect($rect->x, $rect->y, $rect->width, $rect->height), + $image->layer, $image->sourceRect, $this->opacity); + } + } + + private function line(string $id, string $label, float $x, float $y, float $width, int $cellWidth, int $cellHeight, string $color = 'text'): void + { + $length = mb_strlen($label, 'UTF-8'); + $cw = max(1, (int)round($cellWidth * $this->scale)); + $ch = max(1, (int)round($cellHeight * $this->scale)); + if ($length * $cw > $width * $this->scale - 32) { throw new InvalidArgumentException('Pause label needs a wider panel.'); } + $this->text[] = new CanvasTextLayer('pause-' . $id, 10003, + $this->x + ($x + $width / 2) * $this->scale - $length * $cw / 2, $this->y + $y * $this->scale, + new RendererGridConfig($length, 1, $cw, $ch), [new PresentationTextRun(0, 0, $label, $this->skin->colors[$color])], opacity: $this->opacity); + } + + private function fill(string $id, float $x, float $y, float $width, float $height, string $color, int $layer): void + { + $rect = $this->rect($x, $y, $width, $height); + $columns = (int)ceil($rect->width / 256); + $rows = (int)ceil($rect->height / 256); + $cw = (int)ceil($rect->width / $columns); + $ch = (int)ceil($rect->height / $rows); + $runs = []; + for ($row = 0; $row < $rows; $row++) { + $runs[] = new PresentationTextRun($row, 0, str_repeat(' ', $columns), background: $this->skin->colors[$color]); + } + $this->text[] = new CanvasTextLayer('pause-' . $id, $layer, $rect->x, $rect->y, + new RendererGridConfig($columns, $rows, $cw, $ch), $runs, $rect, $this->opacity); + } + + private function rect(float $x, float $y, float $width, float $height): CanvasRectangle + { + return new CanvasRectangle($this->x + $x * $this->scale, $this->y + $y * $this->scale, $width * $this->scale, $height * $this->scale); + } +} diff --git a/src/Battle/Presentation/GraphicalBattlePresentation.php b/src/Battle/Presentation/GraphicalBattlePresentation.php index d7f88d5f..f148a0de 100644 --- a/src/Battle/Presentation/GraphicalBattlePresentation.php +++ b/src/Battle/Presentation/GraphicalBattlePresentation.php @@ -10,6 +10,7 @@ use Ichiloto\Engine\IO\Console\SgrColorParser; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImagePreflight; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasIndicator; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasIndicatorKind; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasRectangle; @@ -18,6 +19,7 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationColor; use Ichiloto\Engine\Rendering\Presentation\PresentationTextRun; use Ichiloto\Engine\Rendering\Sprites\PngAssetPreflight; +use Ichiloto\Engine\Util\Debug; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Ichiloto\Engine\Scenes\Battle\BattleConfig; @@ -43,10 +45,10 @@ public function requiredCapabilities(): array public static function prepare(BattleConfig $battle, BattlePresentationCatalog $catalog, string $assetRoot): ?self { - $arena = $catalog->arenas[$battle->troop->definitionId ?? $battle->troop->name] ?? null; + $arena = $catalog->arenaFor($battle); if ($arena === null) { return null; } + if ($catalog->ui !== null) { $arena = $arena->withDefaultUi($catalog->ui); } $presentation = new self($arena, $battle); - $assets = []; $images = [$arena->background]; foreach ([[$battle->party->members->toArray(), $arena->partySlots, true], [$battle->troop->members->toArray(), $arena->enemySlots, false]] as [$members, $slots, $party]) { @@ -57,9 +59,20 @@ public static function prepare(BattleConfig $battle, BattlePresentationCatalog $ if ($art === null || $slot === null) { throw new RuntimeException("Graphical battle requires artwork and a formation slot for every participant: {$key}"); } - $source = PngAssetPreflight::inspect($assetRoot, $art->asset, $art->sourceRect); - if ($source !== ['width' => $art->width, 'height' => $art->height]) { - throw new RuntimeException("Graphical battler dimensions do not match the PNG/crop: {$key}"); + // Artwork changes throughout development; the image on disk is this + // moment's truth. Authored metadata reconciles to it and the battle + // renders best-effort, with the mismatch logged for the author + // rather than blocking play. Assets only fail here when missing, + // not PNGs, or corrupt. + $probe = PngAssetPreflight::inspect($assetRoot, $art->asset); + $reconciled = $art->clampedTo($probe['width'], $probe['height']); + if ($reconciled !== $art) { + Debug::warn(sprintf( + 'Battler artwork changed under its authored metadata; rendering a best-effort clamped crop: %s (%s)', + $key, + $art->asset, + )); + $art = $reconciled; } $identity = spl_object_id($member); if (isset($presentation->participants[$identity])) { @@ -80,18 +93,13 @@ public static function prepare(BattleConfig $battle, BattlePresentationCatalog $ $images[] = $image; } } - foreach ($images as $image) { - PngAssetPreflight::inspect($assetRoot, $image->asset, $image->sourceRect); - $size = PngAssetPreflight::inspect($assetRoot, $image->asset); - $assets[$image->asset] = $size['width'] * $size['height'] * 4; - } - foreach ([...($arena->skin?->textures ?? []), ...($arena->skin?->targetCursor?->textures ?? [])] as $texture) { - PngAssetPreflight::inspect($assetRoot, $texture->asset, $texture->source); - $size = PngAssetPreflight::inspect($assetRoot, $texture->asset); - $assets[$texture->asset] = $size['width'] * $size['height'] * 4; - } - if (array_sum($assets) > 67108864) { - throw new RuntimeException('Graphical battle PNG sources exceed the native 64 MiB decoded-image budget.'); + $hud = CanvasImagePreflight::textures(array_values([ + ...($arena->skin?->textures ?? []), ...($arena->skin?->targetCursor?->textures ?? []), + ])); + CanvasImagePreflight::inspect([...$images, ...$hud], $assetRoot); + if ($catalog->results !== null) { + GraphicalBattleResults::preflight($catalog->results, $assetRoot, $images, + array_map(static fn(Character $member): string => $member->actorId, $battle->party->members->toArray())); } if (count($battle->party->battlers) > count($arena->partySlots)) { throw new RuntimeException('Graphical battle requires a slot for every active party member.'); diff --git a/src/Battle/Presentation/GraphicalBattleResults.php b/src/Battle/Presentation/GraphicalBattleResults.php new file mode 100644 index 00000000..451effd8 --- /dev/null +++ b/src/Battle/Presentation/GraphicalBattleResults.php @@ -0,0 +1,312 @@ +opacity = $playback->opacity(); + } + + /** Validate the catalog, but budget only concurrently displayed families. + * @param list $battlefield + * @param list|null $actorIds Party order, including reserves; null checks the whole catalog. + * @return array Catalog source costs, not a concurrent frame budget. + */ + public static function preflight(BattleResultsSkin $skin, string $root, array $battlefield = [], ?array $actorIds = null): array + { + return self::preflightPrepared($skin->withCurrentImages($root), $root, $battlefield, $actorIds); + } + + /** Resolve replaceable whole images once for the disposable Results presentation. */ + public static function prepare(BattleResultsSkin $skin, string $root, array $battlefield = [], ?array $actorIds = null): BattleResultsSkin + { + $skin = $skin->withCurrentImages($root); + self::preflightPrepared($skin, $root, $battlefield, $actorIds); + return $skin; + } + + private static function preflightPrepared(BattleResultsSkin $skin, string $root, array $battlefield, ?array $actorIds): array + { + foreach (['panel' => [754, 502], 'quiet' => [470, 164], 'track' => [582, 12], + 'selector' => [16, 16], 'portrait' => [96, 96], 'exp' => [578, 8], + 'divider' => [440, 16], 'button' => [230, 46]] as $role => [$width, $height]) { + $skin->textures[$role]->images('results-preflight', new CanvasRectangle(0, 0, $width, $height), 0); + } + $assets = []; + $art = [...array_values($skin->textures), ...array_values($skin->icons)]; + foreach ($skin->portraits as $families) { + array_push($art, ...array_values(array_filter($families))); + } + foreach ($art as $texture) { + PngAssetPreflight::inspect($root, $texture->asset, $texture->source); + $size = PngAssetPreflight::inspect($root, $texture->asset); + $assets[$texture->asset] = $size['width'] * $size['height'] * 4; + } + $base = [...$battlefield, ...CanvasImagePreflight::textures([ + ...array_values($skin->textures), ...array_values($skin->icons), + ])]; + CanvasImagePreflight::inspect($base, $root); + $actorIds ??= array_keys($skin->portraits); + foreach ($actorIds as $index => $id) { + // Primary pages show at most four consecutive actors. Continued names may shift page edges. + $menus = []; + foreach (array_slice($actorIds, $index, 4) as $visibleId) { + $menu = $skin->portraits[$visibleId]['menu'] ?? null; + if ($menu !== null) { $menus[] = $menu; } + } + CanvasImagePreflight::inspect([...$base, ...CanvasImagePreflight::textures($menus)], $root); + $bust = $skin->portraits[$id]['bust'] ?? null; + if ($bust !== null) { + // Event pages replace Primary, and use one actor's bust; no outgoing/incoming union. + CanvasImagePreflight::inspect([...$base, ...CanvasImagePreflight::textures([$bust])], $root); + } + } + return $assets; + } + + public static function frame(PresentationCanvas $battlefield, BattleResultsSkin $skin, + BattleResultsPlayback $playback): PresentationCanvas + { + if ($battlefield->width !== 1350 || $battlefield->height !== 720) { + throw new InvalidArgumentException('This Results layout requires a 1350x720 battle canvas.'); + } + $playback->setScrollLimit(BattleResultsContent::pageCount($playback) - 1); + if ($playback->isFinished() || $playback->opacity() <= 0) { return $battlefield; } + $view = new self($skin, $playback); + $stage = $playback->currentStage(); + $view->heading(match ($stage['kind']) { + 'primary' => 'VICTORY', 'level' => 'LEVEL UP', 'ability' => 'NEW ABILITY', default => 'SPECIAL REWARD', + }); + if ($stage['kind'] === 'primary') { $view->primary(); } else { $view->event(); } + $view->controls(); + return new PresentationCanvas(1350, 720, [...$battlefield->images, ...$view->images], + $battlefield->indicators, [...$battlefield->textLayers, ...$view->text]); + } + + private function heading(string $title): void + { + $this->line('heading', $title, 80, 28, 1190, 36, 54, 'text', align: 'center'); + $this->image('heading-divider', 'divider', new CanvasRectangle(455, 123, 440, 16)); + $counter = $this->playback->eventCounter(); + if ($counter['current'] > 0) { + $this->line('event-counter', $counter['current'] . '/' . $counter['total'], 1138, 90, 160, 11, 22, 'muted', align: 'right'); + } + } + + private function primary(): void + { + $this->image('party', 'panel', new CanvasRectangle(52, 146, 754, 502)); + $this->image('rewards', 'panel', new CanvasRectangle(828, 146, 470, 324)); + $this->image('summary', 'quiet', new CanvasRectangle(828, 484, 470, 164)); + $this->line('party-title', 'PARTY PROGRESS', 80, 168, 698, 11, 22, 'accent'); + $this->line('rewards-title', 'REWARDS', 856, 168, 414, 11, 22, 'accent'); + $this->line('summary-title', 'BATTLE SUMMARY', 856, 508, 414, 11, 22, 'accent'); + $rewards = $this->playback->rewards; + $rows = $this->page(BattleResultsContent::partyRows($rewards), 4); + foreach ($rows as $slot => $row) { + $actor = $row['actor']; + $award = $rewards->progression[$actor]; + $progress = $this->playback->progress($actor); + $y = 198 + $slot * 106; + $alpha = $this->playback->reveal(0.36 + $slot * 0.055, 0.32); + $this->portrait('party-' . $slot, $award->after->actorId, $award->after->name, + 'menu', new CanvasRectangle(80, $y, 96, 96), $alpha); + foreach ($row['name'] as $line => $name) { + $this->line('party-name-' . $slot . '-' . $line, $name, 196, $y + $line * 28, + $row['nameColumns'] * 14, 14, 28, 'text', alpha: $alpha); + } + $this->line('party-level-' . $slot, 'Lv ' . $progress['level'], 680, $y, 98, 13, 26, 'accent', 'right', $alpha); + $gain = $award->experienceAwarded > 0 ? '+' . $award->experienceAwarded : ''; + $this->line('party-gain-' . $slot, $gain, 778 - $row['gainWidth'], $y + 28, + $row['gainWidth'], 13, 26, 'positive', 'right', $alpha); + if ($progress['maximum']) { + $this->line('party-cap-' . $slot, 'MAX LEVEL', 196, $y + 60, 260, 11, 22, 'muted', alpha: $alpha); + } else { + $this->gauge('party-' . $slot, new CanvasRectangle(196, $y + 64, 582, 12), $progress['ratio'], $alpha); + } + $markers = []; + if ($row['continued']) { $markers[] = 'NAME CONTINUED'; } + if ($this->playback->isComplete()) { + if ($award->levelledUp()) { $markers[] = 'LEVEL UP'; } + if ($award->learnedDetails !== []) { $markers[] = 'NEW ABILITY'; } + } + $this->line('party-markers-' . $slot, implode(' / ', $markers), 196, $y + 80, 582, 9, 18, 'accent', alpha: $alpha); + } + $this->total('experience', 'EXP / MEMBER', $rewards->experiencePerMember, 207); + $this->total('gold', 'MONEY', $rewards->gold, 241); + $this->line('items-title', 'ITEMS', 856, 278, 414, 11, 22, 'muted'); + $items = BattleResultsContent::itemLines($rewards); + if ($items === []) { $this->line('items-empty', 'None', 856, 308, 414, 13, 26, 'muted'); } + foreach ($this->page($items, 4) as $index => $item) { + $y = 307 + 27 * $index; + $alpha = $this->playback->reveal(0.65 + $index * 0.075); + $quantityWidth = max(26, mb_strlen($item['quantity']) * 13); + $this->line('item-name-' . $index, $item['name'], 856, $y, 403 - $quantityWidth, 13, 26, 'text', alpha: $alpha); + $this->line('item-quantity-' . $index, $item['quantity'], 1270 - $quantityWidth, $y, $quantityWidth, + 13, 26, 'text', 'right', $alpha); + } + if (count($items) > 4) { + $start = min($this->playback->scrollOffset, (int)ceil(count($items) / 4) - 1) * 4; + $remaining = max(0, count($items) - $start - 4); + $this->line('items-overflow', $remaining > 0 ? '+' . $remaining . ' more lines' : 'End of item list', + 856, 431, 414, 11, 22, 'muted'); + } + $summary = BattleResultsContent::summaryLines($rewards); + foreach ($this->page($summary, 3) as $row => $line) { + $this->line('summary-' . $row, $line, 856, 544 + 26 * $row, 414, 11, 22, 'text'); + } + } + + private function event(): void + { + $this->image('event', 'panel', new CanvasRectangle(100, 146, 1150, 502)); + $stage = $this->playback->currentStage(); + $actor = $stage['actor']; + if ($actor !== null) { + $person = $this->playback->rewards->progression[$actor]->after; + $this->portrait('event', $person->actorId, $person->name, 'bust', new CanvasRectangle(178, 216, 304, 382)); + if ($stage['kind'] === 'ability') { + $this->symbol('ability', 'skill', new CanvasRectangle(538, 560, 48, 48)); + } + } else { + $this->symbol('special', 'item', new CanvasRectangle(608, 212, 134, 134)); + } + foreach ($this->page(BattleResultsContent::eventLines($this->playback), BattleResultsContent::eventPageSize($this->playback)) as $index => $line) { + $this->line('event-line-' . $index, $line['text'], $actor === null ? 350 : 538, + ($actor === null ? 386 : 198) + $index * 32, 650, 13, 26, + $line['tone'], $actor === null ? 'center' : 'left', $this->playback->reveal(0.12 + $index * 0.06, 0.28)); + } + if ($actor !== null && $stage['kind'] === 'level') { + $progress = $this->playback->progress($actor); + $label = $progress['maximum'] ? 'MAX LEVEL' : $progress['current'] . ' / ' . $progress['needed']; + $this->line('event-progress', $label, 538, 553, 650, 13, 26, 'muted', 'right'); + if (!$progress['maximum']) { + $this->gauge('event-progress', new CanvasRectangle(538, 594, 650, 12), $progress['ratio']); + } + } + } + + private function controls(): void + { + $prompt = $this->playback->confirmation(); + if ($prompt['opacity'] > 0) { + $this->image('confirm', 'button', new CanvasRectangle(560, 662, 230, 46), alpha: $prompt['opacity']); + $this->line('confirm', $prompt['label'], 560, 671, 230, 14, 28, 'text', 'center', $prompt['opacity']); + } + if ($this->playback->pageCount() > 1) { + $this->line('pages', 'Page ' . ($this->playback->scrollOffset + 1) . '/' . $this->playback->pageCount() + . ' - Navigate to view', 810, 673, 488, 11, 22, 'muted'); + } + } + + private function total(string $id, string $label, int $value, int $y): void + { + $this->line($id . '-label', $label, 856, $y + 2, 143, 11, 22, 'muted'); + $this->line($id . '-value', (string)$value, 1010, $y, 260, 13, 26, 'accent', 'right'); + } + + private function portrait(string $id, string $actorId, string $name, string $family, CanvasRectangle $bounds, float $alpha = 1): void + { + $this->image($id . '-portrait-frame', 'portrait', $bounds, alpha: $alpha); + $art = $this->skin->portraits[$actorId][$family] ?? null; + if ($art !== null) { + $this->contain($id . '-portrait', $art, + new CanvasRectangle($bounds->x + 10, $bounds->y + 10, $bounds->width - 20, $bounds->height - 20), $alpha); + return; + } + $words = preg_split('/\s+/u', trim($name), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $initials = implode('', array_map(static fn(string $word): string => mb_substr($word, 0, 1, 'UTF-8'), array_slice($words, 0, 2))); + $this->line($id . '-initials', $initials === '' ? '?' : $initials, $bounds->x + 12, + $bounds->y + ($bounds->height - 40) / 2, $bounds->width - 24, 20, 40, 'accent', 'center', $alpha); + } + + private function symbol(string $id, string $category, CanvasRectangle $bounds): void + { + $art = $this->skin->icons[$category] ?? $this->skin->icons['unknown'] ?? null; + if ($art !== null) { $this->contain($id, $art, $bounds); return; } + $this->line($id . '-symbol', '?', $bounds->x, $bounds->y + ($bounds->height - 54) / 2, + $bounds->width, 36, 54, 'accent', 'center'); + } + + private function contain(string $id, CanvasNineSlice $art, CanvasRectangle $bounds, float $alpha = 1): void + { + $scale = min($bounds->width / $art->source->width, $bounds->height / $art->source->height); + $width = $art->source->width * $scale; + $height = $art->source->height * $scale; + $this->images[] = new CanvasImage('results-' . $id, $art->asset, + new CanvasRectangle($bounds->x + ($bounds->width - $width) / 2, $bounds->y + ($bounds->height - $height) / 2, $width, $height), + self::BASE_LAYER + 2, $art->source, $alpha * $this->opacity, $bounds); + } + + private function gauge(string $id, CanvasRectangle $bounds, float $ratio, float $alpha = 1): void + { + $this->image($id . '-track', 'track', $bounds, alpha: $alpha); + if ($ratio <= 0) { return; } + $inner = new CanvasRectangle($bounds->x + 2, $bounds->y + 2, $bounds->width - 4, $bounds->height - 4); + $this->image($id . '-fill', 'exp', $inner, + new CanvasRectangle($inner->x, $inner->y, $inner->width * min(1, $ratio), $inner->height), $alpha); + } + + private function image(string $id, string $role, CanvasRectangle $bounds, ?CanvasRectangle $clip = null, float $alpha = 1): void + { + foreach ($this->skin->textures[$role]->images('results-' . $id, $bounds, self::BASE_LAYER + 1, $clip) as $image) { + $this->images[] = new CanvasImage($image->id, $image->asset, $image->destination, $image->layer, + $image->sourceRect, $this->opacity * $alpha, $image->clipRect); + } + } + + private function line(string $id, string $label, float $x, float $y, float $width, int $cellWidth, int $cellHeight, + string $color, string $align = 'left', float $alpha = 1): void + { + if ($label === '') { return; } + $columns = (int)floor($width / $cellWidth); + $length = mb_strlen($label, 'UTF-8'); + if ($length > $columns) { throw new RuntimeException('Results text must be paginated before composition: ' . $id); } + if ($align === 'center') { + // Center the actual glyph cells, not a rounded number of padding cells. + $x += ($width - $length * $cellWidth) / 2; + $columns = $length; + $width = $length * $cellWidth; + } + $column = $align === 'right' ? $columns - $length : 0; + $effects = $id === 'heading' ? new CanvasGlyphEffects(1, $this->skin->colors['ink'], + 1, 2, 1, 0.8, $this->skin->colors['ink']) : null; + // Counters and navigation hints sit over arbitrary arena art, unlike panel text. + $background = in_array($id, ['event-counter', 'pages'], true) ? $this->skin->colors['ink'] : null; + $this->text[] = new CanvasTextLayer('results-' . $id, self::BASE_LAYER + 3, $x, $y, + new RendererGridConfig($columns, 1, $cellWidth, $cellHeight), + [new PresentationTextRun(0, $column, $label, $this->skin->colors[$color], $background)], + $effects === null ? new CanvasRectangle($x, $y, $width, $cellHeight) : null, + $alpha * $this->opacity, $effects); + } + + private function page(array $rows, int $size): array + { + $page = min($this->playback->scrollOffset, max(0, (int)ceil(count($rows) / $size) - 1)); + return array_slice($rows, $page * $size, $size); + } +} diff --git a/src/Battle/Presentation/PauseAction.php b/src/Battle/Presentation/PauseAction.php new file mode 100644 index 00000000..91f8e8b5 --- /dev/null +++ b/src/Battle/Presentation/PauseAction.php @@ -0,0 +1,13 @@ +pausedAt ??= ($this->feedbackTiming ??= new BattleFeedbackTiming())->now(); + } + + public function resumeTiming(bool $discard = false): void + { + if ($this->pausedAt !== null && !$discard) { + $elapsed = max(0, ($this->feedbackTiming ??= new BattleFeedbackTiming())->now() - $this->pausedAt); + foreach ($this->feedback as &$feedback) { $feedback['shownAt'] += $elapsed; } + unset($feedback); + } + $this->pausedAt = null; + } public function render(?int $x = null, ?int $y = null): void { diff --git a/src/Battle/UI/BattleResultWindow.php b/src/Battle/UI/BattleResultWindow.php index 0e2da692..8e504db4 100644 --- a/src/Battle/UI/BattleResultWindow.php +++ b/src/Battle/UI/BattleResultWindow.php @@ -3,6 +3,8 @@ namespace Ichiloto\Engine\Battle\UI; use Ichiloto\Engine\Battle\BattleResult; +use Ichiloto\Engine\Battle\Presentation\BattleResultsPlayback; +use Ichiloto\Engine\Battle\Presentation\BattleResultsText; use Ichiloto\Engine\Core\Time; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\UI\Windows\Window; @@ -38,6 +40,29 @@ class BattleResultWindow extends Window */ protected float $revealDelay = 0.75; + /** Projects all structured facts through explicit pages rather than truncating rewards. */ + public function displayPlayback(BattleResultsPlayback $playback): void + { + $wrapped = []; + foreach (BattleResultsText::lines($playback) as $line) { + array_push($wrapped, ...explode("\n", wrap_text($line, self::WIDTH - 4))); + } + $height = self::HEIGHT - 2; + $pages = max(1, (int)ceil(count($wrapped) / $height)); + $playback->setScrollLimit($pages - 1); + $page = min($pages - 1, $playback->scrollOffset); + $content = array_pad(array_slice($wrapped, $page * $height, $height), $height, ''); + $title = 'Victory - ' . ucfirst($playback->currentStage()['kind']); + $prompt = $playback->confirmation(); + $help = ($prompt['enabled'] ? 'enter:' . $prompt['label'] : '') + . ($pages > 1 ? sprintf(' up/down:Page %d/%d', $page + 1, $pages) : ''); + if ($this->getContent() === $content && $this->getHelp() === $help && $this->getTitle() === $title) { return; } + $this->setTitle($title); + $this->setHelp($help); + $this->setContent($content); + $this->render(); + } + public function __construct(protected BattleScreen $battleScreen) { parent::__construct( diff --git a/src/Battle/UI/BattleScreen.php b/src/Battle/UI/BattleScreen.php index d079020e..2c2cc32f 100644 --- a/src/Battle/UI/BattleScreen.php +++ b/src/Battle/UI/BattleScreen.php @@ -122,6 +122,23 @@ class BattleScreen implements CanRender, CanUpdate * @var float The time to hide the message. */ protected float $alertHideTime = 0; + private ?float $pausedAt = null; + + public function pauseTiming(): void + { + $this->pausedAt ??= Time::getTime(); + $this->fieldWindow->pauseTiming(); + } + + /** Shift only this owner's live deadline; disposal just drops the pause marker. */ + public function resumeTiming(bool $discard = false): void + { + if ($this->pausedAt !== null && !$discard && $this->isAlerting) { + $this->alertHideTime += max(0, Time::getTime() - $this->pausedAt); + } + $this->pausedAt = null; + if (isset($this->fieldWindow)) { $this->fieldWindow->resumeTiming($discard); } + } /** * @var bool Whether the info panel is visible. */ @@ -399,18 +416,18 @@ public function refreshLayout(): void $this->screenDimensions = $this->resolveScreenDimensions(); $this->fieldWindow->setPosition($this->getWindowPosition(0, 0)); $this->messageWindow->setPosition($this->getWindowPosition(2, 1)); - $this->commandWindow->setPosition($this->getWindowPosition(0, $this->fieldWindow->height)); - $this->commandContextWindow->setPosition($this->getWindowPosition($this->commandWindow->width, $this->fieldWindow->height)); + $this->commandWindow->setPosition($this->getWindowPosition(0, $this->fieldWindow->getHeight())); + $this->commandContextWindow->setPosition($this->getWindowPosition($this->commandWindow->getWidth(), $this->fieldWindow->getHeight())); $this->characterNameWindow->setPosition( $this->getWindowPosition( - $this->commandWindow->width + $this->commandContextWindow->width, - $this->fieldWindow->height + $this->commandWindow->getWidth() + $this->commandContextWindow->getWidth(), + $this->fieldWindow->getHeight() ) ); $this->characterStatusWindow->setPosition( $this->getWindowPosition( - $this->commandWindow->width + $this->commandContextWindow->width + $this->characterNameWindow->width, - $this->fieldWindow->height + $this->commandWindow->getWidth() + $this->commandContextWindow->getWidth() + $this->characterNameWindow->getWidth(), + $this->fieldWindow->getHeight() ) ); } diff --git a/src/Core/Game.php b/src/Core/Game.php index 942dc14b..6c3b5b08 100644 --- a/src/Core/Game.php +++ b/src/Core/Game.php @@ -29,6 +29,7 @@ use Ichiloto\Engine\Events\Interfaces\StaticObserverInterface; use Ichiloto\Engine\Events\Interfaces\SubjectInterface; use Ichiloto\Engine\Entities\Elements\ElementRegistry; +use Ichiloto\Engine\Entities\EquipmentOptimization\EquipmentOptimizationPolicyRegistry; use Ichiloto\Engine\Exceptions\NotFoundException; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\TerminalCapabilities; @@ -395,6 +396,23 @@ private function cleanupTerminal(): void } $this->terminalCleanedUp = true; + $this->isRunning = false; + Timers::setFrameTick(null); + // Confirmation input must not become a field action while this frame unwinds. + try { + InputManager::resetState(); + } catch (Throwable $error) { + $this->logCrash($error); + } + // Release scene-owned work while its audio and rendering services + // still exist. A broken scene must not prevent platform cleanup. + try { + if (isset($this->sceneManager)) { + $this->sceneManager->stop(); + } + } catch (Throwable $error) { + $this->logCrash($error); + } $this->shutdownAudio(); try { $this->rendererRuntime?->shutdown(); @@ -477,6 +495,7 @@ private function initializeObservers(): void */ private function initializeConfigStore(): void { + EquipmentOptimizationPolicyRegistry::configureFromProject(); ConfigStore::put(PlaySettings::class, new PlaySettings($this->options)); ConfigStore::put(AppConfig::class, new AppConfig()); ConfigStore::put(ProjectConfig::class, new ProjectConfig()); @@ -724,7 +743,13 @@ public function run(): void while ($this->isRunning) { LatencyTrace::beginIteration(); $this->rendererRuntime?->pump(); + if ($this->terminalCleanedUp) { + break; + } $this->handleInput(); + if ($this->terminalCleanedUp) { + break; + } $this->update(); // Quitting happens inside update(), and by then the terminal @@ -993,10 +1018,19 @@ private function handleGameEvents(): void */ public function quit(): void { + if ($this->terminalCleanedUp) { + return; + } $this->notify($this, new GameEvent(GameEventType::QUIT)); $this->stop(); } + /** True once shutdown begins; startup modals are live before the main loop runs. */ + public function hasStopped(): bool + { + return $this->terminalCleanedUp; + } + /** * @inheritDoc */ @@ -1016,6 +1050,9 @@ public function addObserver(ObserverInterface|string $observer): void */ protected function handleInput(): void { + if ($this->terminalCleanedUp) { + return; + } InputManager::handleInput(); } @@ -1026,14 +1063,28 @@ protected function handleInput(): void */ protected function update(): void { - $started = LatencyTrace::now(); + if ($this->terminalCleanedUp) { + return; + } + + $started = LatencyTrace::getTimeNow(); LatencyTrace::record('game.update.begin', $this->latencySceneState()); $this->frameCount++; $this->syncScreenSize(); $this->sceneManager->update(); + if ($this->terminalCleanedUp) { + return; + } + $this->notificationManager->update(); + if ($this->terminalCleanedUp) { + return; + } $this->audioManager->update(); Timers::update(); + if ($this->terminalCleanedUp) { + return; + } $this->notify($this, new GameEvent(GameEventType::UPDATE)); LatencyTrace::end('game.update.end', $started, $this->latencySceneState()); @@ -1070,18 +1121,34 @@ public function tickWhileBlocked(): void private function updateBlockedFrame(): void { + if ($this->terminalCleanedUp) { + return; + } + LatencyTrace::record('game.blocked.begin'); $this->rendererRuntime?->pump(); + if ($this->terminalCleanedUp) { + return; + } $this->notify($this, new GameEvent(GameEventType::UPDATE)); + if ($this->terminalCleanedUp) { + return; + } Timers::update(); + if ($this->terminalCleanedUp) { + return; + } $this->notificationManager->update(); + if ($this->terminalCleanedUp) { + return; + } $this->audioManager->update(); $this->notificationManager->render(); } private function presentBlockedFrame(): void { - if (Console::isComposing()) { return; } + if ($this->terminalCleanedUp || Console::isComposing()) { return; } // The modal owns its logical layout; only its physical margins may move. $this->syncScreenSize(resizeLogicalViewport: false); if ($this->rendererRuntime !== null) { @@ -1168,7 +1235,7 @@ private function applyTerminalScreenSize(array $physical, bool $resizeLogicalVie */ protected function render(): void { - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); LatencyTrace::record('game.render.begin'); // Modal dismissals are committed only at the frame boundary. That // lets dialogue pages and choices replace one another within a single diff --git a/src/Core/Menu/Commands/ShowCreditsCommand.php b/src/Core/Menu/Commands/ShowCreditsCommand.php index 04f1d8bf..9e17cf45 100644 --- a/src/Core/Menu/Commands/ShowCreditsCommand.php +++ b/src/Core/Menu/Commands/ShowCreditsCommand.php @@ -6,6 +6,8 @@ use Ichiloto\Engine\Core\Interfaces\ExecutionContextInterface; use Ichiloto\Engine\Core\Menu\Interfaces\MenuInterface; use Ichiloto\Engine\Core\Menu\MenuItem; +use Ichiloto\Engine\Scenes\Title\TitleScene; +use Ichiloto\Engine\UI\Presentation\CreditsContent; use Ichiloto\Engine\Util\Config\ProjectConfig; use Ichiloto\Engine\Util\Debug; use Throwable; @@ -22,7 +24,7 @@ * ]; * ``` * - * Each section is shown as a page; the project name and version are used + * Credits roll graphically or use centered alert pages; the project name is used * when no file is authored, so the entry is never empty. * * @package Ichiloto\Engine\Core\Menu\Commands @@ -58,16 +60,10 @@ public static function projectHasCredits(): bool */ public function execute(?ExecutionContextInterface $context = null): int { - foreach ($this->loadSections() as $section) { - $lines = array_values(array_filter((array) ($section['lines'] ?? []), 'is_string')); - - if (empty($lines)) { - continue; - } - - show_text(implode("\n", $lines), strval($section['title'] ?? ''), charactersPerSecond: dialogue_speed()); + if (!$context instanceof MenuCommandExecutionContext || !$context->scene instanceof TitleScene) { + return self::FAILURE; } - + $context->scene->openCredits($this->loadSections()); return self::SUCCESS; } @@ -95,7 +91,8 @@ protected function loadSections(): array $sections = require $filename; if (is_array($sections) && ! empty($sections)) { - return array_values(array_filter($sections, 'is_array')); + $content = new CreditsContent($sections); + if ($content->sections !== []) { return $content->sections; } } } catch (Throwable $exception) { Debug::warn(sprintf('Could not read credits: %s', $exception->getMessage())); diff --git a/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php b/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php index 70ac029e..c9226017 100644 --- a/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php +++ b/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php @@ -59,6 +59,23 @@ class EquipmentSelectionMode extends EquipmentMenuMode implements CanRender */ protected int $totalEquipment = 0; + /** Read the owner's cached availability and identity decision without recomputing inventory. + * @return list + */ + public function getPresentationCandidates(): array + { + return array_values(array_map(fn(Equipment $equipment) => [ + 'equipment' => $equipment, + 'available' => $this->getAvailableQuantity($equipment), + 'current' => $this->equipmentMatches($equipment, $this->equipmentSlot?->equipment), + ], $this->compatibleEquipment)); + } + + public function getPresentationIndex(): int + { + return $this->activeIndex; + } + /** * @inheritDoc * @throws Exception If an error occurs while alerting the player. diff --git a/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php b/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php index 53e4033f..bf64621c 100644 --- a/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php +++ b/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php @@ -6,6 +6,7 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\Stats; +use Ichiloto\Engine\UI\Presentation\MenuDirection; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; @@ -30,6 +31,11 @@ class CharacterDetailPanel extends Window */ protected ?Stats $previewStats = null; + public function getPresentationPreview(): ?Stats + { + return $this->previewStats === null ? null : clone $this->previewStats; + } + /** * Create a new instance of the character detail panel. * @@ -130,9 +136,10 @@ protected function buildCharacterContent(): array private function formatStatLine(string $label, ?int $currentStat, ?int $previewStat): string { return sprintf( - " %-10s %6s -> %6s %1s", + " %-10s %6s %s %6s %1s", $label, $this->formatStatValue($currentStat), + MenuDirection::RIGHT->getGlyph(), $this->formatStatValue($previewStat ?? $currentStat), $this->getStatIndicator($currentStat, $previewStat) ); @@ -164,8 +171,8 @@ private function getStatIndicator(?int $currentStat, ?int $previewStat): string { if (isset($currentStat) && isset($previewStat)) { return match(true) { - $currentStat < $previewStat => '↑', - $currentStat > $previewStat => '↓', + $currentStat < $previewStat => MenuDirection::UP->getGlyph(), + $currentStat > $previewStat => MenuDirection::DOWN->getGlyph(), default => '', }; } diff --git a/src/Core/Menu/EquipmentMenu/Windows/EquipmentInfoPanel.php b/src/Core/Menu/EquipmentMenu/Windows/EquipmentInfoPanel.php index 461de8ac..6d168908 100644 --- a/src/Core/Menu/EquipmentMenu/Windows/EquipmentInfoPanel.php +++ b/src/Core/Menu/EquipmentMenu/Windows/EquipmentInfoPanel.php @@ -14,6 +14,12 @@ */ class EquipmentInfoPanel extends Window { + private string $presentationText = ''; + + public function getPresentationText(): string + { + return $this->presentationText; + } /** * Creates a new instance of the equipment info panel. * @@ -44,6 +50,7 @@ public function __construct( */ public function setText(string $text): void { + $this->presentationText = $text; $lines = explode("\n", $text); $lineCount = count($lines); @@ -58,4 +65,4 @@ public function setText(string $text): void $this->setContent($lines); $this->render(); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/ItemMenu.php b/src/Core/Menu/ItemMenu/ItemMenu.php index 5fddd943..d4b45808 100644 --- a/src/Core/Menu/ItemMenu/ItemMenu.php +++ b/src/Core/Menu/ItemMenu/ItemMenu.php @@ -4,6 +4,7 @@ use Ichiloto\Engine\Core\Menu\Menu; use Ichiloto\Engine\Entities\Inventory\Inventory; +use Ichiloto\Engine\Entities\Inventory\InventoryItem; use Ichiloto\Engine\Scenes\Game\GameScene; use InvalidArgumentException; @@ -14,6 +15,15 @@ */ class ItemMenu extends Menu { + /** The Use/Sort/Discard list never admits equipment or quest-critical key items. + * @return list + */ + public function getRegularItems(): array + { + return array_values(array_filter($this->inventory->items->toArray(), + static fn(InventoryItem $item) => !$item->isKeyItem)); + } + /** * @var Inventory The inventory of the party. */ @@ -67,4 +77,4 @@ public function update(): void { // TODO: Implement update() method. } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Modes/DiscardItemMode.php b/src/Core/Menu/ItemMenu/Modes/DiscardItemMode.php index c5bfddf4..36fc08f8 100644 --- a/src/Core/Menu/ItemMenu/Modes/DiscardItemMode.php +++ b/src/Core/Menu/ItemMenu/Modes/DiscardItemMode.php @@ -4,9 +4,11 @@ use Exception; use Ichiloto\Engine\Audio\Enumerations\SystemSound; -use Ichiloto\Engine\Core\Menu\ItemMenu\Modes\ItemMenuMode; -use Ichiloto\Engine\IO\Enumerations\AxisName; +use Ichiloto\Engine\Entities\Inventory\InventoryItem; use Ichiloto\Engine\IO\Input; +use Ichiloto\Engine\IO\InputManager; +use Ichiloto\Engine\Quests\QuestManager; +use Ichiloto\Engine\UI\Modal\ModalManager; class DiscardItemMode extends ItemMenuMode { @@ -20,35 +22,52 @@ public function update(): void if (Input::isButtonDown("back")) { play_sound(SystemSound::CANCEL); $this->state->setMode(new SelectIemMenuCommandMode($this->state)); + return; } - $v = Input::getAxis(AxisName::VERTICAL); + $this->navigateItems(); - if (abs($v) > 0) { - play_sound(SystemSound::CURSOR); + if (Input::isButtonDown("confirm")) { + if (($item = $this->state->selectionPanel->activeItem) !== null) { $this->confirmDiscard($item); } + return; + } + } - if ($v > 0) { - $this->state->selectionPanel->selectNext(); - } else { - $this->state->selectionPanel->selectPrevious(); + private function confirmDiscard(InventoryItem $item): void + { + try { + if (!$this->hasCurrentStack($item, 1)) { return; } + $quantity = $item->quantity > 1 + ? ModalManager::getInstance($this->state->getGameScene()->getGame())->selectQuantity( + 'Discard ' . $item->name, $item->quantity, 'Discard item') + : 1; + if ($quantity === null || !$this->hasCurrentStack($item, $quantity)) { return; } + if (!confirm(sprintf('Discard %s x %d?', $item->name, $quantity)) + || !$this->hasCurrentStack($item, $quantity)) { return; } + if (!$this->inventory->consumeQuantityById($item->id, $quantity)) { return; } + QuestManager::current()?->syncCollectObjectives(); + $this->state->selectionPanel->setItems($this->state->itemMenu->getRegularItems()); + if ($this->state->selectionPanel->totalItems === 0) { + alert('You have no items left.'); + $this->state->setMode(new SelectIemMenuCommandMode($this->state)); + } + } finally { + InputManager::resetState(); + if ($this->state->mode === $this && !$this->state->getGameScene()->getGame()->hasStopped()) { + $this->state->selectionPanel->setItems($this->state->itemMenu->getRegularItems()); + $this->state->infoPanel->setText($this->state->selectionPanel->activeItem?->description ?? ''); } } + } - if (Input::isButtonDown("confirm")) { - if (confirm("Are you sure you want to discard this item?")) { - $this->state->getGameScene()->party->inventory->removeItems($this->state->selectionPanel->activeItem); - $this->state->selectionPanel->setItems($this->state->getGameScene()->party->inventory->all->toArray()); - - if ($this->state->selectionPanel->activeIndex > $this->state->selectionPanel->totalItems - 1) { - $this->state->selectionPanel->selectPrevious(); - } - - if ($this->state->selectionPanel->totalItems === 0) { - alert("You have no items left."); - $this->state->setMode(new SelectIemMenuCommandMode($this->state)); - } - } + private function hasCurrentStack(InventoryItem $item, int $quantity): bool + { + if ($quantity < 1 || $item->quantity < $quantity + || !in_array($item, $this->state->itemMenu->getRegularItems(), true)) { + alert('The selected item quantity is no longer available.'); + return false; } + return true; } /** @@ -66,4 +85,4 @@ public function exit(): void { $this->state->selectionPanel->blur(); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Modes/ItemMenuMode.php b/src/Core/Menu/ItemMenu/Modes/ItemMenuMode.php index 74bb001b..e22a5964 100644 --- a/src/Core/Menu/ItemMenu/Modes/ItemMenuMode.php +++ b/src/Core/Menu/ItemMenu/Modes/ItemMenuMode.php @@ -6,6 +6,9 @@ use Ichiloto\Engine\Entities\Inventory\Inventory; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Scenes\Game\States\ItemMenuState; +use Ichiloto\Engine\Audio\Enumerations\SystemSound; +use Ichiloto\Engine\IO\Enumerations\AxisName; +use Ichiloto\Engine\IO\Input; /** * Class ItemMenuMode. Represents an item menu mode. @@ -38,4 +41,18 @@ abstract class ItemMenuMode implements MainMenuModeInterface public function __construct(protected ItemMenuState $state) { } -} \ No newline at end of file + + protected function navigateItems(): void + { + $horizontal = Input::getAxis(AxisName::HORIZONTAL); + $vertical = Input::getAxis(AxisName::VERTICAL); + if ($horizontal != 0) { + play_sound(SystemSound::CURSOR); + $this->state->selectionPanel->changePage($horizontal <=> 0); + } elseif ($vertical != 0) { + play_sound(SystemSound::CURSOR); + if ($vertical > 0) { $this->state->selectionPanel->selectNext(); } + else { $this->state->selectionPanel->selectPrevious(); } + } + } +} diff --git a/src/Core/Menu/ItemMenu/Modes/SelectIemMenuCommandMode.php b/src/Core/Menu/ItemMenu/Modes/SelectIemMenuCommandMode.php index ff756e7d..f233dc59 100644 --- a/src/Core/Menu/ItemMenu/Modes/SelectIemMenuCommandMode.php +++ b/src/Core/Menu/ItemMenu/Modes/SelectIemMenuCommandMode.php @@ -58,7 +58,8 @@ public function update(): void } if (Input::isButtonDown("confirm")) { - if ($this->inventory->isNotEmpty) { + if ($this->state->itemMenu->activeIndex === self::VIEW_KEY_ITEMS_INDEX + || $this->state->selectionPanel->totalItems > 0) { play_sound(SystemSound::CONFIRM); $this->state->itemMenu->getActiveItem()?->execute($this->state->itemMenuContext); $mode = match ($this->state->itemMenu->activeIndex) { @@ -132,7 +133,7 @@ protected function updateSelectionPanelItems(): void $scene = $this->state->context->getScene(); if ($scene instanceof GameScene) { - $items = $scene->party->inventory->items->toArray(); + $items = $this->state->itemMenu->getRegularItems(); if ($this->state->itemMenu->activeIndex === self::VIEW_KEY_ITEMS_INDEX) { $items = $scene->party->inventory->keyItems->toArray(); } @@ -150,4 +151,4 @@ protected function updateInfoPanel(): void { $this->state->infoPanel->setText($this->state->itemMenu->getActiveItem()->getDescription()); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Modes/SelectItemTargetMode.php b/src/Core/Menu/ItemMenu/Modes/SelectItemTargetMode.php index be722b80..e50d2854 100644 --- a/src/Core/Menu/ItemMenu/Modes/SelectItemTargetMode.php +++ b/src/Core/Menu/ItemMenu/Modes/SelectItemTargetMode.php @@ -4,11 +4,13 @@ use Exception; use Ichiloto\Engine\Audio\Enumerations\SystemSound; -use Ichiloto\Engine\Core\Menu\QuantitySelector; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\Inventory\InventoryItem; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; +use Ichiloto\Engine\IO\InputManager; +use Ichiloto\Engine\Quests\QuestManager; +use Ichiloto\Engine\UI\Modal\ModalManager; /** * Class SelectItemTargetMode. Represents the target selection mode of the item menu. @@ -22,22 +24,12 @@ class SelectItemTargetMode extends ItemMenuMode */ public ?ItemMenuMode $previousMode = null; - /** Active Shop-style quantity selection, when the chosen stack has multiple copies. */ - protected ?QuantitySelector $quantitySelector = null; - protected ?InventoryItem $pendingItem = null; - protected ?Character $pendingTarget = null; - /** * @inheritDoc * @throws Exception If an error occurs while alerting the player. */ public function update(): void { - if ($this->quantitySelector instanceof QuantitySelector) { - $this->updateQuantitySelection(); - return; - } - if (Input::isButtonDown("back")) { play_sound(SystemSound::CANCEL); $this->goBackToThePreviousMode(); @@ -47,11 +39,7 @@ public function update(): void if (Input::isButtonDown("confirm")) { if (($item = $this->state->selectionPanel->activeItem) && ($target = $this->state->targetSelectionPanel->activeCharacter)) { play_sound(SystemSound::CONFIRM); - if ($item->quantity > 1) { - $this->beginQuantitySelection($item, $target); - } else { - $this->useItem($item, $target, 1); - } + $this->confirmUse($item, $target); } else { play_sound(SystemSound::BUZZER); } @@ -87,7 +75,6 @@ public function enter(): void */ public function exit(): void { - $this->cancelQuantitySelection(); $this->state->targetSelectionPanel->setTargets([]); $this->state->targetSelectionPanel->blur(); $this->state->statusPanel->setTarget(null); @@ -132,91 +119,38 @@ protected function goBackToThePreviousMode(): void $this->state->setMode($previousMode); } - /** Starts the same bounded fine/coarse quantity workflow used by shops. */ - protected function beginQuantitySelection(InventoryItem $item, Character $target): void + /** Blocking modals own their input; only this mode applies the confirmed amount. */ + protected function confirmUse(InventoryItem $item, Character $target): void { - $this->pendingItem = $item; - $this->pendingTarget = $target; - $this->quantitySelector = new QuantitySelector(maximum: $item->quantity); - $this->updateQuantitySelectionContent(); - } - - /** Handles Shop-style quantity adjustment, confirmation, and cancellation. */ - protected function updateQuantitySelection(): void - { - if ( - Input::isButtonDown('cancel') - || Input::isButtonDown('back') - ) { - play_sound(SystemSound::CANCEL); - $this->cancelQuantitySelection(); - return; - } - - $vertical = Input::getAxis(AxisName::VERTICAL); - $horizontal = Input::getAxis(AxisName::HORIZONTAL); - - if (abs($vertical) > 0 || abs($horizontal) > 0) { - if ($this->quantitySelector?->adjustForAxes($vertical, $horizontal)) { - play_sound(SystemSound::CURSOR); - $this->updateQuantitySelectionContent(); + try { + if (!$this->canUseCurrentStack($item, $target, 1)) { return; } + $quantity = $item->quantity > 1 + ? ModalManager::getInstance($this->state->getGameScene()->getGame())->selectQuantity( + sprintf('Use %s on %s', $item->name, $target->name), $item->quantity, 'Use item') + : 1; + if ($quantity === null || !$this->canUseCurrentStack($item, $target, $quantity)) { return; } + if (confirm(sprintf('Use %s x %d on %s?', $item->name, $quantity, $target->name)) + && $this->canUseCurrentStack($item, $target, $quantity)) { + $this->useItem($item, $target, $quantity); + } + } finally { + InputManager::resetState(); + if ($this->state->mode === $this && !$this->state->getGameScene()->getGame()->hasStopped()) { + $this->state->selectionPanel->setItems($this->state->itemMenu->getRegularItems()); + $this->state->infoPanel->setText($this->state->selectionPanel->activeItem?->description ?? ''); } - return; - } - - if (! Input::isButtonDown('confirm')) { - return; - } - - $item = $this->pendingItem; - $target = $this->pendingTarget; - $quantity = $this->quantitySelector?->quantity ?? 1; - $this->clearQuantitySelection(); - - if ($item instanceof InventoryItem && $target instanceof Character) { - play_sound(SystemSound::CONFIRM); - $this->useItem($item, $target, $quantity); - } - } - - /** Renders the selected quantity in the existing item-menu information panel. */ - protected function updateQuantitySelectionContent(): void - { - if ( - ! $this->quantitySelector instanceof QuantitySelector - || ! $this->pendingItem instanceof InventoryItem - || ! $this->pendingTarget instanceof Character - ) { - return; } - - $this->state->infoPanel->setText(sprintf( - "Use %s x %02d on %s?\nUp/Down: +1/-1 Right/Left: +10/-10 Enter: Confirm C/Esc: Cancel", - $this->pendingItem->name, - $this->quantitySelector->quantity, - $this->pendingTarget->name, - )); } - /** Cancels quantity selection without consuming the item. */ - protected function cancelQuantitySelection(): void + private function canUseCurrentStack(InventoryItem $item, Character $target, int $quantity): bool { - if (! $this->quantitySelector instanceof QuantitySelector) { - return; + if ($quantity < 1 || $item->quantity < $quantity + || !in_array($item, $this->state->itemMenu->getRegularItems(), true) + || !in_array($target, $this->party->members->toArray(), true)) { + alert('The selected item quantity or target is no longer available.'); + return false; } - - $this->clearQuantitySelection(); - $this->state->infoPanel->setText( - $this->state->selectionPanel->activeItem?->description ?? '' - ); - } - - /** Clears transient quantity state. */ - protected function clearQuantitySelection(): void - { - $this->quantitySelector = null; - $this->pendingItem = null; - $this->pendingTarget = null; + return true; } /** Applies the selected quantity and refreshes the existing item panels. */ @@ -225,14 +159,11 @@ protected function useItem(InventoryItem $item, Character $target, int $quantity $target->use($item, $quantity); if ($item->quantity === 0) { - $this->inventory->removeItems($item); - $this->state->selectionPanel->setItems($this->inventory->items->toArray()); + $this->inventory->all->remove($item); } + QuestManager::current()?->syncCollectObjectives(); + if ($this->state->getGameScene()->getGame()->hasStopped()) { return; } $this->state->statusPanel->updateContent(); - $this->state->selectionPanel->updateContent(); - $this->state->infoPanel->setText( - $this->state->selectionPanel->activeItem?->description ?? '' - ); } } diff --git a/src/Core/Menu/ItemMenu/Modes/SortItemsMode.php b/src/Core/Menu/ItemMenu/Modes/SortItemsMode.php index 0700b802..efeb7d2e 100644 --- a/src/Core/Menu/ItemMenu/Modes/SortItemsMode.php +++ b/src/Core/Menu/ItemMenu/Modes/SortItemsMode.php @@ -23,7 +23,7 @@ public function update(): void } $this->state->getGameScene()->party->inventory->sort(); - $this->state->selectionPanel->setItems($this->state->getGameScene()->party->inventory->items->toArray()); + $this->state->selectionPanel->setItems($this->state->itemMenu->getRegularItems()); } /** @@ -41,4 +41,4 @@ public function exit(): void { // Nothing to do here. } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Modes/UseItemMode.php b/src/Core/Menu/ItemMenu/Modes/UseItemMode.php index d9838da9..820b1dfa 100644 --- a/src/Core/Menu/ItemMenu/Modes/UseItemMode.php +++ b/src/Core/Menu/ItemMenu/Modes/UseItemMode.php @@ -3,10 +3,7 @@ namespace Ichiloto\Engine\Core\Menu\ItemMenu\Modes; use Ichiloto\Engine\Audio\Enumerations\SystemSound; -use Ichiloto\Engine\Core\Menu\MenuItem; -use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; -use Ichiloto\Engine\Util\Debug; /** * Class ItemMenuItemSelectionMode. Represents an item menu item selection mode. @@ -23,9 +20,11 @@ public function update(): void if (Input::isButtonDown("back")) { play_sound(SystemSound::CANCEL); $this->state->setMode(new SelectIemMenuCommandMode($this->state)); + return; } if (Input::isButtonDown("confirm")) { + if ($this->state->selectionPanel->activeItem === null) { return; } play_sound(SystemSound::CONFIRM); $this->state->setMode(new SelectItemTargetMode($this->state)); if ($mode = $this->state->mode) { @@ -33,19 +32,10 @@ public function update(): void $mode->previousMode = $this; } } + return; } - $v = Input::getAxis(AxisName::VERTICAL); - - if (abs($v) > 0) { - play_sound(SystemSound::CURSOR); - - if ($v > 0) { - $this->state->selectionPanel->selectNext(); - } else { - $this->state->selectionPanel->selectPrevious(); - } - } + $this->navigateItems(); } /** @@ -67,4 +57,4 @@ public function exit(): void { // Do nothing. } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Modes/ViewKeyItemsMode.php b/src/Core/Menu/ItemMenu/Modes/ViewKeyItemsMode.php index 282b5151..ad622ce1 100644 --- a/src/Core/Menu/ItemMenu/Modes/ViewKeyItemsMode.php +++ b/src/Core/Menu/ItemMenu/Modes/ViewKeyItemsMode.php @@ -4,7 +4,6 @@ use Ichiloto\Engine\Audio\Enumerations\SystemSound; use Ichiloto\Engine\Entities\Interfaces\InventoryItemInterface; -use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; /** @@ -29,19 +28,7 @@ public function update(): void return; } - $v = Input::getAxis(AxisName::VERTICAL); - - if (abs($v) > 0) { - play_sound(SystemSound::CURSOR); - - if ($v > 0) { - $this->state->selectionPanel->selectNext(); - } else { - $this->state->selectionPanel->selectPrevious(); - } - - $this->describeActiveItem(); - } + $this->navigateItems(); } /** @@ -69,8 +56,7 @@ public function enter(): void */ public function exit(): void { - // Restore the full inventory for the modes that expect it. - $this->state->selectionPanel->setItems($this->inventory->items->toArray()); + $this->state->selectionPanel->setItems($this->state->itemMenu->getRegularItems()); $this->state->selectionPanel->blur(); } diff --git a/src/Core/Menu/ItemMenu/Windows/InfoPanel.php b/src/Core/Menu/ItemMenu/Windows/InfoPanel.php index b6a6b53c..704e75cb 100644 --- a/src/Core/Menu/ItemMenu/Windows/InfoPanel.php +++ b/src/Core/Menu/ItemMenu/Windows/InfoPanel.php @@ -3,7 +3,6 @@ namespace Ichiloto\Engine\Core\Menu\ItemMenu\Windows; use Ichiloto\Engine\Core\Menu\Interfaces\MenuInterface; -use Ichiloto\Engine\Core\Menu\ItemMenu\ItemMenu; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; @@ -15,6 +14,8 @@ */ class InfoPanel extends Window { + protected(set) string $text = ''; + public function __construct( protected MenuInterface $menu, Rect $area, @@ -39,6 +40,7 @@ public function __construct( */ public function setText(string $text): void { + $this->text = $text; $lines = explode("\n", $text); $lineCount = count($lines); @@ -51,4 +53,4 @@ public function setText(string $text): void $this->setContent($lines); $this->render(); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php b/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php index 93918e6b..b31b5093 100644 --- a/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php +++ b/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php @@ -3,14 +3,12 @@ namespace Ichiloto\Engine\Core\Menu\ItemMenu\Windows; use Ichiloto\Engine\Core\Interfaces\CanFocus; -use Ichiloto\Engine\Core\Menu\Interfaces\MenuInterface; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Entities\Inventory\InventoryItem; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Scenes\Game\States\ItemMenuState; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; -use Ichiloto\Engine\Util\Debug; /** * The window that displays the commands that can be executed on an item. @@ -23,11 +21,8 @@ class ItemSelectionPanel extends Window implements CanFocus * The minimum number of pages. */ public const int MIN_PAGE_COUNT = 1; - /** - * The maximum number of pages. - */ + /** Legacy display bound, retained for compatibility; pages are no longer truncated. */ public const int MAX_PAGE_COUNT = 99; - /** * @var int The index of the active item. */ @@ -37,7 +32,7 @@ class ItemSelectionPanel extends Window implements CanFocus } set { - $this->activeIndex = $value; + $this->activeIndex = max(-1, min($value, $this->totalItems - 1)); $this->updateContent(); } } @@ -54,7 +49,7 @@ class ItemSelectionPanel extends Window implements CanFocus /** * @var InventoryItem[] The items to display. */ - protected array $items = []; + protected(set) array $items = []; /** * @var int The total number of items. */ @@ -64,7 +59,7 @@ class ItemSelectionPanel extends Window implements CanFocus */ public int $page { get { - return clamp(($this->activeIndex / $this->height) + 1, 1, $this->totalPages); + return intdiv(max(0, $this->activeIndex), $this->pageSize) + 1; } } /** @@ -72,10 +67,12 @@ class ItemSelectionPanel extends Window implements CanFocus */ public int $totalPages { get { - return clamp(ceil($this->totalItems / $this->height), self::MIN_PAGE_COUNT, self::MAX_PAGE_COUNT); + return max(self::MIN_PAGE_COUNT, (int)ceil($this->totalItems / $this->pageSize)); } } + public int $pageSize { get => max(1, $this->height - 2); } + /** * ItemMenuCommandsPanel constructor. * @@ -91,7 +88,7 @@ public function __construct( { parent::__construct( "Page 1/1", - '<,>: Change page', + 'Left/Right: Change page', $area->position, $area->size->width, $area->size->height, @@ -106,7 +103,7 @@ public function __construct( */ public function focus(): void { - $this->activeIndex = 0; + $this->activeIndex = max(0, $this->activeIndex); $this->updateInfoPanel(); } @@ -125,6 +122,7 @@ public function blur(): void */ public function selectPrevious(): void { + if ($this->totalItems === 0) { return; } $nextIndex = wrap($this->activeIndex - 1, 0, $this->totalItems - 1); $this->activeIndex = $nextIndex; $this->updateInfoPanel(); @@ -137,11 +135,21 @@ public function selectPrevious(): void */ public function selectNext(): void { + if ($this->totalItems === 0) { return; } $nextIndex = wrap($this->activeIndex + 1, 0, $this->totalItems - 1); $this->activeIndex = $nextIndex; $this->updateInfoPanel(); } + public function changePage(int $direction): void + { + if ($this->totalItems === 0 || $direction === 0) { return; } + $page = wrap($this->page - 1 + ($direction <=> 0), 0, $this->totalPages - 1); + $offset = max(0, $this->activeIndex) % $this->pageSize; + $this->activeIndex = min($this->totalItems - 1, $page * $this->pageSize + $offset); + $this->updateInfoPanel(); + } + /** * Sets the items to display in the menu. * @@ -150,10 +158,10 @@ public function selectNext(): void */ public function setItems(array $items): void { - $this->items = $items; + $this->items = array_values($items); $this->totalItems = count($items); - $this->title = sprintf("Page %02d/%02d", $this->page, $this->totalPages); - $this->updateContent(); + // Reapply the owner index through its clamp after filtering or stack depletion. + $this->activeIndex = $this->activeIndex; } /** @@ -163,13 +171,16 @@ public function setItems(array $items): void */ public function updateContent(): void { - $content = array_fill(0, $this->height - 2, ''); + $content = array_fill(0, $this->pageSize, ''); + $first = ($this->page - 1) * $this->pageSize; + $this->title = sprintf("Page %02d/%02d", $this->page, $this->totalPages); - foreach ($this->items as $index => $item) { + foreach (array_slice($this->items, $first, $this->pageSize) as $row => $item) { + $index = $first + $row; $prefix = $index === $this->activeIndex ? '>' : ' '; $itemName = TerminalText::padRight($item->name, 60); $quantity = TerminalText::padLeft((string)$item->quantity, 2); - $content[$index] = " {$prefix} {$itemName} {$quantity}"; + $content[$row] = " {$prefix} {$itemName} {$quantity}"; } $this->setContent($content); @@ -182,7 +193,7 @@ public function updateContent(): void protected function updateInfoPanel(): void { if ($this->activeItem) { - $this->state->infoPanel->setText($this->activeItem->description); + $this->state->infoPanel?->setText($this->activeItem->description); } } } diff --git a/src/Core/Menu/MagicMenu/Windows/MagicListPanel.php b/src/Core/Menu/MagicMenu/Windows/MagicListPanel.php index 78816dd8..0b0ba6b2 100644 --- a/src/Core/Menu/MagicMenu/Windows/MagicListPanel.php +++ b/src/Core/Menu/MagicMenu/Windows/MagicListPanel.php @@ -54,7 +54,7 @@ protected function updateContent(): void $visibleRows = max(1, $this->height - 2); $availableWidth = max(0, $this->width - 4); $content = array_fill(0, $visibleRows, ''); - $visibleEntries = array_slice($this->entries, $this->scrollOffset, $visibleRows, true); + $visibleEntries = array_slice($this->entries, $this->scrollOffset, $visibleRows); foreach ($visibleEntries as $row => $entry) { $actualIndex = $this->scrollOffset + $row; diff --git a/src/Core/Menu/MainMenu/CharacterSelectionMenu.php b/src/Core/Menu/MainMenu/CharacterSelectionMenu.php index daae7ba0..6a2d8d68 100644 --- a/src/Core/Menu/MainMenu/CharacterSelectionMenu.php +++ b/src/Core/Menu/MainMenu/CharacterSelectionMenu.php @@ -72,6 +72,16 @@ public function getActivePanelIndex(): int return $this->activePanelIndex; } + public function getMarkedPanelIndex(): ?int + { + return $this->markedPanelIndex; + } + + public function getHelpText(): string + { + return $this->helpText; + } + /** * @inheritDoc */ diff --git a/src/Core/Menu/MainMenu/ConfigMenu.php b/src/Core/Menu/MainMenu/ConfigMenu.php new file mode 100644 index 00000000..18f44fff --- /dev/null +++ b/src/Core/Menu/MainMenu/ConfigMenu.php @@ -0,0 +1,116 @@ + $this->infoText ??= new MenuInfoText(); } + + public function __construct( + private MainMenuSettingsManager $settingsManager, + public readonly ConfigSelectionWindow $selection, + public readonly ConfigDetailPanel $detail, + private Closure $onBack, + ) {} + + public function getStatusMessage(): ?string { return $this->statusMessage; } + public function hasStatusError(): bool { return $this->statusError; } + public function getChoiceIndex(GameSetting $setting): int { return $this->settingsManager->getCurrentChoiceIndex($setting); } + + public function enter(): void + { + $this->statusMessage = null; + $this->statusError = false; + $this->menuInfoText->reset(); + $this->selection->setSettings($this->settingsManager->getSettings()); + $this->selection->focus(); + $this->render(); + } + + public function render(): void + { + $this->selection->render(); + $setting = $this->selection->getActiveSetting(); + $this->syncInfoSelection($setting); + if ($setting instanceof GameSetting) { + $page = $this->menuInfoText->getPage($setting->description, $this->statusMessage, + max(1, $this->detail->getContentWidth() - 2)); + $this->detail->setContent(array_pad($page->lines, 2, '')); + $this->detail->setHelp($page->total > 2 ? $page->range() : ''); + $this->detail->render(); + } else { + $this->detail->setContent(['', '']); + $this->detail->setHelp(''); + $this->detail->render(); + } + } + + public function update(): void + { + $setting = $this->selection->getActiveSetting(); + $this->syncInfoSelection($setting); + if (Input::isButtonDown('info')) { + $info = $this->menuInfoText; + $info->getPage($setting?->description ?? '', $this->statusMessage, + $info->lastPage?->columns ?? max(1, $this->detail->getContentWidth() - 2)); + $info->advance(); + $this->render(); + return; + } + if (Input::isButtonDown('cancel')) { + play_sound(SystemSound::CANCEL); + ($this->onBack)(); + return; + } + $vertical = Input::getAxis(AxisName::VERTICAL); + if ($vertical !== 0.0) { + play_sound(SystemSound::CURSOR); + $this->statusMessage = null; + $this->statusError = false; + if ($vertical > 0) { $this->selection->selectNext(); } + else { $this->selection->selectPrevious(); } + $this->render(); + return; + } + $horizontal = Input::getAxis(AxisName::HORIZONTAL); + if ($horizontal !== 0.0 || Input::isButtonDown('confirm')) { + $setting = $this->selection->getActiveSetting(); + if (!$setting instanceof GameSetting) { return; } + play_sound(SystemSound::CURSOR); + try { + $label = $this->settingsManager->cycle($setting, $horizontal < 0 ? -1 : 1); + $this->statusMessage = sprintf('%s set to %s.', $setting->label, $label); + $this->statusError = false; + } catch (Throwable $exception) { + $this->statusMessage = sprintf('Could not save settings: %s', $exception->getMessage()); + $this->statusError = true; + } + $this->selection->updateContent(); + $this->render(); + } + } + + private function syncInfoSelection(?GameSetting $setting): void + { + if ($setting !== $this->infoSelection) { $this->menuInfoText->reset(); } + $this->infoSelection = $setting; + } +} diff --git a/src/Core/Menu/MainMenu/Modes/MainMenuConfigMode.php b/src/Core/Menu/MainMenu/Modes/MainMenuConfigMode.php index 8f714d0a..c21da3e2 100644 --- a/src/Core/Menu/MainMenu/Modes/MainMenuConfigMode.php +++ b/src/Core/Menu/MainMenu/Modes/MainMenuConfigMode.php @@ -2,11 +2,7 @@ namespace Ichiloto\Engine\Core\Menu\MainMenu\Modes; -use Ichiloto\Engine\Audio\Enumerations\SystemSound; -use Ichiloto\Engine\Settings\GameSetting; -use Ichiloto\Engine\IO\Enumerations\AxisName; -use Ichiloto\Engine\IO\Input; -use Throwable; +use Ichiloto\Engine\Core\Menu\MainMenu\ConfigMenu; /** * Handles the in-game config menu shown from the main menu. @@ -15,30 +11,24 @@ */ class MainMenuConfigMode extends MainMenuMode { - /** - * @var GameSetting[] The configurable settings shown in this mode. - */ - protected array $settings = []; - /** - * @var string|null The latest status message to display in the detail panel. - */ - protected ?string $statusMessage = null; + private ?ConfigMenu $config = null; + + public function getConfigMenu(): ?ConfigMenu { return $this->config; } /** * @inheritDoc */ public function enter(): void { - $this->settings = $this->mainMenuState->settingsManager->getSettings(); - $this->statusMessage = null; $this->mainMenuState->infoPanel?->setText('Configure game settings.'); $this->mainMenuState->eraseSummaryPanels(); $this->mainMenuState->mainMenu?->erase(); $this->mainMenuState->characterSelectionMenu?->erase(); $this->mainMenuState->renderConfigPanels(); - $this->mainMenuState->configSelectionWindow?->setSettings($this->settings); - $this->mainMenuState->configSelectionWindow?->focus(); - $this->renderActiveSetting(); + $state = $this->mainMenuState; + $this->config = new ConfigMenu($state->settingsManager, $state->configSelectionWindow, + $state->configDetailPanel, fn() => $state->setMode(new MainMenuCommandSelectionMode($state))); + $this->config->enter(); } /** @@ -46,6 +36,7 @@ public function enter(): void */ public function exit(): void { + $this->config = null; $this->mainMenuState->eraseConfigPanels(); } @@ -54,103 +45,6 @@ public function exit(): void */ public function update(): void { - $this->handleNavigation(); - $this->handleActions(); - } - - /** - * Handles vertical navigation through the settings list. - * - * @return void - */ - protected function handleNavigation(): void - { - $v = Input::getAxis(AxisName::VERTICAL); - - if ($v > 0) { - play_sound(SystemSound::CURSOR); - $this->statusMessage = null; - $this->mainMenuState->configSelectionWindow?->selectNext(); - $this->renderActiveSetting(); - return; - } - - if ($v < 0) { - play_sound(SystemSound::CURSOR); - $this->statusMessage = null; - $this->mainMenuState->configSelectionWindow?->selectPrevious(); - $this->renderActiveSetting(); - } - } - - /** - * Handles adjustment and cancel actions for the config menu. - * - * @return void - */ - protected function handleActions(): void - { - if (Input::isButtonDown('cancel')) { - play_sound(SystemSound::CANCEL); - $this->mainMenuState->setMode(new MainMenuCommandSelectionMode($this->mainMenuState)); - return; - } - - $h = Input::getAxis(AxisName::HORIZONTAL); - - if ($h > 0 || Input::isButtonDown('confirm')) { - play_sound(SystemSound::CURSOR); - $this->changeSetting(1); - return; - } - - if ($h < 0) { - play_sound(SystemSound::CURSOR); - $this->changeSetting(-1); - } - } - - /** - * Cycles the active setting in the requested direction. - * - * @param int $step The direction to move. Use `1` for next and `-1` for previous. - * @return void - */ - protected function changeSetting(int $step): void - { - $setting = $this->mainMenuState->configSelectionWindow?->getActiveSetting(); - - if (! $setting instanceof GameSetting) { - return; - } - - try { - $valueLabel = $this->mainMenuState->settingsManager->cycle($setting, $step); - $this->statusMessage = sprintf('%s set to %s.', $setting->label, $valueLabel); - } catch (Throwable $exception) { - $this->statusMessage = sprintf('Could not save settings: %s', $exception->getMessage()); - } - - $this->mainMenuState->configSelectionWindow?->updateContent(); - $this->renderActiveSetting(); - } - - /** - * Renders the description panel for the currently selected setting. - * - * @return void - */ - protected function renderActiveSetting(): void - { - $setting = $this->mainMenuState->configSelectionWindow?->getActiveSetting(); - - if (! $setting instanceof GameSetting) { - return; - } - - $this->mainMenuState->configDetailPanel?->showSetting( - $setting, - $this->statusMessage - ); + $this->config?->update(); } } diff --git a/src/Core/Menu/MainMenu/Windows/ConfigSelectionWindow.php b/src/Core/Menu/MainMenu/Windows/ConfigSelectionWindow.php index 1fbce32e..1f2ee58d 100644 --- a/src/Core/Menu/MainMenu/Windows/ConfigSelectionWindow.php +++ b/src/Core/Menu/MainMenu/Windows/ConfigSelectionWindow.php @@ -79,6 +79,10 @@ public function getActiveSetting(): ?GameSetting return $this->settings[$this->activeIndex] ?? null; } + /** @return list Live source order, including project-filtered catalogs. */ + public function getSettings(): array { return $this->settings; } + public function getActiveIndex(): int { return $this->activeIndex; } + /** * @inheritdoc */ diff --git a/src/Core/Menu/QuantitySelector.php b/src/Core/Menu/QuantitySelector.php index 08d519d4..a66c2c5d 100644 --- a/src/Core/Menu/QuantitySelector.php +++ b/src/Core/Menu/QuantitySelector.php @@ -57,6 +57,13 @@ public function set(int $quantity): bool /** Adjusts the selected quantity and reports whether it changed. */ public function adjust(int $amount): bool { + // Saturate before adding: valid integer bounds can otherwise overflow to float. + if ($amount > 0 && $amount >= $this->maximum - $this->quantity) { + return $this->set($this->maximum); + } + if ($amount < 0 && $amount <= $this->minimum - $this->quantity) { + return $this->set($this->minimum); + } return $this->set($this->quantity + $amount); } diff --git a/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php b/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php index ee70a254..bb4de06a 100644 --- a/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php +++ b/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php @@ -111,7 +111,10 @@ public function update(): void public function enter(): void { $this->state->mainPanel->setHelp('esc:Cancel, enter:Confirm'); - $this->state->infoPanel->setText('Use the arrow keys to adjust the quantity of the item to purchase.'); + $this->state->infoPanel->setText(sprintf( + 'Use the arrow keys to adjust the quantity of the item to %s.', + $this->isShopPurchase ? 'sell' : 'purchase', + )); $this->quantitySelector = new QuantitySelector($this->maxQuantity); $this->symbol = config(ProjectConfig::class, 'vocab.currency.symbol', 'G'); $this->updateWindowContent(); diff --git a/src/Core/SystemData.php b/src/Core/SystemData.php index 0de14afa..a5d3f946 100644 --- a/src/Core/SystemData.php +++ b/src/Core/SystemData.php @@ -3,6 +3,8 @@ namespace Ichiloto\Engine\Core; use Ichiloto\Engine\Battle\Enumerations\BattleEngineType; +use Ichiloto\Engine\Battle\EncounterAdvantage; +use Ichiloto\Engine\Battle\Engines\ActiveTime\ActiveTimeBattleConfig; use Ichiloto\Engine\Exceptions\RequiredFieldException; readonly class SystemData @@ -42,21 +44,35 @@ public function getActiveTimeSettings(): object return $this->battle->activeTime; } + public function getOpeningSettings(): object + { + return $this->battle->opening; + } + private static function normalizeBattleSettings(mixed $battleData): object { $battleArray = is_array($battleData) ? $battleData : []; $activeTime = is_array($battleArray['activeTime'] ?? null) ? $battleArray['activeTime'] : []; + $opening = is_array($battleArray['opening'] ?? null) ? $battleArray['opening'] : []; + $preemptive = min(100, max(0, intval($opening['preemptiveChancePercent'] ?? $activeTime['surpriseAttackChancePercent'] ?? 8))); + $ambush = min(100, max(0, intval($opening['ambushChancePercent'] ?? $activeTime['backAttackChancePercent'] ?? 6))); + EncounterAdvantage::validateChances($preemptive, $ambush); return json_decode(json_encode([ 'engine' => BattleEngineType::fromValue($battleArray['engine'] ?? null)->value, + 'opening' => [ + 'preemptiveChancePercent' => $preemptive, + 'ambushChancePercent' => $ambush, + ], 'activeTime' => [ 'mode' => 'wait', 'baseFillRate' => max(1, intval($activeTime['baseFillRate'] ?? 35)), 'speedFactorPercent' => max(0, intval($activeTime['speedFactorPercent'] ?? 100)), - 'openingVariance' => max(0, intval($activeTime['openingVariance'] ?? 24)), - 'openingSpeedFactorPercent' => max(0, intval($activeTime['openingSpeedFactorPercent'] ?? 250)), - 'surpriseAttackChancePercent' => min(100, max(0, intval($activeTime['surpriseAttackChancePercent'] ?? 8))), - 'backAttackChancePercent' => min(100, max(0, intval($activeTime['backAttackChancePercent'] ?? 6))), + 'openingVariance' => max(0, intval($activeTime['openingVariance'] ?? ActiveTimeBattleConfig::DEFAULT_OPENING_VARIANCE)), + 'openingSpeedFactorPercent' => max(0, intval($activeTime['openingSpeedFactorPercent'] ?? ActiveTimeBattleConfig::DEFAULT_OPENING_SPEED_FACTOR * 100)), + // Retain the legacy accessor values; battle.opening is authoritative. + 'surpriseAttackChancePercent' => $preemptive, + 'backAttackChancePercent' => $ambush, ], ])); } diff --git a/src/Cutscenes/Cinematics/CinematicCommandSchema.php b/src/Cutscenes/Cinematics/CinematicCommandSchema.php index 1fdc663e..3fd9b4d3 100644 --- a/src/Cutscenes/Cinematics/CinematicCommandSchema.php +++ b/src/Cutscenes/Cinematics/CinematicCommandSchema.php @@ -57,7 +57,8 @@ final class CinematicCommandSchema 'failure' => 'retryable_without_completion_writes', ]; public const array STAGED_ACTOR_FIELDS = [ - 'id', 'sprite', 'asset', 'x', 'y', 'facing', 'visible', 'collision', 'sprites', + 'id', 'sprite', 'asset', 'x', 'y', 'facing', 'visible', 'collision', 'sprites', 'sprites2d', + 'subject', 'suppress', 'replace', ]; public const array SKIP_POLICIES = ['forbidden', 'authored']; public const array FINALIZER_COMMAND_TYPES = [ @@ -121,6 +122,20 @@ public static function export(): array 'completion' => self::CINEMATIC_TRIGGER_COMPLETION, ], 'stagedActorFields' => self::STAGED_ACTOR_FIELDS, + 'movementRoute' => [ + 'modes' => ['steps', 'waypoints', 'retrace'], + 'waypoints' => 'non-empty list of non-negative integer x and/or y; omitted axis retains current coordinate', + 'planning' => 'deterministic cardinal path per authored waypoint, bounded by current map; no replan after a blocked step', + 'remember' => 'unique session-local id; cinematic real player or NPC only', + 'retrace' => 'consume completed recorded path once from the original endpoint, then restore entry facing', + 'ownership' => 'one route per subject; current object, map and presentation generation', + ], + 'stagedActorBinding' => [ + 'subject' => ['kind' => 'player|npc', 'id' => 'required for npc'], + 'suppress' => 'additional real subject references sharing this visual', + 'replace' => 'explicitly replace an existing visual without resetting captured transforms', + 'movement' => 'route the real subject; a bound visual has no independent transform or collision', + ], 'skipPolicies' => self::SKIP_POLICIES, 'finalizerCommandTypes' => self::FINALIZER_COMMAND_TYPES, 'finalizerCommandShapes' => self::FINALIZER_COMMAND_SHAPES, diff --git a/src/Cutscenes/Cinematics/CinematicController.php b/src/Cutscenes/Cinematics/CinematicController.php index 90172a13..bab6ebf9 100644 --- a/src/Cutscenes/Cinematics/CinematicController.php +++ b/src/Cutscenes/Cinematics/CinematicController.php @@ -15,6 +15,7 @@ final class CinematicController implements EventSessionCompletionTargetInterface protected ?CameraStateSnapshot $cameraBefore = null; protected ?EventSessionCompletionTargetInterface $downstreamCompletionTarget = null; protected bool $terminalOutcomeHandled = false; + private ?string $cameraMapBefore = null; public function __construct(protected GameScene $gameScene) { @@ -36,6 +37,7 @@ public function start( $this->active = $cinematic; $this->cameraBefore = $this->gameScene->camera->captureState(); + $this->cameraMapBefore = $this->gameScene->currentMapId; $this->downstreamCompletionTarget = $downstreamCompletionTarget; $this->terminalOutcomeHandled = false; @@ -61,6 +63,10 @@ public function start( if ($session === null) { $this->cleanup(); + } elseif ($this->active !== null && $this->gameScene->sceneManager->currentScene === $this->gameScene) { + // Apply an initial cover before yielding, but never paint a failed setup. + $this->gameScene->restoreFieldAfterOverlay(); + $this->gameScene->cinematicPresentation?->render(); } return $session; @@ -73,7 +79,8 @@ public function skip(): bool public function onEventSessionCompleted(GameScene $gameScene, EventExecutionSession $session): void { - if ($this->active === null || $session->scriptId !== $this->active->id) { + if ($this->active === null || $session->scriptId !== $this->active->id + || $gameScene->eventInterpreter?->activeSession() !== $session) { throw new RuntimeException('Cinematic completion identity did not match the active asset.'); } @@ -85,30 +92,47 @@ public function onEventSessionCompleted(GameScene $gameScene, EventExecutionSess $downstreamCompletionTarget = $this->downstreamCompletionTarget; $gameScene->gameState->recordStoryEvent('cinematic:' . $this->active->id . ':completed'); - $gameScene->getGame()->audioManager->finalizeCinematicMusic('complete'); - $this->cleanup(); + try { + $gameScene->getGame()->audioManager->finalizeCinematicMusic('complete'); + } finally { + $this->cleanup(restoreTransforms: false); + } $downstreamCompletionTarget?->onEventSessionCompleted($gameScene, $session); } public function onEventSessionFailed(GameScene $gameScene, EventExecutionSession $session): void { - if ($this->terminalOutcomeHandled) { + if ($this->terminalOutcomeHandled || $this->active === null + || $gameScene->eventInterpreter?->activeSession() !== $session) { return; } $this->terminalOutcomeHandled = true; $downstreamCompletionTarget = $this->downstreamCompletionTarget; - $gameScene->getGame()->audioManager->finalizeCinematicMusic('failure'); - $this->cleanup(); + try { + $gameScene->getGame()->audioManager->finalizeCinematicMusic('failure'); + } finally { + $this->cleanup(); + } $downstreamCompletionTarget?->onEventSessionFailed($gameScene, $session); } - protected function cleanup(): void + public function shutdown(): void + { + if ($this->active !== null) { + $this->gameScene->eventInterpreter?->failActiveSession('Cinematic interrupted by field shutdown.'); + if ($this->active !== null) { + $this->cleanup(); + } + } + } + + protected function cleanup(bool $restoreTransforms = true): void { - $this->gameScene->cinematicStage?->clear(); + $this->gameScene->cinematicStage?->clear($restoreTransforms); $this->gameScene->cinematicPresentation?->clear(); - if ($this->cameraBefore?->followsPlayer ?? true) { + if (($this->cameraBefore?->followsPlayer ?? true) || $this->cameraMapBefore !== $this->gameScene->currentMapId) { $this->gameScene->camera->attach($this->gameScene->player); } elseif ($this->cameraBefore !== null) { $this->gameScene->camera->restoreState($this->cameraBefore); @@ -116,6 +140,7 @@ protected function cleanup(): void $this->active = null; $this->cameraBefore = null; + $this->cameraMapBefore = null; $this->downstreamCompletionTarget = null; } } diff --git a/src/Cutscenes/Cinematics/CinematicPresentationManager.php b/src/Cutscenes/Cinematics/CinematicPresentationManager.php index 9046222e..a5a4dc46 100644 --- a/src/Cutscenes/Cinematics/CinematicPresentationManager.php +++ b/src/Cutscenes/Cinematics/CinematicPresentationManager.php @@ -5,8 +5,11 @@ use Ichiloto\Engine\Animations\AnimationFrame; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\ScreenTransitionSession; use Ichiloto\Engine\Scenes\Game\GameScene; +use Ichiloto\Engine\UI\Enumerations\PresentationPriority; /** Renders temporary cinematic overlays and reusable field animation frames. */ final class CinematicPresentationManager @@ -81,9 +84,10 @@ public function clear(): void public function render(): void { - $this->renderAnimation(); - $this->renderOverlay(); - $this->renderTransition(); + Console::withLayer('cinematic-animation', fn() => $this->renderAnimation(), PresentationLayerPolicy::UI); + Console::withLayer('cinematic-overlay', fn() => $this->renderOverlay(), + PresentationLayerPolicy::UI + PresentationPriority::MODAL->value); + Console::withLayer('cinematic-cover', fn() => $this->renderTransition(), PresentationLayerPolicy::TRANSITION); } protected function renderAnimation(): void @@ -137,7 +141,7 @@ protected function renderOverlay(): void $x = max(0, intdiv($screenWidth - $width, 2)); $y = $kind === 'title_card' ? max(0, intdiv($screenHeight - count($rows), 2)) - : max(0, $screenHeight - count($rows) - 2); + : 0; $this->gameScene->camera->draw($rows, $x, $y); } diff --git a/src/Cutscenes/Cinematics/CinematicScriptValidator.php b/src/Cutscenes/Cinematics/CinematicScriptValidator.php index 08ea8adb..82ba1ee9 100644 --- a/src/Cutscenes/Cinematics/CinematicScriptValidator.php +++ b/src/Cutscenes/Cinematics/CinematicScriptValidator.php @@ -3,6 +3,7 @@ namespace Ichiloto\Engine\Cutscenes\Cinematics; use InvalidArgumentException; +use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; /** * Validates cinematic command trees without booting a game runtime. @@ -215,6 +216,21 @@ protected static function validateCamera(array $command, string $cinematicId, st /** @param array $entry */ public static function validateStagedActor(array $entry, string $cinematicId, string $path): void { + try { + CinematicStageManager::validateBinding($entry); + } catch (InvalidArgumentException $error) { + throw self::failure($cinematicId, "$path/subject", $error->getMessage()); + } + if (array_key_exists('sprites2d', $entry)) { + if (!is_array($entry['sprites2d'])) { + throw self::failure($cinematicId, $path, 'staged actor sprites2d must be an array.'); + } + try { + CinematicStageManager::graphicalSprites($entry['sprites2d']); + } catch (InvalidArgumentException $error) { + throw self::failure($cinematicId, "$path/sprites2d", $error->getMessage()); + } + } $id = trim(strval($entry['id'] ?? '')); if ($id === '') { @@ -315,6 +331,11 @@ public static function validateCinematicMusic(array $command, string $cinematicI /** @param array $command */ protected static function validateMoveRoute(array $command, string $cinematicId, string $path): void { + try { + MovementRouteRunner::validatePathOptions($command); + } catch (\Throwable $error) { + throw self::failure($cinematicId, $path, $error->getMessage()); + } $subject = strtolower(trim(strval($command['subject'] ?? 'player'))); if (! in_array($subject, ['player', 'npc', 'staged_actor'], true)) { @@ -336,11 +357,14 @@ protected static function validateMoveRoute(array $command, string $cinematicId, self::validateOptionalDuration($command, ['secondsPerStep'], $cinematicId, $path); if (array_key_exists('speed', $command) - && (! is_numeric($command['speed']) || floatval($command['speed']) <= 0.0) + && (! is_numeric($command['speed']) || ! is_finite(floatval($command['speed'])) || floatval($command['speed']) <= 0.0) ) { - throw self::failure($cinematicId, $path, 'movement-route speed must be greater than zero.'); + throw self::failure($cinematicId, $path, 'movement-route speed must be finite and greater than zero.'); } + if (!array_key_exists('steps', $command)) { + return; + } $steps = $command['steps'] ?? null; if (! is_array($steps) || ! array_is_list($steps) || $steps === []) { @@ -435,9 +459,9 @@ protected static function validateOptionalDuration( $value = $command[$field]; - if (! is_numeric($value) || ($positive ? floatval($value) <= 0.0 : floatval($value) < 0.0)) { + if (! is_numeric($value) || ! is_finite(floatval($value)) || ($positive ? floatval($value) <= 0.0 : floatval($value) < 0.0)) { $expectation = $positive ? 'greater than zero' : 'zero or greater'; - throw self::failure($cinematicId, $path, sprintf('%s must be numeric and %s.', $field, $expectation)); + throw self::failure($cinematicId, $path, sprintf('%s must be finite, numeric and %s.', $field, $expectation)); } } } diff --git a/src/Cutscenes/Cinematics/CinematicStageManager.php b/src/Cutscenes/Cinematics/CinematicStageManager.php index d479814d..16590aa1 100644 --- a/src/Cutscenes/Cinematics/CinematicStageManager.php +++ b/src/Cutscenes/Cinematics/CinematicStageManager.php @@ -5,7 +5,13 @@ use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Events\Enumerations\CollisionType; +use Ichiloto\Engine\Field\Npc; +use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; +use Ichiloto\Engine\Rendering\Sprites\GraphicalSpriteDefinition; use Ichiloto\Engine\Scenes\Game\GameScene; use RuntimeException; @@ -14,7 +20,10 @@ final class CinematicStageManager { /** @var array */ protected array $actors = []; + /** @var array Transform ownership outlives a visual replacement/removal. */ + private array $subjects = []; protected(set) ?string $lastMoveFailure = null; + public private(set) int $generation = 0; public function __construct(protected GameScene $gameScene) { @@ -25,8 +34,13 @@ public function configure(array $entries): void { $this->clear(); - foreach ($entries as $entry) { - $this->add($entry); + try { + foreach ($entries as $entry) { + $this->add($entry); + } + } catch (\Throwable $error) { + $this->clear(); + throw $error; } } @@ -39,9 +53,18 @@ public function add(array $entry): StagedActor throw new RuntimeException('A staged actor requires a stable cutscene-local id.'); } - if (isset($this->actors[$id])) { + self::validateBinding($entry); + $replace = ($entry['replace'] ?? false) === true; + if (isset($this->actors[$id]) && !$replace) { throw new RuntimeException(sprintf('Duplicate staged actor id "%s".', $id)); } + if ($replace && !isset($this->actors[$id])) { + throw new RuntimeException(sprintf('Cannot replace missing staged actor "%s".', $id)); + } + + if (array_key_exists('sprites2d', $entry) && !is_array($entry['sprites2d'])) { + throw new \InvalidArgumentException('Staged actor sprites2d must be an array.'); + } $assetReference = trim(strval($entry['asset'] ?? '')) ?: null; $sprite = $entry['sprite'] ?? '@'; @@ -56,6 +79,27 @@ public function add(array $entry): StagedActor $sprite = is_array($sprite) ? array_values(array_map('strval', $sprite)) : [strval($sprite)]; $facing = MovementHeading::tryFrom(ucfirst(strtolower(strval($entry['facing'] ?? 'South')))) ?? MovementHeading::SOUTH; + $graphicalSprites = isset($entry['sprites2d']) ? self::graphicalSprites($entry['sprites2d']) : null; + $subject = isset($entry['subject']) ? $this->resolveSubject($entry['subject']) : null; + $suppressed = $subject !== null ? [spl_object_id($subject) => $subject] : []; + foreach ($entry['suppress'] ?? [] as $reference) { + $other = $this->resolveSubject($reference); + $suppressed[spl_object_id($other)] = $other; + } + foreach ($this->actors as $existing) { + if ($existing->id === $id) { + continue; + } + foreach ($existing->suppressedSubjects as $lease) { + if (isset($suppressed[spl_object_id($lease->subject)])) { + throw new RuntimeException(sprintf('Subject presentation is already owned by staged actor "%s".', $existing->id)); + } + } + } + $leases = []; + foreach ($suppressed as $key => $realSubject) { + $leases[$key] = $this->subjects[$key] ?? new CinematicSubjectLease($this->gameScene, $realSubject); + } $actor = new StagedActor( id: $id, sprite: $sprite !== [] ? $sprite : ['@'], @@ -65,11 +109,138 @@ public function add(array $entry): StagedActor directionalSprites: is_array($entry['sprites'] ?? null) ? $entry['sprites'] : [], facing: $facing, assetReference: $assetReference, + graphicalSprites: $graphicalSprites, + subject: $subject !== null ? $leases[spl_object_id($subject)] : null, + suppressedSubjects: array_values($leases), ); + ($this->actors[$id] ?? null)?->releaseVisual(); + $this->subjects += $leases; $this->actors[$id] = $actor; + $this->gameScene->requestFieldPresentationReconciliation(); return $actor; } + /** Shared runtime/authoring boundary for real-subject presentation ownership. */ + public static function validateBinding(array $entry): void + { + if (array_key_exists('replace', $entry) && !is_bool($entry['replace'])) { + throw new \InvalidArgumentException('Staged actor replace must be boolean.'); + } + if (!array_key_exists('subject', $entry)) { + if (array_key_exists('suppress', $entry)) { + throw new \InvalidArgumentException('Additional suppression requires a real subject anchor.'); + } + return; + } + if (($entry['collision'] ?? false) !== false + || array_intersect(['x', 'y', 'facing'], array_keys($entry)) !== []) { + throw new \InvalidArgumentException('A bound visual inherits real position/facing and cannot add collision.'); + } + $suppressed = array_key_exists('suppress', $entry) ? $entry['suppress'] : []; + if (!is_array($suppressed) || !array_is_list($suppressed)) { + throw new \InvalidArgumentException('Staged actor suppress must be a list of real subject references.'); + } + foreach ([$entry['subject'], ...$suppressed] as $reference) { + if (!is_array($reference) || !in_array($reference['kind'] ?? null, ['player', 'npc'], true) + || (($reference['kind'] ?? null) === 'npc' + && (!is_string($reference['id'] ?? null) || trim($reference['id']) === ''))) { + throw new \InvalidArgumentException('Real subject requires kind player or npc, with a stable id for npc.'); + } + } + } + + private function resolveSubject(array $reference): Player|Npc + { + $subject = $reference['kind'] === 'player' ? $this->gameScene->player + : $this->gameScene->npcManager?->findById($reference['id']); + if ($subject === null || ($subject instanceof Npc + && !in_array($subject, $this->gameScene->npcManager?->visibleNpcs() ?? [], true))) { + throw new RuntimeException(sprintf('Real cinematic subject "%s" is unavailable on map "%s".', + $reference['id'] ?? 'player', $this->gameScene->currentMapId)); + } + return $subject; + } + + public function suppresses(Player|Npc $subject): bool + { + foreach ($this->actors as $actor) { + if (!$actor->ownsPresentation()) { + continue; + } + foreach ($actor->suppressedSubjects as $lease) { + if ($lease->subject === $subject) { + return true; + } + } + } + return false; + } + + public function subjectMoved(Player|Npc $subject): void + { + foreach ($this->actors as $actor) { + if ($actor->subject?->subject === $subject) { + $actor->beginGraphicalStep(); + } + } + } + + public function subjectStopped(Player|Npc $subject): void + { + foreach ($this->actors as $actor) { + if ($actor->subject?->subject === $subject) { + $actor->stopGraphicalAnimation(); + } + } + } + + public function restoreSubjectTransforms(): void + { + foreach ($this->subjects as $lease) { + $lease->restore(); + $this->subjectStopped($lease->subject); + } + $this->gameScene->requestFieldPresentationReconciliation(); + } + + /** Route ownership does not require replacing a subject's normal artwork. */ + public function captureSubject(Player|Npc $subject): void + { + $key = spl_object_id($subject); + $this->subjects[$key] ??= new CinematicSubjectLease($this->gameScene, $subject); + } + + public function commitSubjectTransforms(Player|Npc|null $subject = null): void + { + foreach ($this->subjects as $lease) { + if ($subject === null || $lease->subject === $subject) { + $lease->commit(); + } + } + } + + /** Shared by authoring validation and runtime staging. */ + public static function graphicalSprites(array $data): GraphicalSpriteDefinition|DirectionalGraphicalSpriteSet + { + $sprites = array_key_exists('asset', $data) + ? GraphicalSpriteDefinition::fromArray($data) : DirectionalGraphicalSpriteSet::fromArray($data); + $definitions = $sprites instanceof DirectionalGraphicalSpriteSet + ? [$sprites->north, $sprites->east, $sprites->south, $sprites->west] : [$sprites]; + foreach ($definitions as $definition) { + if ($definition->layer < PresentationLayerPolicy::WORLD || $definition->layer >= PresentationLayerPolicy::UI) { + throw new \InvalidArgumentException('Cinematic world sprites require layers 0..999; UI layers are reserved.'); + } + } + return $sprites; + } + + public function advanceGraphicalAnimation(float $seconds): void + { + foreach ($this->actors as $actor) { + $actor->advanceGraphicalAnimation($seconds); + } + } + public function find(string $id): ?StagedActor { return $this->actors[trim($id)] ?? null; @@ -93,12 +264,23 @@ public function hide(string $id): void public function remove(string $id): void { + ($this->actors[trim($id)] ?? null)?->releaseVisual(); unset($this->actors[trim($id)]); + $this->gameScene->requestFieldPresentationReconciliation(); } - public function clear(): void + public function clear(bool $restoreTransforms = true): void { + $this->generation++; + foreach ($this->subjects as $lease) { + $lease->release($restoreTransforms); + } + foreach ($this->actors as $actor) { + $actor->releaseVisual(); + } $this->actors = []; + $this->subjects = []; + $this->gameScene->requestFieldPresentationReconciliation(); } /** @return StagedActor[] */ @@ -126,9 +308,8 @@ public function move(string $id, Vector2 $direction, bool $faceOnly = false): bo { $actor = $this->require($id); $this->lastMoveFailure = null; - $actor->face($direction); - if ($faceOnly) { + $actor->face($direction); return true; } @@ -168,6 +349,7 @@ public function move(string $id, Vector2 $direction, bool $faceOnly = false): bo } if ($blocker !== null) { + $actor->face($direction); $this->lastMoveFailure = sprintf( 'Staged actor "%s" cannot move to (%d, %d): blocked by %s.', $id, @@ -179,8 +361,7 @@ public function move(string $id, Vector2 $direction, bool $faceOnly = false): bo } } - $actor->position->x = $x; - $actor->position->y = $y; + $actor->move($direction); return true; } @@ -188,7 +369,9 @@ public function render(): void { foreach ($this->actors as $actor) { if ($actor->isVisible) { - $this->gameScene->camera->renderOnScreen($actor->sprite, $actor->position); + Console::withLayer($actor->getGraphicalSpriteId(), function () use ($actor): void { + $this->gameScene->camera->renderOnScreen($actor->sprite, $actor->position); + }); } } } diff --git a/src/Cutscenes/Cinematics/CinematicSubjectLease.php b/src/Cutscenes/Cinematics/CinematicSubjectLease.php new file mode 100644 index 00000000..5e0c3cc2 --- /dev/null +++ b/src/Cutscenes/Cinematics/CinematicSubjectLease.php @@ -0,0 +1,72 @@ +mapId = $scene->currentMapId; + $this->commit(); + } + + public function isCurrent(): bool + { + return !$this->released && $this->scene->currentMapId === $this->mapId + && ($this->subject instanceof Player + ? $this->scene->player === $this->subject + : $this->scene->npcManager?->findById($this->subject->id ?? '') === $this->subject); + } + + public function isEligible(): bool + { + return $this->isCurrent() && ($this->subject instanceof Player + || in_array($this->subject, $this->scene->npcManager?->visibleNpcs() ?? [], true)); + } + + /** Ratify only this transform, never story state or presentation eligibility. */ + public function commit(): void + { + if (!$this->isCurrent()) { + return; + } + $this->entryPosition = clone $this->subject->position; + $this->entryHeading = $this->subject->heading; + $this->entrySprite = $this->subject->sprite; + } + + public function restore(): void + { + if (!$this->isEligible()) { + return; + } + if ($this->subject instanceof Player) { + $this->subject->restoreFieldTransform($this->entryPosition, $this->entryHeading, (array)$this->entrySprite); + } else { + $this->subject->restoreFieldTransform($this->entryPosition, $this->entryHeading, strval($this->entrySprite)); + } + } + + public function release(bool $restore): void + { + if ($restore) { + $this->restore(); + } + $this->released = true; + } +} diff --git a/src/Cutscenes/Cinematics/StagedActor.php b/src/Cutscenes/Cinematics/StagedActor.php index 6cf9dfec..2fc84f84 100644 --- a/src/Cutscenes/Cinematics/StagedActor.php +++ b/src/Cutscenes/Cinematics/StagedActor.php @@ -4,11 +4,36 @@ use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; +use Ichiloto\Engine\Rendering\Sprites\GraphicalSpriteDefinition; +use Ichiloto\Engine\Rendering\Sprites\GraphicalSpriteProviderInterface; +use Ichiloto\Engine\Rendering\Sprites\SpriteWalkAnimation; /** A temporary field participant owned by one cinematic. */ -final class StagedActor +final class StagedActor implements GraphicalSpriteProviderInterface { - protected(set) MovementHeading $facing; + public MovementHeading $facing { + get => $this->subject?->subject->heading ?? $this->localFacing; + } + public Vector2 $position { + get => $this->subject !== null ? clone $this->subject->subject->position : $this->localPosition; + } + public array $sprite { + get { + $sprite = $this->directionalSprites[$this->facing->name] + ?? $this->directionalSprites[strtolower($this->facing->name)] ?? null; + return is_array($sprite) && $sprite !== [] ? array_values(array_map('strval', $sprite)) : $this->baseSprite; + } + } + public bool $isVisible { + get => $this->visible && $this->ownsPresentation(); + } + private MovementHeading $localFacing; + private Vector2 $localPosition; + private array $baseSprite; + private bool $visible; + private bool $released = false; + private SpriteWalkAnimation $walkAnimation; /** * @param string[] $sprite @@ -16,49 +41,128 @@ final class StagedActor */ public function __construct( public readonly string $id, - protected(set) array $sprite, - protected(set) Vector2 $position, - protected(set) bool $isVisible = true, + array $sprite, + Vector2 $position, + bool $isVisible = true, protected(set) bool $hasCollision = false, protected(set) array $directionalSprites = [], MovementHeading $facing = MovementHeading::SOUTH, public readonly ?string $assetReference = null, + private readonly GraphicalSpriteDefinition|DirectionalGraphicalSpriteSet|null $graphicalSprites = null, + public readonly ?CinematicSubjectLease $subject = null, + /** @var CinematicSubjectLease[] */ + public readonly array $suppressedSubjects = [], ) { - $this->facing = $facing; - $this->applyFacingSprite(); + $this->localFacing = $facing; + $this->localPosition = $position; + $this->baseSprite = $sprite; + $this->visible = $isVisible; + $this->walkAnimation = new SpriteWalkAnimation(); } public function show(): void { - $this->isVisible = true; + $this->visible = true; } public function hide(): void { - $this->isVisible = false; + $this->visible = false; + $this->walkAnimation->stop(); } public function face(Vector2 $direction): void { - $this->facing = match (true) { + $this->assertIndependentMotion(); + $this->walkAnimation->stop(); + $this->setFacing($direction); + } + + public function move(Vector2 $direction): void + { + $this->assertIndependentMotion(); + $this->setFacing($direction); + $this->position->x += $direction->x; + $this->position->y += $direction->y; + $this->beginGraphicalStep(); + } + + private function setFacing(Vector2 $direction): void + { + $this->localFacing = match (true) { $direction->y < 0 => MovementHeading::NORTH, $direction->y > 0 => MovementHeading::SOUTH, $direction->x < 0 => MovementHeading::WEST, $direction->x > 0 => MovementHeading::EAST, default => $this->facing, }; - $this->applyFacingSprite(); } - protected function applyFacingSprite(): void + public function getGraphicalSpriteId(): string + { + return 'staged:' . $this->id; + } + + public function getGraphicalSpriteDefinition(): ?GraphicalSpriteDefinition + { + $definition = $this->directionDefinition(); + return !$this->isVisible || $definition === null ? null : $this->walkAnimation->present($definition); + } + + public function getGraphicalSpriteWorldPosition(): Vector2 + { + return clone $this->position; + } + + public function beginGraphicalStep(): void { - $sprite = $this->directionalSprites[$this->facing->name] - ?? $this->directionalSprites[strtolower($this->facing->name)] - ?? null; + $definition = $this->directionDefinition(); + if ($this->isVisible && $definition !== null) { + $this->walkAnimation->step($definition); + } + } + + public function advanceGraphicalAnimation(float $seconds): void + { + $this->walkAnimation->advance($seconds); + } + + private function directionDefinition(): ?GraphicalSpriteDefinition + { + return $this->graphicalSprites instanceof DirectionalGraphicalSpriteSet + ? $this->graphicalSprites->getForHeading($this->facing) : $this->graphicalSprites; + } + + public function stopGraphicalAnimation(): void + { + $this->walkAnimation->stop(); + } + + /** Hidden leases still suppress ordinary art; ineligible subjects do not. */ + public function ownsPresentation(): bool + { + if ($this->released) { + return false; + } + foreach ($this->suppressedSubjects as $lease) { + if (!$lease->isEligible()) { + return false; + } + } + return $this->subject === null || $this->subject->isEligible(); + } - if (is_array($sprite) && $sprite !== []) { - $this->sprite = array_values(array_map('strval', $sprite)); + public function releaseVisual(): void + { + $this->released = true; + $this->walkAnimation->stop(); + } + + private function assertIndependentMotion(): void + { + if ($this->subject !== null) { + throw new \RuntimeException('A bound staged visual cannot move independently; route its real subject.'); } } } diff --git a/src/Diagnostics/LatencyTrace.php b/src/Diagnostics/LatencyTrace.php index 871dc9b5..4a0f4db4 100644 --- a/src/Diagnostics/LatencyTrace.php +++ b/src/Diagnostics/LatencyTrace.php @@ -59,7 +59,7 @@ public static function enabled(): bool return self::$sink !== null; } - public static function now(): ?int + public static function getTimeNow(): ?int { return self::enabled() ? (self::$clock !== null ? (self::$clock)() : hrtime(true)) : null; } @@ -67,7 +67,7 @@ public static function now(): ?int /** @param array $data */ public static function record(string $stage, array $data = []): void { - if (($now = self::now()) !== null) { + if (($now = self::getTimeNow()) !== null) { (self::$sink)([ 'stage' => $stage, 'at_ns' => $now, 'pid' => getmypid(), 'iteration' => self::$iteration, 'input_id' => self::$input['id'] ?? null, @@ -79,7 +79,7 @@ public static function record(string $stage, array $data = []): void /** @param array $data */ public static function end(string $stage, ?int $start, array $data = []): void { - if ($start !== null) { self::record($stage, ['duration_ns' => self::now() - $start, ...$data]); } + if ($start !== null) { self::record($stage, ['duration_ns' => self::getTimeNow() - $start, ...$data]); } } public static function beginIteration(): void @@ -106,10 +106,10 @@ public static function keyStage(RendererEvent $event, string $stage, bool $activ { if (!self::enabled() || $event->key === null) { return; } self::$keys ??= new WeakMap(); - $key = self::$keys[$event] ??= ['id' => ++self::$nextKey, 'key' => $event->key, 'received_ns' => self::now()]; + $key = self::$keys[$event] ??= ['id' => ++self::$nextKey, 'key' => $event->key, 'received_ns' => self::getTimeNow()]; if ($activate) { self::$input = $key; } self::record($stage, ['input_id' => $key['id'], 'key' => $key['key'], - 'observed_age_ns' => self::now() - $key['received_ns']]); + 'observed_age_ns' => self::getTimeNow() - $key['received_ns']]); } public static function queue(int $count, ?RendererEvent $oldest): void @@ -117,13 +117,13 @@ public static function queue(int $count, ?RendererEvent $oldest): void if (!self::enabled()) { return; } $received = $oldest === null ? null : (self::$keys[$oldest]['received_ns'] ?? null); self::record('input.queue', ['queued_keys' => $count, - 'oldest_age_ns' => $received === null ? null : self::now() - $received]); + 'oldest_age_ns' => $received === null ? null : self::getTimeNow() - $received]); } public static function accepted(string $key, string $source): void { if (!self::enabled()) { return; } - self::$input ??= ['id' => ++self::$nextKey, 'key' => $key, 'received_ns' => self::now()]; + self::$input ??= ['id' => ++self::$nextKey, 'key' => $key, 'received_ns' => self::getTimeNow()]; self::$consumed++; self::record('input.accepted', ['key' => $key, 'source' => $source]); } @@ -131,9 +131,9 @@ public static function accepted(string $key, string $source): void public static function returned(string $key, string $source, ?int $started = null): void { if (!self::enabled()) { return; } - self::$input ??= ['id' => ++self::$nextKey, 'key' => $key, 'received_ns' => self::now()]; + self::$input ??= ['id' => ++self::$nextKey, 'key' => $key, 'received_ns' => self::getTimeNow()]; self::record('input.source.returned', ['key' => $key, 'source' => $source, - 'poll_ns' => $started === null ? null : self::now() - $started]); + 'poll_ns' => $started === null ? null : self::getTimeNow() - $started]); } /** Flush outside measured frame work; bounded records prevent diagnostic backlogs. */ diff --git a/src/Entities/Character.php b/src/Entities/Character.php index 3ae54774..abdbb343 100644 --- a/src/Entities/Character.php +++ b/src/Entities/Character.php @@ -99,6 +99,12 @@ class Character implements CharacterInterface, CanEquip */ protected array $levelExpThresholds = []; + /** @return array The current role's cumulative level thresholds. */ + public function getLevelExperienceThresholds(): array + { + return $this->levelExpThresholds; + } + /** * Raw character data retained while save aliases and tombstones are being * resolved. It is never included in a newly written save. diff --git a/src/Entities/EquipmentOptimization/DeclaredEquipmentOptimizationPolicy.php b/src/Entities/EquipmentOptimization/DeclaredEquipmentOptimizationPolicy.php index b239a62c..ff768b6f 100644 --- a/src/Entities/EquipmentOptimization/DeclaredEquipmentOptimizationPolicy.php +++ b/src/Entities/EquipmentOptimization/DeclaredEquipmentOptimizationPolicy.php @@ -69,7 +69,7 @@ public function __construct( throw new InvalidArgumentException('Role-slot equipment optimization weights must be arrays.'); } - $this->roleSlotStatWeights[self::normalize($role)] = self::normalizeScopedWeights( + $this->roleSlotStatWeights[self::requireName($role, 'role')] = self::normalizeScopedWeights( $slots, sprintf('role %s slot', $role), ); @@ -77,9 +77,9 @@ public function __construct( $this->elementOutcomeWeights = self::normalizeNamedWeights($elementOutcomeWeights, 'element outcome'); $this->specialPropertyWeights = self::normalizeNamedWeights($specialPropertyWeights, 'special property'); - $this->excludedDefinitionIds = self::normalizeSet($excludedDefinitionIds); - $this->excludedAvailabilities = self::normalizeSet($excludedAvailabilities); - $this->excludedAcquisitionPolicies = self::normalizeSet($excludedAcquisitionPolicies); + $this->excludedDefinitionIds = self::normalizeSet($excludedDefinitionIds, 'definition ID'); + $this->excludedAvailabilities = self::normalizeSet($excludedAvailabilities, 'availability'); + $this->excludedAcquisitionPolicies = self::normalizeSet($excludedAcquisitionPolicies, 'acquisition policy'); } public function score( @@ -179,7 +179,7 @@ private static function normalizeWeights(array $weights, string $scope): array $normalized = []; foreach ($weights as $key => $weight) { - $lookup = self::normalize($key); + $lookup = self::requireName($key, $scope); $stat = array_find( StatKey::cases(), static fn(StatKey $candidate): bool => strtolower($candidate->value) === $lookup, @@ -210,7 +210,7 @@ private static function normalizeScopedWeights(array $scopes, string $scopeType) throw new InvalidArgumentException(sprintf('%s equipment optimization weights must be arrays.', ucfirst($scopeType))); } - $normalized[self::normalize($scope)] = self::normalizeWeights($weights, sprintf('%s %s', $scopeType, $scope)); + $normalized[self::requireName($scope, $scopeType)] = self::normalizeWeights($weights, sprintf('%s %s', $scopeType, $scope)); } return $normalized; @@ -222,9 +222,9 @@ private static function normalizeNamedWeights(array $weights, string $kind): arr $normalized = []; foreach ($weights as $name => $weight) { - $name = self::normalize($name); + $name = self::requireName($name, $kind); - if ($name === '' || ! is_int($weight)) { + if (! is_int($weight)) { throw new InvalidArgumentException(sprintf('%s equipment optimization weights require names and integer values.', ucfirst($kind))); } @@ -235,19 +235,30 @@ private static function normalizeNamedWeights(array $weights, string $kind): arr } /** @param string[] $values @return array */ - private static function normalizeSet(array $values): array + private static function normalizeSet(array $values, string $kind): array { + if (! array_is_list($values)) { + throw new InvalidArgumentException(sprintf('Equipment optimization %s exclusions must be a list.', $kind)); + } + $normalized = []; foreach ($values as $value) { - if (is_string($value) && self::normalize($value) !== '') { - $normalized[self::normalize($value)] = true; - } + $normalized[self::requireName($value, $kind . ' exclusion')] = true; } return $normalized; } + private static function requireName(mixed $value, string $kind): string + { + if (! is_string($value) || trim($value) === '') { + throw new InvalidArgumentException(sprintf('Equipment optimization %s names must be non-empty strings.', $kind)); + } + + return self::normalize($value); + } + private static function normalize(string $value): string { return strtolower(trim($value)); diff --git a/src/Entities/EquipmentOptimization/EquipmentOptimizationPolicyRegistry.php b/src/Entities/EquipmentOptimization/EquipmentOptimizationPolicyRegistry.php index 58c97f0b..c2280a31 100644 --- a/src/Entities/EquipmentOptimization/EquipmentOptimizationPolicyRegistry.php +++ b/src/Entities/EquipmentOptimization/EquipmentOptimizationPolicyRegistry.php @@ -2,11 +2,70 @@ namespace Ichiloto\Engine\Entities\EquipmentOptimization; +use Assegai\Util\Path; +use InvalidArgumentException; +use RuntimeException; +use Throwable; + /** Process-wide project policy selected during game bootstrap. */ final class EquipmentOptimizationPolicyRegistry { + private const array KEYS = [ + 'statWeights', + 'roleStatWeights', + 'slotStatWeights', + 'roleSlotStatWeights', + 'elementOutcomeWeights', + 'specialPropertyWeights', + 'excludedDefinitionIds', + 'excludedAvailabilities', + 'excludedAcquisitionPolicies', + ]; + private static ?EquipmentOptimizationPolicyInterface $policy = null; + /** Loads the existing Editor-authored project contract, never retaining a prior project's policy. */ + public static function configureFromProject(): void + { + self::reset(); + $filename = Path::join(Path::getCurrentWorkingDirectory(), 'assets', 'Data', 'equipment-optimization.php'); + + if (! file_exists($filename) && ! is_link($filename)) { + return; + } + + try { + if (! is_file($filename) || ! is_readable($filename)) { + throw new InvalidArgumentException('The declaration must be a readable PHP file.'); + } + + $data = (static fn(): mixed => require $filename)(); + + if (! is_array($data)) { + throw new InvalidArgumentException('The declaration must return an array.'); + } + + foreach ($data as $key => $value) { + if (! is_string($key) || ! in_array($key, self::KEYS, true)) { + throw new InvalidArgumentException(sprintf('Unknown equipment optimization field: %s.', $key)); + } + + if (! is_array($value)) { + throw new InvalidArgumentException(sprintf('Equipment optimization field %s must be an array.', $key)); + } + } + + // An explicit empty declaration is a zero-weight policy, not the legacy fallback. + self::configure(new DeclaredEquipmentOptimizationPolicy(...$data)); + } catch (Throwable $exception) { + throw new RuntimeException(sprintf( + 'Equipment optimization policy %s could not be loaded: %s', + $filename, + $exception->getMessage(), + ), previous: $exception); + } + } + public static function configure(?EquipmentOptimizationPolicyInterface $policy): void { self::$policy = $policy; diff --git a/src/Entities/Inventory/Inventory.php b/src/Entities/Inventory/Inventory.php index 578bffa3..b4d2f8a8 100644 --- a/src/Entities/Inventory/Inventory.php +++ b/src/Entities/Inventory/Inventory.php @@ -130,10 +130,6 @@ public function __construct( public function addItems(InventoryItemInterface ...$items): void { foreach ($items as $index => $item) { - if ($this->inventoryItems->count() >= $this->capacity) { - break; - } - if (! $item instanceof InventoryItemInterface) { throw new InvalidArgumentException('The item must be an instance of ' . InventoryItemInterface::class); } @@ -144,6 +140,10 @@ public function addItems(InventoryItemInterface ...$items): void continue; } + if ($this->inventoryItems->count() >= $this->capacity) { + continue; + } + $this->inventoryItems->add($item); } diff --git a/src/Entities/Inventory/InventoryItem.php b/src/Entities/Inventory/InventoryItem.php index 68c923c2..575c8cf0 100644 --- a/src/Entities/Inventory/InventoryItem.php +++ b/src/Entities/Inventory/InventoryItem.php @@ -83,10 +83,15 @@ public function __construct( } } + /** Whether the definition permits sale, independently of price or held quantity. */ + public bool $isSellable { + get => $this->sellable && ! $this->isKeyItem; + } + /** Deterministic integral sale value; half-up rounding occurs exactly once. */ public int $sellValue { get { - if (! $this->sellable || $this->isKeyItem) { + if (! $this->isSellable) { return 0; } diff --git a/src/Entities/Magic/LearnableSpell.php b/src/Entities/Magic/LearnableSpell.php index 625d1f32..b6107957 100644 --- a/src/Entities/Magic/LearnableSpell.php +++ b/src/Entities/Magic/LearnableSpell.php @@ -89,14 +89,15 @@ public function isReady(Character $character, Party $party, array $storyEvents = * * @param Character $character The learning character. * @param Party $party The party that may pay shared costs. + * @param string[] $storyEvents The recorded story-event flags used by learning. * @return string The status label. */ - public function getStatusLabel(Character $character, Party $party): string + public function getStatusLabel(Character $character, Party $party, array $storyEvents = []): string { if ($this->isLearned) { return 'Learned'; } - return $this->isReady($character, $party) ? 'Ready' : 'In Progress'; + return $this->isReady($character, $party, $storyEvents) ? 'Ready' : 'In Progress'; } } diff --git a/src/Entities/Magic/SpellLearningRequirement.php b/src/Entities/Magic/SpellLearningRequirement.php index 0d325f30..f9a52f08 100644 --- a/src/Entities/Magic/SpellLearningRequirement.php +++ b/src/Entities/Magic/SpellLearningRequirement.php @@ -132,9 +132,10 @@ public function consumeCosts(Party $party): void * @param Character $character The character learning the spell. * @param Party $party The party that may pay shared costs. * @param int $trainingHours The current training-time progress. + * @param string[] $storyEvents The recorded story-event flags used by learning. * @return string The requirement summary. */ - public function describeProgress(Character $character, Party $party, int $trainingHours): string + public function describeProgress(Character $character, Party $party, int $trainingHours, array $storyEvents = []): string { $parts = []; @@ -162,6 +163,10 @@ public function describeProgress(Character $character, Party $party, int $traini ); } + foreach ($this->requiredEvents as $eventName) { + $parts[] = sprintf('%s %s', $eventName, in_array($eventName, $storyEvents, true) ? '[Done]' : '[Needed]'); + } + return implode(' ', $parts); } } diff --git a/src/Events/Interpreter/EventExecutionLane.php b/src/Events/Interpreter/EventExecutionLane.php index e19670ca..939e9aae 100644 --- a/src/Events/Interpreter/EventExecutionLane.php +++ b/src/Events/Interpreter/EventExecutionLane.php @@ -125,16 +125,24 @@ public function complete(): void public function fail(string $message): void { + if ($this->status === EventExecutionStatus::FAILED) { + return; + } $this->failureMessage = $message; - $this->cancelPending(); $this->status = EventExecutionStatus::FAILED; + $this->cancelPending(); } public function cancel(): void { - $this->cancelPending(); - $this->frames = []; - $this->status = EventExecutionStatus::COMPLETED; + try { + $this->cancelPending(); + } finally { + $this->frames = []; + if ($this->status !== EventExecutionStatus::FAILED) { + $this->status = EventExecutionStatus::COMPLETED; + } + } } public function hasFinishedFrames(): bool @@ -145,13 +153,27 @@ public function hasFinishedFrames(): bool protected function cancelPending(): void { - $this->pendingOperation?->cancel(); - $this->parallelGroup?->cancel(); + $operation = $this->pendingOperation; + $group = $this->parallelGroup; + // Detach first so throwing or reentrant cancellation cannot run an operation twice. $this->pendingOperation = null; $this->parallelGroup = null; $this->pendingCommand = null; $this->pendingState = []; $this->frameToPush = null; + $failures = []; + foreach ([$operation, $group] as $pending) { + try { + $pending?->cancel(); + } catch (\Throwable $failure) { + $failures[] = $failure; + } + } + if ($failures !== []) { + throw new \RuntimeException(sprintf('Lane "%s" cancellation failed: %s', $this->path, + implode('; ', array_map(static fn(\Throwable $failure): string => $failure->getMessage(), $failures))), + previous: $failures[0]); + } } protected function discardCompletedFrames(): void diff --git a/src/Events/Interpreter/EventExecutionSession.php b/src/Events/Interpreter/EventExecutionSession.php index 331fa99e..37b0207f 100644 --- a/src/Events/Interpreter/EventExecutionSession.php +++ b/src/Events/Interpreter/EventExecutionSession.php @@ -11,6 +11,38 @@ final class EventExecutionSession protected EventExecutionLane $rootLane; protected ?EventExecutionLane $suspendedLane = null; protected ?string $presentationOwner = null; + /** @var array */ + private array $movementRoutes = []; + /** @var array */ + private array $movingSubjects = []; + + public function claimMovementSubject(object $subject): void + { + $key = spl_object_id($subject); + if (isset($this->movingSubjects[$key])) { + throw new \RuntimeException('Only one movement route may own a subject at a time.'); + } + $this->movingSubjects[$key] = true; + } + + public function releaseMovementSubject(object $subject): void + { + unset($this->movingSubjects[spl_object_id($subject)]); + } + + public function rememberMovementRoute(string $id, MovementRouteRecord $route): void + { + if (isset($this->movementRoutes[$id])) { + throw new \RuntimeException(sprintf('Movement route "%s" is already recorded in this session.', $id)); + } + $this->movementRoutes[$id] = $route; + } + + public function movementRoute(string $id): MovementRouteRecord + { + return $this->movementRoutes[$id] + ?? throw new \RuntimeException(sprintf('Movement route "%s" was not recorded in this session.', $id)); + } protected(set) EventExecutionStatus $status = EventExecutionStatus::RUNNING; protected(set) ?string $failureMessage = null; @@ -119,21 +151,31 @@ public function updatePendingState(array $state): void public function complete(): void { $this->rootLane->complete(); + $this->movementRoutes = []; + $this->movingSubjects = []; $this->status = EventExecutionStatus::COMPLETED; } public function fail(string $message): void { + if ($this->status === EventExecutionStatus::FAILED) { + return; + } $this->failureMessage = $message; - $this->rootLane->fail($message); $this->status = EventExecutionStatus::FAILED; + $this->rootLane->fail($message); } public function cancelLanes(): void { - $this->rootLane->cancel(); - $this->suspendedLane = null; - $this->presentationOwner = null; + try { + $this->rootLane->cancel(); + } finally { + $this->suspendedLane = null; + $this->presentationOwner = null; + $this->movementRoutes = []; + $this->movingSubjects = []; + } } public function hasFinishedFrames(): bool diff --git a/src/Events/Interpreter/EventInterpreter.php b/src/Events/Interpreter/EventInterpreter.php index 5dd3b189..f96f3f10 100644 --- a/src/Events/Interpreter/EventInterpreter.php +++ b/src/Events/Interpreter/EventInterpreter.php @@ -284,6 +284,10 @@ public function skipActiveCinematic(): bool return false; } + if (in_array($session->status, [EventExecutionStatus::COMPLETED, EventExecutionStatus::FAILED], true)) { + return false; + } + if ($session->cinematic->skipPolicy !== 'authored' || $session->cinematic->finalizer === []) { Debug::warn(sprintf('Cinematic "%s" does not declare an authored safe finalizer.', $session->cinematic->id)); return false; @@ -301,14 +305,15 @@ public function skipActiveCinematic(): bool return false; } - $this->presentation->reset(); + try { + $this->presentation->reset(); - if (! $session->startFinalizerIfNeeded()) { - Debug::warn(sprintf('Cinematic "%s" finalizer could not be started.', $session->cinematic->id)); - return false; - } + if (! $session->startFinalizerIfNeeded()) { + Debug::warn(sprintf('Cinematic "%s" finalizer could not be started.', $session->cinematic->id)); + return false; + } - try { + $this->gameScene->cinematicStage?->restoreSubjectTransforms(); $this->tickLane($session, $session->rootLane(), 0.0); if ($session->rootLane()->status === EventExecutionStatus::COMPLETED) { @@ -457,12 +462,15 @@ protected function execute( $player->erase(); $player->position->x = intval($command['x']); $player->position->y = intval($command['y']); + if ($session->isFinalizing) { + $this->gameScene->cinematicStage?->commitSubjectTransforms($player); + } $player->render(); } return EventCommandResult::COMPLETED; case 'move_route': - $route = new MovementRouteRunner($this->gameScene, $command); + $route = new MovementRouteRunner($this->gameScene, $command, $session); $lane->yieldFor($command, ['kind' => 'movement_route'], $route); return EventCommandResult::YIELDED; @@ -528,16 +536,24 @@ protected function execute( } } + $firstStrike = array_key_exists('firstStrike', $command) + ? \Ichiloto\Engine\Battle\EncounterAdvantage::fromSetting($command['firstStrike'])->value + : null; + $session->suspendLane($lane, $command, [ 'kind' => 'battle', 'resultVariable' => trim(strval($command['resultVariable'] ?? '')), 'defeatPolicy' => $defeatPolicy, ]); - $extraSettings = ['event_defeat_policy' => $defeatPolicy]; + $extraSettings = ['event_defeat_policy' => $defeatPolicy] + + array_intersect_key($command, ['battleArena' => true]); if ($escapePolicy !== null) { $extraSettings['escapePolicy'] = $escapePolicy; } + if ($firstStrike !== null) { + $extraSettings['firstStrike'] = $firstStrike; + } $this->gameScene->sceneManager->loadBattleScene( $this->gameScene->party, @@ -980,25 +996,49 @@ protected function fail(string $message, ?Throwable $throwable = null): void { $session = $this->activeSession; - if ($session === null) { + if ($session === null || $session->status === EventExecutionStatus::FAILED) { return; } - $this->presentation->reset(); - $this->gameScene->cinematicPresentation?->clear(); - $session->cancelLanes(); - $session->fail($message); - Debug::error($message); - - try { - $session->completionTarget?->onEventSessionFailed($this->gameScene, $session); - } catch (Throwable $completionFailure) { - Debug::error(sprintf('Event failure cleanup failed: %s', $completionFailure->getMessage())); + $diagnostics = [$message]; + // Mark failure before invoking user-owned cancellation; preserve identity until the + // completion target has released its cinematic ownership, even when cleanup throws. + foreach ([ + 'cancel operations' => fn() => $session->fail($message), + 'release lanes' => fn() => $session->cancelLanes(), + 'reset presentation' => fn() => $this->presentation->reset(), + 'clear cinematic presentation' => fn() => $this->gameScene->cinematicPresentation?->clear(), + 'notify failure target' => fn() => $session->completionTarget?->onEventSessionFailed($this->gameScene, $session), + ] as $step => $cleanup) { + try { + $cleanup(); + } catch (Throwable $failure) { + $diagnostics[] = sprintf('Event session %d failure cleanup (%s): %s', $session->id, $step, $failure->getMessage()); + } } $this->lastSession = $session; $this->activeSession = null; - $this->gameScene->onEventSessionFinished($session, false); + try { + $this->gameScene->onEventSessionFinished($session, false); + } catch (Throwable $failure) { + $diagnostics[] = sprintf('Event session %d failure cleanup (finish scene): %s', $session->id, $failure->getMessage()); + } + + foreach ($diagnostics as $diagnostic) { + try { + Debug::error($diagnostic); + } catch (Throwable $logFailure) { + // A failed project log must neither interrupt cleanup nor conceal its errors. + try { + if (!error_log($diagnostic . ' [Project logger failed: ' . $logFailure->getMessage() . ']')) { + throw new RuntimeException('The fallback error log was unavailable.'); + } + } catch (Throwable $fallbackFailure) { + throw $throwable ?? new RuntimeException($message, previous: $fallbackFailure); + } + } + } } /** @param array> $conditions The condition entries. */ diff --git a/src/Events/Interpreter/EventParallelGroup.php b/src/Events/Interpreter/EventParallelGroup.php index b39d739b..19863131 100644 --- a/src/Events/Interpreter/EventParallelGroup.php +++ b/src/Events/Interpreter/EventParallelGroup.php @@ -76,8 +76,18 @@ public function isComplete(): bool public function cancel(): void { + $failures = []; foreach ($this->lanes as $lane) { - $lane->cancel(); + try { + $lane->cancel(); + } catch (\Throwable $failure) { + $failures[] = $failure; + } + } + if ($failures !== []) { + throw new \RuntimeException('Parallel cancellation failed: ' + . implode('; ', array_map(static fn(\Throwable $failure): string => $failure->getMessage(), $failures)), + previous: $failures[0]); } } } diff --git a/src/Events/Interpreter/MovementRoutePlanner.php b/src/Events/Interpreter/MovementRoutePlanner.php new file mode 100644 index 00000000..e34fca40 --- /dev/null +++ b/src/Events/Interpreter/MovementRoutePlanner.php @@ -0,0 +1,71 @@ + */ + public static function plan(GameScene $scene, Vector2 $origin, Vector2 $target, bool $movingNpc): array + { + $width = $scene->camera->worldSpaceWidth; + $height = $scene->camera->worldSpaceHeight; + foreach ([$origin, $target] as $position) { + if ($width < 1 || $height < 1 || !is_finite($position->x) || !is_finite($position->y) + || floor($position->x) !== $position->x || floor($position->y) !== $position->y + || $position->x < 0 || $position->x >= $width || $position->y < 0 || $position->y >= $height) { + throw new MovementRouteException('Movement-route origin and waypoint must be integral cells inside the current map.'); + } + } + $start = intval($origin->y) * $width + intval($origin->x); + $end = intval($target->y) * $width + intval($target->x); + $queue = new SplQueue(); + $queue->enqueue($start); + $previous = [$start => -1]; + while (!$queue->isEmpty() && !array_key_exists($end, $previous)) { + $current = $queue->dequeue(); + $x = $current % $width; + $y = intdiv($current, $width); + foreach ([[0, -1], [0, 1], [-1, 0], [1, 0]] as [$dx, $dy]) { + $nx = $x + $dx; + $ny = $y + $dy; + $key = $ny * $width + $nx; + if ($nx < 0 || $nx >= $width || $ny < 0 || $ny >= $height || array_key_exists($key, $previous) + || !$scene->mapManager->canMoveTo($nx, $ny) + || ($movingNpc && $scene->player !== null + && intval($scene->player->position->x) === $nx && intval($scene->player->position->y) === $ny)) { + continue; + } + if (count($previous) >= self::MAX_VISITED_CELLS) { + throw new MovementRouteException('Movement-route search exceeded its cell budget; add nearer authored waypoints.'); + } + $previous[$key] = $current; + if ($key === $end) { + break; + } + $queue->enqueue($key); + } + } + if (!array_key_exists($end, $previous)) { + throw new MovementRouteException(sprintf('No walkable route on map "%s" from (%d, %d) to waypoint (%d, %d).', + $scene->currentMapId, $origin->x, $origin->y, $target->x, $target->y)); + } + $steps = []; + for ($key = $end; $key !== $start; $key = $previous[$key]) { + $parent = $previous[$key]; + $steps[] = ['direction' => match (true) { + intdiv($key, $width) < intdiv($parent, $width) => 'up', + intdiv($key, $width) > intdiv($parent, $width) => 'down', + $key % $width < $parent % $width => 'left', + default => 'right', + }]; + } + return array_reverse($steps); + } +} diff --git a/src/Events/Interpreter/MovementRouteRecord.php b/src/Events/Interpreter/MovementRouteRecord.php new file mode 100644 index 00000000..dba8c50a --- /dev/null +++ b/src/Events/Interpreter/MovementRouteRecord.php @@ -0,0 +1,59 @@ + */ + private array $inverse = []; + private Vector2 $endpoint; + public bool $complete = false; + private bool $retraced = false; + private readonly MovementHeading $heading; + private readonly array|string $sprite; + + public function __construct(public readonly string $mapId, public readonly Player|Npc $subject, + private readonly int $generation) + { + $this->endpoint = clone $subject->position; + $this->heading = $subject->heading; + $this->sprite = $subject->sprite; + } + + public function record(string $direction): void + { + $this->inverse[] = ['direction' => match ($direction) { + 'up' => 'down', 'down' => 'up', 'left' => 'right', 'right' => 'left', + }]; + $this->endpoint = clone $this->subject->position; + } + + /** @return list> */ + public function retrace(string $mapId, Player|Npc $subject, int $generation): array + { + if (!$this->complete || $this->retraced || $this->mapId !== $mapId || $this->subject !== $subject + || $this->generation !== $generation + || $subject->position->x !== $this->endpoint->x || $subject->position->y !== $this->endpoint->y) { + throw new MovementRouteException('A recorded route can only be retraced once, after completion, by its original subject from its endpoint on the same map.'); + } + $this->retraced = true; + return array_reverse($this->inverse); + } + + public function restoreFacing(): void + { + // Keep the position reached by walking; restore even a directionless idle pose. + if ($this->subject instanceof Player) { + $this->subject->restoreFieldTransform(clone $this->subject->position, $this->heading, (array)$this->sprite); + } else { + $this->subject->restoreFieldTransform(clone $this->subject->position, $this->heading, strval($this->sprite)); + } + } +} diff --git a/src/Events/Interpreter/MovementRouteRunner.php b/src/Events/Interpreter/MovementRouteRunner.php index 95faeb32..e1265bc1 100644 --- a/src/Events/Interpreter/MovementRouteRunner.php +++ b/src/Events/Interpreter/MovementRouteRunner.php @@ -3,7 +3,10 @@ namespace Ichiloto\Engine\Events\Interpreter; use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\Cutscenes\Cinematics\StagedActor; use Ichiloto\Engine\Exceptions\MovementRouteException; +use Ichiloto\Engine\Field\Npc; +use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\UI\Accessibility; @@ -20,6 +23,14 @@ final class MovementRouteRunner implements EventPendingOperationInterface protected int $completedRepeats = 0; protected float $remainingDelay = 0.0; protected(set) bool $isComplete = false; + /** @var list */ + private array $waypoints = []; + private Player|Npc|StagedActor $routeSubject; + private Vector2 $expectedPosition; + private string $mapId; + private int $generation; + private ?MovementRouteRecord $record = null; + private ?MovementRouteRecord $returnRecord = null; /** * @param array $command The authored `move_route` command. @@ -27,10 +38,41 @@ final class MovementRouteRunner implements EventPendingOperationInterface public function __construct( protected GameScene $gameScene, protected array $command, + private readonly ?EventExecutionSession $session = null, ) { $this->steps = array_values(array_filter((array) ($command['steps'] ?? []), is_array(...))); $this->validate(); + $this->routeSubject = $this->resolveSubject() + ?? throw new MovementRouteException(sprintf( + 'Movement-route %s is not available on map "%s".', + $this->subjectLabel(), + $gameScene->currentMapId, + )); + $this->expectedPosition = clone $this->routeSubject->position; + $this->mapId = $gameScene->currentMapId; + $this->generation = $gameScene->cinematicStage?->generation ?? 0; + if (isset($command['remember']) || isset($command['retrace'])) { + if ($session?->cinematic === null || $this->routeSubject instanceof StagedActor) { + throw new MovementRouteException('Recorded routes require a real subject in an active cinematic session.'); + } + if (isset($command['retrace'])) { + $this->returnRecord = $session->movementRoute($command['retrace']); + $this->steps = $this->returnRecord->retrace( + $this->mapId, $this->routeSubject, $this->generation); + } else { + $this->record = new MovementRouteRecord($this->mapId, $this->routeSubject, $this->generation); + $session->rememberMovementRoute($command['remember'], $this->record); + } + } + $this->waypoints = $command['waypoints'] ?? []; + if ($this->waypoints !== []) { + $this->prepareNextLeg(); + } + $session?->claimMovementSubject($this->routeSubject); + if ($session?->cinematic !== null && !$this->routeSubject instanceof StagedActor) { + $gameScene->cinematicStage?->captureSubject($this->routeSubject); + } } /** @@ -46,7 +88,8 @@ public function update(float $deltaSeconds): bool } if (Accessibility::prefersReducedMotion()) { - $maximumUnits = 1; + $maximumUnits = 1 + count($this->waypoints) + * MovementRoutePlanner::MAX_VISITED_CELLS; foreach ($this->steps as $step) { $maximumUnits += max(1, intval($step['count'] ?? 1)); @@ -76,10 +119,12 @@ protected function advanceVisibleUnit(): bool return true; } + $this->assertSubjectCurrent(); + $step = $this->steps[$this->stepIndex] ?? null; if (! is_array($step)) { - $this->isComplete = true; + $this->finish(); return true; } @@ -113,6 +158,16 @@ protected function advanceVisibleUnit(): bool $this->throwBlockedRoute($subject, $directionName, $direction); } + $this->assertSubjectCurrent(checkPosition: false); + if (!$faceOnly) { + $expected = Vector2::sum($this->expectedPosition, $direction); + if ($this->routeSubject->position->x !== $expected->x || $this->routeSubject->position->y !== $expected->y) { + throw new MovementRouteException('Movement-route subject was displaced during a step.'); + } + $this->expectedPosition = $expected; + $this->record?->record($directionName); + } + $this->completedRepeats++; if ($this->completedRepeats >= $count || $faceOnly) { @@ -126,11 +181,108 @@ protected function advanceVisibleUnit(): bool public function cancel(): void { + if ($this->isComplete) { + return; + } $this->isComplete = true; + $this->session?->releaseMovementSubject($this->routeSubject); + } + + private function finish(): void + { + if ($this->returnRecord !== null) { + $this->returnRecord->restoreFacing(); + $this->gameScene->requestFieldPresentationReconciliation(); + } + if ($this->record !== null) { + $this->record->complete = true; + } + $this->cancel(); + } + + private function resolveSubject(): Player|Npc|StagedActor|null + { + return match (strtolower(trim(strval($this->command['subject'] ?? 'player')))) { + 'npc' => $this->gameScene->npcManager?->findById(strval($this->command['npcId'] ?? '')), + 'staged_actor' => $this->gameScene->cinematicStage?->find(strval($this->command['actorId'] ?? '')), + default => $this->gameScene->player, + }; + } + + private function subjectLabel(): string + { + return match (strtolower(trim(strval($this->command['subject'] ?? 'player')))) { + 'npc' => sprintf('NPC "%s"', strval($this->command['npcId'] ?? '')), + 'staged_actor' => sprintf('staged actor "%s"', strval($this->command['actorId'] ?? '')), + default => 'player', + }; + } + + private function assertSubjectCurrent(bool $checkPosition = true): void + { + if ($this->mapId !== $this->gameScene->currentMapId || $this->resolveSubject() !== $this->routeSubject + || $this->generation !== ($this->gameScene->cinematicStage?->generation ?? 0) + || ($checkPosition && ($this->routeSubject->position->x !== $this->expectedPosition->x + || $this->routeSubject->position->y !== $this->expectedPosition->y))) { + throw new MovementRouteException('Movement-route subject, map or position changed outside its owned route.'); + } + } + + private function prepareNextLeg(): void + { + $this->steps = []; + $this->stepIndex = 0; + while ($this->steps === [] && $this->waypoints !== []) { + $point = array_shift($this->waypoints); + $target = new Vector2($point['x'] ?? $this->routeSubject->position->x, + $point['y'] ?? $this->routeSubject->position->y); + $this->steps = MovementRoutePlanner::plan($this->gameScene, $this->routeSubject->position, + $target, $this->routeSubject instanceof Npc); + } + } + + /** Shared extension validation for runtime and cinematic authoring. */ + public static function validatePathOptions(array $command): void + { + $modes = array_intersect(['steps', 'waypoints', 'retrace'], array_keys($command)); + if (count($modes) !== 1) { + throw new MovementRouteException('Movement route requires exactly one of steps, waypoints or retrace.'); + } + foreach (['remember', 'retrace'] as $field) { + if (array_key_exists($field, $command) && (!is_string($command[$field]) + || preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $command[$field]) !== 1)) { + throw new MovementRouteException("Movement route $field requires a safe stable id."); + } + } + if (isset($command['remember'], $command['retrace'])) { + throw new MovementRouteException('A retraced route cannot also be remembered.'); + } + if ((isset($command['waypoints']) || isset($command['remember']) || isset($command['retrace'])) + && strtolower(trim(strval($command['subject'] ?? 'player'))) === 'staged_actor') { + throw new MovementRouteException('Waypoints and recorded routes require a real player or NPC subject.'); + } + if (!array_key_exists('waypoints', $command)) { + return; + } + $points = $command['waypoints']; + if (!is_array($points) || !array_is_list($points) || $points === []) { + throw new MovementRouteException('Movement-route waypoints must be a non-empty list.'); + } + foreach ($points as $point) { + if (!is_array($point) || $point === [] || array_diff(array_keys($point), ['x', 'y']) !== []) { + throw new MovementRouteException('Each movement-route waypoint requires x and/or y.'); + } + foreach ($point as $value) { + if (!is_int($value) || $value < 0) { + throw new MovementRouteException('Movement-route waypoint coordinates must be non-negative integers.'); + } + } + } } protected function validate(): void { + self::validatePathOptions($this->command); $wait = $this->command['wait'] ?? true; if (! is_bool($wait) || ! $wait) { @@ -151,6 +303,10 @@ protected function validate(): void throw new MovementRouteException('A staged-actor movement route requires a stable actorId.'); } + $this->stepDelay([]); + if (!array_key_exists('steps', $this->command)) { + return; + } if ($this->steps === []) { throw new MovementRouteException('A movement route requires at least one step.'); } @@ -193,7 +349,7 @@ protected function stepDelay(array $step): float $seconds = $step['seconds'] ?? $this->command['secondsPerStep'] ?? null; if ($seconds !== null) { - if (! is_numeric($seconds) || floatval($seconds) < 0.0) { + if (! is_numeric($seconds) || !is_finite(floatval($seconds)) || floatval($seconds) < 0.0) { throw new MovementRouteException('Movement-route timing must be zero or a positive number of seconds.'); } @@ -203,7 +359,7 @@ protected function stepDelay(array $step): float $speed = $this->command['speed'] ?? null; if ($speed !== null) { - if (! is_numeric($speed) || floatval($speed) <= 0.0) { + if (! is_numeric($speed) || !is_finite(floatval($speed)) || floatval($speed) <= 0.0) { throw new MovementRouteException('Movement-route speed must be greater than zero.'); } @@ -217,7 +373,14 @@ protected function advanceStep(): void { $this->stepIndex++; $this->completedRepeats = 0; - $this->isComplete = $this->stepIndex >= count($this->steps); + if ($this->stepIndex >= count($this->steps)) { + if ($this->waypoints !== []) { + $this->prepareNextLeg(); + } + if ($this->stepIndex >= count($this->steps)) { + $this->finish(); + } + } } protected function throwBlockedRoute(string $subject, string $directionName, Vector2 $direction): never @@ -229,11 +392,6 @@ protected function throwBlockedRoute(string $subject, string $directionName, Vec }; $attemptedX = intval($position?->x ?? 0) + intval($direction->x); $attemptedY = intval($position?->y ?? 0) + intval($direction->y); - $subjectLabel = match ($subject) { - 'npc' => sprintf('NPC "%s"', strval($this->command['npcId'] ?? '')), - 'staged_actor' => sprintf('staged actor "%s"', strval($this->command['actorId'] ?? '')), - default => 'player', - }; $blockerDetail = $subject === 'staged_actor' ? $this->gameScene->cinematicStage?->lastMoveFailure : null; @@ -241,7 +399,7 @@ protected function throwBlockedRoute(string $subject, string $directionName, Vec throw new MovementRouteException(sprintf( 'Movement route failed on map "%s": %s step %d (%s) was blocked at (%d, %d).%s', $this->gameScene->currentMapId, - $subjectLabel, + $this->subjectLabel(), $this->stepIndex + 1, $directionName, $attemptedX, diff --git a/src/Field/EncounterManager.php b/src/Field/EncounterManager.php index 96562502..6b555f28 100644 --- a/src/Field/EncounterManager.php +++ b/src/Field/EncounterManager.php @@ -16,6 +16,7 @@ * 'encounters' => [ * 'troops' => ['Bat x 2' => 5, 'Rat + Bat' => 3], // name => weight * 'rate' => 12, // average steps between encounters + * 'battleArena' => 'arena.forest', // optional graphical arena catalog key * 'tiles' => 'encounter' // 'encounter' (default): only danger tiles count; 'any': every step counts * ], * ``` @@ -30,13 +31,12 @@ */ class EncounterManager { - protected const int PREEMPTIVE_CHANCE_PERCENT = 5; - protected const int AMBUSH_CHANCE_PERCENT = 5; - /** * @var array Troop weights, keyed by troop name. */ protected array $troopWeights = []; + /** @var array Optional presentation selection, validated only by the graphical renderer path. */ + private array $presentationSettings = []; /** * @var int Average steps between encounters; 0 disables the map. */ @@ -72,6 +72,7 @@ public function __construct(protected GameScene $gameScene) public function configure(?array $encounters): void { $this->troopWeights = []; + $this->presentationSettings = array_intersect_key($encounters ?? [], ['battleArena' => true]); $this->averageStepsBetween = 0; $this->countsEveryTile = strval($encounters['tiles'] ?? 'encounter') === 'any'; @@ -144,16 +145,10 @@ protected function startEncounter(): void return; } - $firstStrike = match (true) { - rand(1, 100) <= self::PREEMPTIVE_CHANCE_PERCENT => 'party', - rand(1, 100) <= self::AMBUSH_CHANCE_PERCENT => 'troop', - default => null, - }; - $this->gameScene->sceneManager->loadBattleScene( $this->gameScene->party, $troop, - extraSettings: $firstStrike !== null ? ['firstStrike' => $firstStrike] : [] + extraSettings: $this->presentationSettings ); } diff --git a/src/Field/Npc.php b/src/Field/Npc.php index 59cc25d0..fd71415b 100644 --- a/src/Field/Npc.php +++ b/src/Field/Npc.php @@ -43,7 +43,7 @@ class Npc implements EventSessionCompletionTargetInterface */ public float $nextWanderTime = 0.0; - protected MovementHeading $heading = MovementHeading::SOUTH; + protected(set) MovementHeading $heading = MovementHeading::SOUTH; /** @var array> Variant writes awaiting script completion. */ protected array $pendingConversationSets = []; @@ -144,6 +144,15 @@ public function face(Vector2 $direction): void } } + /** Restore staging only; eligibility and conversation/story state remain live. */ + public function restoreFieldTransform(Vector2 $position, MovementHeading $heading, string $sprite): void + { + $this->position->x = $position->x; + $this->position->y = $position->y; + $this->heading = $heading; + $this->sprite = $sprite; + } + /** * Starts the asynchronous script portion of an NPC conversation. * diff --git a/src/Field/NpcManager.php b/src/Field/NpcManager.php index d658a3b1..528ebf5c 100644 --- a/src/Field/NpcManager.php +++ b/src/Field/NpcManager.php @@ -129,6 +129,13 @@ public function update(): void public function render(): void { foreach ($this->visibleNpcs() as $npc) { + $this->renderNpc($npc); + } + } + + protected function renderNpc(Npc $npc): void + { + if ($this->conditionsHold($npc->conditions) && !($this->gameScene->cinematicStage?->suppresses($npc) ?? false)) { $this->gameScene->camera->renderOnScreen([$npc->sprite], $npc->position); } } @@ -196,6 +203,7 @@ public function moveNpcById(string $id, Vector2 $direction): bool && intval($player->position->x) === $destinationX && intval($player->position->y) === $destinationY) ) { + $this->gameScene->cinematicStage?->subjectStopped($npc); return false; } @@ -203,7 +211,8 @@ public function moveNpcById(string $id, Vector2 $direction): bool $npc->face($direction); $npc->position->x = $destinationX; $npc->position->y = $destinationY; - $this->gameScene->camera->renderOnScreen([$npc->sprite], $npc->position); + $this->gameScene->cinematicStage?->subjectMoved($npc); + $this->renderNpc($npc); return true; } @@ -225,7 +234,8 @@ public function faceNpc(string $id, Vector2 $direction): bool $this->eraseNpc($npc); $npc->face($direction); - $this->gameScene->camera->renderOnScreen([$npc->sprite], $npc->position); + $this->gameScene->cinematicStage?->subjectStopped($npc); + $this->renderNpc($npc); return true; } @@ -272,7 +282,8 @@ protected function takeWanderStep(Npc $npc): void $npc->face(new Vector2($dx, $dy)); $npc->position->x = $destinationX; $npc->position->y = $destinationY; - $this->gameScene->camera->renderOnScreen([$npc->sprite], $npc->position); + $this->gameScene->cinematicStage?->subjectMoved($npc); + $this->renderNpc($npc); } /** @@ -287,6 +298,9 @@ protected function takeWanderStep(Npc $npc): void */ protected function eraseNpc(Npc $npc): void { + if ($this->gameScene->cinematicStage?->suppresses($npc) ?? false) { + return; + } $columns = max(1, TerminalText::displayWidth($npc->sprite)); for ($column = 0; $column < $columns; $column++) { diff --git a/src/Field/Player.php b/src/Field/Player.php index bb24bbd0..92132df9 100644 --- a/src/Field/Player.php +++ b/src/Field/Player.php @@ -150,6 +150,9 @@ public function getGraphicalSpriteId(): string #[Override] public function getGraphicalSpriteDefinition(): ?GraphicalSpriteDefinition { + if ($this->isPresentationSuppressed()) { + return null; + } $definition = $this->graphicalSprites?->getForHeading($this->heading); return $definition === null ? null : ($this->walkAnimation?->present($definition) ?? $definition); } @@ -162,6 +165,24 @@ public function advanceGraphicalAnimation(float $seconds): void public function stopGraphicalAnimation(): void { $this->walkAnimation?->stop(); + if (($this->scene ?? null) instanceof GameScene) { + $this->scene->cinematicStage?->subjectStopped($this); + } + } + + private function isPresentationSuppressed(): bool + { + return ($this->scene ?? null) instanceof GameScene && ($this->scene->cinematicStage?->suppresses($this) ?? false); + } + + /** Restore owned temporary staging without movement triggers or outcome writes. */ + public function restoreFieldTransform(Vector2 $position, MovementHeading $heading, array $sprite): void + { + $this->position->x = $position->x; + $this->position->y = $position->y; + $this->sprite = $sprite; + $this->heading = $heading; + $this->stopGraphicalAnimation(); } #[Override] @@ -246,6 +267,9 @@ public function tryMove(Vector2 $direction, Camera $camera): bool && ($origin->x !== $this->position->x || $origin->y !== $this->position->y)) { $this->walkAnimation->step($this->graphicalSprites->getForHeading($this->heading)); } + if ($origin->x !== $this->position->x || $origin->y !== $this->position->y) { + $this->getGameScene()->cinematicStage?->subjectMoved($this); + } $this->handleTriggers($event); $this->getGameScene()->encounterManager?->registerStep($collisionType); @@ -795,6 +819,9 @@ public function reconcileActiveEventState(): void */ public function render(): void { + if ($this->isPresentationSuppressed()) { + return; + } Console::withLayer($this->getGraphicalSpriteId(), function (): void { $this->scene->camera->renderAtScreenPosition($this->sprite, $this->screenPosition); }); @@ -810,6 +837,9 @@ public function render(): void public function renderPlayer(?Vector2 $offset = null): void { + if ($this->isPresentationSuppressed()) { + return; + } $worldPosition = new Vector2( $this->position->x - ($offset?->x ?? 0), $this->position->y - ($offset?->y ?? 0) @@ -831,6 +861,9 @@ public function renderPlayer(?Vector2 $offset = null): void */ public function erase(): void { + if ($this->isPresentationSuppressed()) { + return; + } $this->eraseSpriteFootprint($this->position, $this->sprite); if ($this->canAct) { @@ -847,6 +880,9 @@ public function erase(): void */ public function erasePlayer(Camera $camera, ?array $sprite = null): void { + if ($this->isPresentationSuppressed()) { + return; + } $sprite ??= $this->sprite; $this->eraseSpriteFootprint($this->position, $sprite); diff --git a/src/IO/ActionHint.php b/src/IO/ActionHint.php new file mode 100644 index 00000000..d845d616 --- /dev/null +++ b/src/IO/ActionHint.php @@ -0,0 +1,20 @@ +controlForAction($action); + // An owner can declare a real retained keyboard alias, never an invented general fallback. + if ($control === null && $provider instanceof InputBindings && $keyboardAlias !== null) { + $control = ControlHint::keyboard($keyboardAlias); + } + return new ActionHint($action, $label, $control); + } +} diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index 8902551d..0d7f16b4 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -280,6 +280,67 @@ public static function isComposing(): bool return self::$frameDepth !== 0 || self::$isRecomposing; } + /** Release live writes without erasing later scene writes or another owner's cells. */ + public static function removeLayer(string $id, bool $repaint = true): void + { + if (!isset(self::$layerPriorities[$id])) { return; } + $before = self::visibleCellRows(); + $saved = [self::$buffer, self::$layerCells, self::$layerPriorities, self::$frameRows, + self::$frameDepth, self::$recomposeRepaintRows]; + $emit = $repaint && self::$terminalOutputEnabled && !self::$terminalHandedBack && !self::$isRecomposing; + if ($emit) { self::beginFrame(); } + try { + unset(self::$layerPriorities[$id]); + foreach (self::$layerCells as $row => &$entries) { + $affected = false; + foreach ($entries as &$entry) { + if (array_key_exists($id, $entry['layers'])) { unset($entry['layers'][$id]); $affected = true; } + } + unset($entry); + if (!$affected || !isset(self::$buffer[$row])) { continue; } + $cells = self::$buffer[$row]; + foreach ($entries as $x => $entry) { + $cells[$x] = $entry['base']; + foreach ($entry['layers'] as $cell) { $cells[$x] = $cell; } + } + // A newer write can cover only one half of a retained wide underlay. + // Drop visible fragments, retaining provenance so removing that owner restores it. + self::$buffer[$row] = self::wholeGlyphCells($cells); + } + unset($entries); + if ($emit) { + $after = self::visibleCellRows(); + foreach ($after as $row => $cells) { + foreach (self::changedCellSpansFromCells($before[$row] ?? [], $cells) as $span) { + self::writeBufferRow($row, $span['start'], $span['end'] - $span['start'] + 1); + } + } + self::endFrame(); + } + } catch (\Throwable $exception) { + [self::$buffer, self::$layerCells, self::$layerPriorities, self::$frameRows, + self::$frameDepth, self::$recomposeRepaintRows] = $saved; + throw $exception; + } + } + + /** Retained underlays may be partly occluded by a newer owner. Never display fragments. */ + private static function wholeGlyphCells(array $cells): array + { + foreach ($cells as $x => $cell) { + if ($cell === self::WIDE_SYMBOL_CONTINUATION) { + $anchor = self::resolveCellAnchor($cells, $x); + if ($anchor === null || $anchor + TerminalText::getSymbolWidth($cells[$anchor]) <= $x) { $cells[$x] = ' '; } + continue; + } + $width = TerminalText::getSymbolWidth($cell); + for ($offset = 1; $offset < $width; $offset++) { + if (($cells[$x + $offset] ?? null) !== self::WIDE_SYMBOL_CONTINUATION) { $cells[$x] = ' '; break; } + } + } + return $cells; + } + /** @param string[] $before @param string[] $after */ private static function recordLayerWrite(int $row, int $start, int $end, array $before, array $after): void { @@ -609,7 +670,7 @@ public static function recomposeFrame(callable $renderer, bool $forceFullRepaint // are blanked while stable rows are not needlessly repainted. self::$frameRows = []; $visibleBuffer = self::visibleCellRows(); - $diffStart = LatencyTrace::now(); + $diffStart = LatencyTrace::getTimeNow(); if (self::$terminalOutputEnabled && $forceFullRepaint) { for ($row = 0; $row < self::$height; $row++) { @@ -881,7 +942,7 @@ public static function writeNormalizedRow(NormalizedRow $row, int|float $x, int| $length = count($incoming); if ($length === 0) { return; } - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); $before = self::$buffer[$y] ?? array_fill(0, self::$width, ' '); $visibleBefore = self::$overlays === [] || self::$isRecomposing || !self::$terminalOutputEnabled ? null : self::compositeOverlayRow($before, $y); @@ -898,7 +959,7 @@ public static function writeNormalizedRow(NormalizedRow $row, int|float $x, int| LatencyTrace::end('terminal.compose', $started); if (!self::$terminalOutputEnabled || self::$isRecomposing) { return; } - $diffStart = LatencyTrace::now(); + $diffStart = LatencyTrace::getTimeNow(); $spans = self::changedCellSpansFromCells($visibleBefore ?? $before, $visibleBefore === null ? $after : self::compositeOverlayRow($after, $y)); LatencyTrace::end('terminal.diff', $diffStart); @@ -971,6 +1032,7 @@ public static function snapshot(array $excludedLayers = []): ConsoleFrameSnapsho } } } + $cells = self::wholeGlyphCells($cells); $cells = self::compositeOverlayRow($cells, $y, $excluded); foreach ($cells as &$cell) { if ($cell === self::WIDE_SYMBOL_CONTINUATION) { @@ -992,7 +1054,7 @@ public static function snapshot(array $excludedLayers = []): ConsoleFrameSnapsho */ public static function presentationSnapshot(array $excludedLayers = [], array $replacedLayerCells = []): ConsolePresentationSnapshot { - $cellsStart = LatencyTrace::now(); + $cellsStart = LatencyTrace::getTimeNow(); if (self::isComposing()) { throw new RuntimeException('Cannot snapshot Console while a frame or screen recomposition is active.'); } @@ -1024,6 +1086,26 @@ public static function presentationSnapshot(array $excludedLayers = [], array $r $priorities[$id] = $priority; } } + // Live layers also cover whole glyphs, including when a removed layer exposes + // a wide underlay beside a newer scene write. Only the snapshot copy is masked. + foreach ($planes as $id => $rows) { + foreach ($rows as $row => $cells) { $planes[$id][$row] = self::wholeGlyphCells($cells); } + } + foreach ($named as $id => $rows) { + foreach ($rows as $row => $cells) { + foreach ($planes as $lowerId => &$plane) { + if ($lowerId === $id) { break; } + if (!isset($plane[$row])) { continue; } + foreach (array_keys($cells) as $column) { + $anchor = self::resolveCellAnchor($plane[$row], $column); + if ($anchor !== null && TerminalText::getSymbolWidth($plane[$row][$anchor] ?? ' ') > 1) { + self::clearCellRange($plane[$row], $anchor, 1); + } + } + } + unset($plane); + } + } foreach (self::$overlays as $id => $overlay) { if (isset($excluded[$id])) { continue; } $rows = []; @@ -1055,7 +1137,7 @@ public static function presentationSnapshot(array $excludedLayers = [], array $r /** @param array> $rows @return list */ private static function presentationRuns(array $rows): array { - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); $parsing = 0; $runs = $cache = []; foreach ($rows as $y => $cells) { @@ -1069,9 +1151,9 @@ private static function presentationRuns(array $rows): array $glyph = ' '; } else { if (!isset($cache[$cell])) { - $parseStart = LatencyTrace::now(); + $parseStart = LatencyTrace::getTimeNow(); $cache[$cell] = [SgrColorParser::parse($cell), TerminalText::rendererScalar($cell)]; - if ($parseStart !== null) { $parsing += LatencyTrace::now() - $parseStart; } + if ($parseStart !== null) { $parsing += LatencyTrace::getTimeNow() - $parseStart; } } $parsed = $cache[$cell]; [$next, $glyph] = $parsed; @@ -1281,7 +1363,7 @@ public static function endFrame(): void return; } - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); ksort(self::$frameRows, SORT_NUMERIC); $payload = ''; @@ -1815,7 +1897,7 @@ public static function select( * @param string $message The message to show. * @param string $title The title of the dialog. Defaults to "". * @param string $help The help text to show. Defaults to "". - * @param WindowPosition $position The position of the dialog. Defaults to BOTTOM (i.e. the bottom of the screen). + * @param WindowPosition|null $position Explicit position, or top for narration and bottom for speech. * @param float $charactersPerSecond The number of characters to display per second. * @return void */ @@ -1823,7 +1905,7 @@ public static function showText( string $message, string $title = '', string $help = '', - WindowPosition $position = WindowPosition::BOTTOM, + ?WindowPosition $position = null, float $charactersPerSecond = 1 ): void { diff --git a/src/IO/Console/NormalizedRow.php b/src/IO/Console/NormalizedRow.php index 580031dc..c9a7b776 100644 --- a/src/IO/Console/NormalizedRow.php +++ b/src/IO/Console/NormalizedRow.php @@ -30,7 +30,7 @@ public static function fromText(string $text, ?int $width = null): self /** @param list $symbols Already separated authoring symbols, not a row string. */ public static function fromSymbols(array $symbols): self { - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); $cells = $offsets = []; foreach ($symbols as $symbol) { $offsets[] = count($cells); diff --git a/src/IO/ControlHint.php b/src/IO/ControlHint.php new file mode 100644 index 00000000..a7efccf3 --- /dev/null +++ b/src/IO/ControlHint.php @@ -0,0 +1,36 @@ +family . '.' . $this->control; + } + + public static function keyboard(KeyCode $key): self + { + $label = $key === KeyCode::SHIFT_TAB ? 'Shift+Tab' + : (strlen($key->name) === 1 ? $key->name : str_replace('_', ' ', ucwords(strtolower($key->name), '_'))); + return new self('keyboard', $key->name, $label); + } +} diff --git a/src/IO/InputBindings.php b/src/IO/InputBindings.php index ce58454b..469a54e0 100644 --- a/src/IO/InputBindings.php +++ b/src/IO/InputBindings.php @@ -17,7 +17,7 @@ * * @package Ichiloto\Engine\IO */ -class InputBindings +class InputBindings implements ActionHintProvider { /** * Actions the player must not rebind. @@ -65,6 +65,24 @@ public function describeKeys(string $action): string return $names === [] ? 'Unbound' : implode(', ', $names); } + /** Compact hints prefer familiar controls only when they are currently bound. */ + public function primaryKey(string $action): ?KeyCode + { + $keys = $this->keysOf(InputManager::getBindings()[$action] ?? []); + $preferred = match ($action) { + 'confirm' => KeyCode::ENTER, + 'cancel', 'back' => KeyCode::ESCAPE, + default => null, + }; + return $preferred !== null && in_array($preferred, $keys, true) ? $preferred : ($keys[0] ?? null); + } + + public function controlForAction(string $action): ?ControlHint + { + $key = $this->primaryKey($action); + return $key === null ? null : ControlHint::keyboard($key); + } + /** * Binds a key to an action, replacing whatever it had. * diff --git a/src/IO/InputManager.php b/src/IO/InputManager.php index eb2ffd2c..7c336836 100644 --- a/src/IO/InputManager.php +++ b/src/IO/InputManager.php @@ -44,7 +44,7 @@ class InputManager */ protected static array $config = []; /** - * @var array The bindings as the project authored them, captured at boot so + * @var array The effective project and Engine defaults, captured at boot so * a player who rebinds their way into a corner can get back out. */ protected static array $defaultConfig = []; @@ -61,7 +61,7 @@ public static function init(Game $game): void self::resetState(); $inputConfig = ConfigStore::get(InputConfig::class); assert($inputConfig instanceof InputConfig); - self::$config = $inputConfig->all(); + self::setBindings($inputConfig->all()); self::$defaultConfig = self::$config; } @@ -76,7 +76,7 @@ public static function getBindings(): array } /** - * Returns the bindings the project shipped with. + * Returns the project bindings with missing Engine actions supplied at boot. * * @return array The default bindings. */ @@ -111,7 +111,26 @@ public static function setBinding(string $action, array $keys): bool */ public static function setBindings(array $bindings): void { - self::$config = $bindings; + self::$config = self::getBindingsWithDefaults($bindings); + } + + /** + * Adds the menu Info action for older projects without claiming authored controls. + * + * @param array $bindings + * @return array + */ + private static function getBindingsWithDefaults(array $bindings): array + { + if (array_key_exists('info', $bindings)) { + return $bindings; + } + + $keys = array_values(array_filter([KeyCode::i, KeyCode::I], static fn(KeyCode $key): bool => + !array_any($bindings, static fn(array $binding): bool => in_array($key, $binding['keys'] ?? [], true)))); + $bindings['info'] = ['description' => 'Read the next Info page; wrap to the first.', 'keys' => $keys]; + + return $bindings; } /** diff --git a/src/IO/InputSources/TerminalInputSource.php b/src/IO/InputSources/TerminalInputSource.php index cedebc3d..42b52086 100644 --- a/src/IO/InputSources/TerminalInputSource.php +++ b/src/IO/InputSources/TerminalInputSource.php @@ -27,7 +27,7 @@ public function __construct($stream = null) public function poll(): ?KeyCode { LatencyTrace::beginPoll(); - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); $key = $this->nonBlocking(fn() => self::normalize($this->readInputSequence())); if ($key !== null) { LatencyTrace::returned($key->value, self::class, $started); } return $key; diff --git a/src/Messaging/Dialogue/Dialogue.php b/src/Messaging/Dialogue/Dialogue.php index 54483f0d..4df58ed7 100644 --- a/src/Messaging/Dialogue/Dialogue.php +++ b/src/Messaging/Dialogue/Dialogue.php @@ -41,7 +41,7 @@ public function show(): void $this->text, $this->name, '', - config(ProjectConfig::class, 'ui.dialogue.window.position', WindowPosition::BOTTOM), + config(ProjectConfig::class, 'ui.dialogue.window.position', null), config( ProjectConfig::class, 'ui.dialogue.speed', diff --git a/src/Messaging/Dialogue/Presentation/DialoguePresentationCatalog.php b/src/Messaging/Dialogue/Presentation/DialoguePresentationCatalog.php new file mode 100644 index 00000000..8a8df2ab --- /dev/null +++ b/src/Messaging/Dialogue/Presentation/DialoguePresentationCatalog.php @@ -0,0 +1,13 @@ + */ + public array $learnedDetails = [], ) { } diff --git a/src/Progression/ExperienceAwarder.php b/src/Progression/ExperienceAwarder.php index d01b8572..8053d9b0 100644 --- a/src/Progression/ExperienceAwarder.php +++ b/src/Progression/ExperienceAwarder.php @@ -22,7 +22,8 @@ public static function award(Character $character, int $experience): ExperienceA throw new InvalidArgumentException('Experience awards cannot be negative.'); } - $oldLevel = $character->level; + $before = ProgressionSnapshot::capture($character); + $oldLevel = $before->level; $character->addExperience($experience); $newLevel = $character->level; @@ -33,7 +34,7 @@ public static function award(Character $character, int $experience): ExperienceA $character->restoreVitals(); } - return self::grantAutomaticRoleSkills($character, $oldLevel, $newLevel, $experience); + return self::grantAutomaticRoleSkills($character, $oldLevel, $newLevel, $experience, $before); } /** @@ -63,10 +64,12 @@ private static function grantAutomaticRoleSkills( int $minimumExclusiveLevel, int $maximumInclusiveLevel, int $experience, + ?ProgressionSnapshot $before = null, ): ExperienceAwardResult { $abilities = []; $magic = []; + $details = []; foreach ($character->role->skillsToLearn as $grant) { if ($grant->level <= $minimumExclusiveLevel || $grant->level > $maximumInclusiveLevel) { @@ -82,6 +85,8 @@ private static function grantAutomaticRoleSkills( } else { $abilities[] = $grant->skill->name; } + $details[] = ['name' => $grant->skill->name, 'description' => $grant->skill->description, + 'kind' => $grant->skill instanceof MagicSkill ? 'Magic' : 'Ability', 'cost' => $grant->skill->cost]; } return new ExperienceAwardResult( @@ -91,6 +96,9 @@ private static function grantAutomaticRoleSkills( $maximumInclusiveLevel, $abilities, $magic, + $before, + ProgressionSnapshot::capture($character), + $details, ); } } diff --git a/src/Progression/ProgressionSnapshot.php b/src/Progression/ProgressionSnapshot.php new file mode 100644 index 00000000..969d4e78 --- /dev/null +++ b/src/Progression/ProgressionSnapshot.php @@ -0,0 +1,50 @@ + $thresholds @param array $stats */ + public function __construct( + public string $actorId, + public string $name, + public int $level, + public int $experience, + public int $maxLevel, + public array $thresholds, + public array $stats, + ) {} + + public static function capture(Character $character): self + { + $stats = []; + $effective = $character->effectiveStats; + foreach (StatKey::cases() as $key) { + $stats[$key->value] = $effective->{$key->statsProperty()}; + } + return new self($character->actorId, $character->name, $character->level, + $character->currentExp, $character->maxLevel, $character->getLevelExperienceThresholds(), $stats); + } + + /** @return array{level: int, current: int, needed: int, ratio: float, maximum: bool} */ + public function progressAt(int $experience): array + { + $level = 1; + foreach ($this->thresholds as $candidate => $threshold) { + if ($experience < $threshold || $candidate > $this->maxLevel) { break; } + $level = max(1, $candidate); + } + $maximum = $level >= $this->maxLevel; + $base = $this->thresholds[$level] ?? 0; + $needed = $maximum ? 0 : max(1, ($this->thresholds[$level + 1] ?? $base + 1) - $base); + $current = $maximum ? 0 : max(0, $experience - $base); + return ['level' => $level, 'current' => $current, 'needed' => $needed, + 'ratio' => $maximum ? 1.0 : min(1.0, $current / $needed), 'maximum' => $maximum]; + } +} diff --git a/src/Quests/Quest.php b/src/Quests/Quest.php index afdda8e0..2c4982e2 100644 --- a/src/Quests/Quest.php +++ b/src/Quests/Quest.php @@ -2,6 +2,7 @@ namespace Ichiloto\Engine\Quests; +use Ichiloto\Engine\Exceptions\RequiredFieldException; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Stores\ItemStore; use InvalidArgumentException; @@ -22,7 +23,7 @@ class Quest * @param string $description The journal description. * @param string $giver The quest giver's name. * @param QuestObjective[] $objectives The objectives, in order. - * @param array{gold?: int, experience?: int, items?: string[]} $rewards The completion rewards. + * @param array{gold?: int, experience?: int, items?: array} $rewards The completion rewards. * @param array> $prerequisites Conditions that must hold before the quest can be accepted (same shapes as event-trigger conditions, plus `['type' => 'quest', 'name' => ..., 'status' => 'completed'|'active']`). * @param bool $isOptional True for a side quest, which is offered to the * player and only entered in the journal if they accept it. @@ -82,6 +83,19 @@ public function describeRewards(): string $itemStore = ConfigStore::has(ItemStore::class) ? ConfigStore::get(ItemStore::class) : null; foreach ((array) ($this->rewards['items'] ?? []) as $itemReference) { + if (is_array($itemReference)) { + if (!$itemStore instanceof ItemStore) { + throw new InvalidArgumentException(sprintf('Describing structured rewards for quest "%s" requires the project ItemStore.', $this->id)); + } + $reference = strval($itemReference['item'] ?? throw new RequiredFieldException('item')); + $quantity = max(0, intval($itemReference['quantity'] ?? 1)); + // Granting validates identity even for zero copies; display never creates those copies. + $name = $itemStore->displayNameFor($reference, sprintf('describing rewards for quest "%s"', $this->id)); + if ($quantity > 0) { + $parts[] = $name . ($quantity > 1 ? ' x' . $quantity : ''); + } + continue; + } if (! is_string($itemReference)) { continue; } diff --git a/src/Rendering/Camera.php b/src/Rendering/Camera.php index 839ce019..c082fa97 100644 --- a/src/Rendering/Camera.php +++ b/src/Rendering/Camera.php @@ -173,7 +173,7 @@ public function renderMap(): void Console::beginFrame(); for ($row = 0, $height = $this->getVisibleWorldHeight(); $row < $height; $row++) { - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); $content = $this->normalizedMapRow((int)$this->position->y + $row) ->select((int)$this->position->x, $visibleWidth, $visibleWidth); LatencyTrace::end('terminal.select', $started); diff --git a/src/Rendering/Launch/RendererRegistry.php b/src/Rendering/Launch/RendererRegistry.php index 1ad7932e..c91c64bb 100644 --- a/src/Rendering/Launch/RendererRegistry.php +++ b/src/Rendering/Launch/RendererRegistry.php @@ -7,6 +7,9 @@ use Ichiloto\Engine\Rendering\Runtime\RendererRuntimeConfig; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\UI\Presentation\CreditsMenuPresentation; +use Ichiloto\Engine\UI\Presentation\TitlePresentationCatalog; use InvalidArgumentException; final class RendererRegistry @@ -22,8 +25,11 @@ public function __construct(?RendererExecutableResolverInterface $resolver = nul new RendererDescriptor('terminal', static fn(string $assetRoot): ?RendererRuntime => null), new RendererDescriptor('gpui', static fn(string $assetRoot): RendererRuntime => new RendererRuntime( new RendererRuntimeConfig(new RendererProcessConfig([$resolver->resolve('gpui')]), $assetRoot, 10, 20, - requiredCapabilities: [RendererSessionConfig::SPRITE_SOURCE_RECT, RendererSessionConfig::TILE_BATCHES, - ...(BattlePresentationCatalog::load($assetRoot)?->requiredCapabilities() ?? [])]), + requiredCapabilities: array_values(array_unique([RendererSessionConfig::SPRITE_SOURCE_RECT, RendererSessionConfig::TILE_BATCHES, + ...(BattlePresentationCatalog::load($assetRoot)?->requiredCapabilities() ?? []), + ...MenuPresentationCatalog::requestedCapabilities($assetRoot), + ...(MenuPresentationCatalog::exists($assetRoot) ? CreditsMenuPresentation::CAPABILITIES : []), + ...TitlePresentationCatalog::getRequestedCapabilities($assetRoot)]))), )), ]; foreach ($descriptors as $descriptor) { diff --git a/src/Rendering/Presentation/Canvas/CanvasComposite.php b/src/Rendering/Presentation/Canvas/CanvasComposite.php new file mode 100644 index 00000000..309c4cd5 --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasComposite.php @@ -0,0 +1,43 @@ + $operations */ + public function __construct(public string $id, public int $width, public int $height, + public CanvasRectangle $destination, array $operations, public int $layer = 0, + public float $opacity = 1, public ?CanvasRectangle $clipRect = null) + { + CanvasValidation::id($id); + SpriteValidation::validateSigned32BitRange($layer); + if (min($width, $height) < 1 || max($width, $height) > 4096 || $width * $height > 4194304) { + throw new InvalidArgumentException('Composite dimensions exceed the local raster budget.'); + } + CanvasCompositeValues::getNumber($opacity, 0, 1); + $copy = []; + foreach (CanvasCompositeValues::getList($operations, 0, 256) as $operation) { + if (!$operation instanceof CanvasCompositeOperation) { throw new InvalidArgumentException('Composite operations must be typed.'); } + $operation->assertWithin($width, $height); + $copy[] = $operation; + } + $this->operations = $copy; + } + + public function toArray(): array + { + return ['id' => $this->id, 'width' => $this->width, 'height' => $this->height, + 'destination' => $this->destination->toArray(), 'layer' => $this->layer, + 'operations' => array_map(static fn($operation) => $operation->data, $this->operations), + ...($this->opacity === 1.0 ? [] : ['opacity' => $this->opacity]), + ...($this->clipRect === null ? [] : ['clipRect' => $this->clipRect->toArray()])]; + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasCompositeBrush.php b/src/Rendering/Presentation/Canvas/CanvasCompositeBrush.php new file mode 100644 index 00000000..c57e7768 --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasCompositeBrush.php @@ -0,0 +1,47 @@ + ['color'], 'linear' => ['start', 'end', 'stops'], 'radial' => ['center', 'radius', 'stops'], + default => throw new InvalidArgumentException('Unsupported composite brush.'), + }; + CanvasCompositeValues::validateKeys($data, ['type', ...$required]); + $result = ['type' => $type]; + if ($type === 'solid') { $result['color'] = CanvasCompositeValues::getColor($data['color']); } + else { + foreach (array_diff($required, ['stops']) as $key) { + $result[$key] = CanvasCompositeValues::getPoint($data[$key]); + } + if (($type === 'linear' && $result['start'] === $result['end']) + || ($type === 'radial' && min($result['radius']) <= 0)) { + throw new InvalidArgumentException('Composite gradient must have positive extent.'); + } + $result['stops'] = []; + $previous = -1; + foreach (CanvasCompositeValues::getList($data['stops'], 2, 8) as $stop) { + CanvasCompositeValues::validateKeys($stop, ['offset', 'color'], ['opacity']); + $offset = CanvasCompositeValues::getNumber($stop['offset'], 0, 1); + if ($offset <= $previous) { throw new InvalidArgumentException('Gradient stops must strictly increase.'); } + $previous = $offset; + $result['stops'][] = ['offset' => $offset, 'color' => CanvasCompositeValues::getColor($stop['color']), + 'opacity' => CanvasCompositeValues::getNumber($stop['opacity'] ?? 1, 0, 1)]; + } + if ($result['stops'][0]['offset'] !== 0.0 || $previous !== 1.0) { + throw new InvalidArgumentException('Composite gradients need endpoint stops at zero and one.'); + } + } + $this->data = $result; + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasCompositeBudget.php b/src/Rendering/Presentation/Canvas/CanvasCompositeBudget.php new file mode 100644 index 00000000..adceb65d --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasCompositeBudget.php @@ -0,0 +1,53 @@ +width * $composite->height; + foreach ($composite->operations as $operation) { + $data = $operation->data; + if ($data['type'] === 'stroke') { + $half = $data['width'] / 2; + $left = min(array_column($data['points'], 0)) - $half; + $top = min(array_column($data['points'], 1)) - $half; + $right = max(array_column($data['points'], 0)) + $half; + $bottom = max(array_column($data['points'], 1)) + $half; + } else { + $rect = $data['destination']; + $left = $rect['x']; $top = $rect['y']; + $right = $left + $rect['width']; $bottom = $top + $rect['height']; + } + $pixels = (int)(max(0, min($composite->width, ceil($right)) - max(0, floor($left))) + * max(0, min($composite->height, ceil($bottom)) - max(0, floor($top)))); + $cost = match ($data['type']) { + 'image' => 16 + (isset($data['displacement']) ? 8 : 0), + 'fill' => 4, 'stroke' => 4 + count($data['points']), + }; + foreach ([$data['masks'], $data['displacement']['masks'] ?? []] as $masks) { + if ($masks === []) { continue; } + $cost += 2; + foreach ($masks as $mask) { + $cost += match ($mask['type']) { + 'polygon' => array_sum(array_map(count(...), $mask['contours'])), 'ellipse' => 8, 'image_alpha' => 16, + }; + } + } + $work += $pixels * $cost; + if ($work > self::MAX_WORK) { throw new InvalidArgumentException('Canvas composites exceed the bounded cold raster work budget.'); } + } + } + return $work; + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasCompositeMask.php b/src/Rendering/Presentation/Canvas/CanvasCompositeMask.php new file mode 100644 index 00000000..97be54f9 --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasCompositeMask.php @@ -0,0 +1,56 @@ + ['contours'], 'ellipse' => ['center', 'radius'], 'image_alpha' => ['asset', 'destination'], + default => throw new InvalidArgumentException('Unsupported composite mask.'), + }; + CanvasCompositeValues::validateKeys($data, ['type', ...$required], + ['invert', $type === 'image_alpha' ? 'source' : 'feather']); + $result = ['type' => $type, 'invert' => CanvasCompositeValues::getBoolean($data['invert'] ?? false)]; + if ($type === 'image_alpha') { + $result['asset'] = CanvasCompositeValues::getAsset($data['asset']); + $result['destination'] = CanvasCompositeValues::getRectangle($data['destination']); + if (isset($data['source'])) { $result['source'] = CanvasCompositeValues::getRectangle($data['source'], true); } + } else { + $result['feather'] = CanvasCompositeValues::getNumber($data['feather'] ?? 0, 0, 4096); + if ($type === 'polygon') { + $result['contours'] = []; + foreach (CanvasCompositeValues::getList($data['contours'], 1, 8) as $contour) { + $points = array_map(CanvasCompositeValues::getPoint(...), CanvasCompositeValues::getList($contour, 3, 128)); + foreach ($points as $index => $point) { + if ($point === $points[($index + 1) % count($points)]) { + throw new InvalidArgumentException('Composite polygon edges require distinct endpoints.'); + } + } + $result['contours'][] = $points; + } + } else { + $result['center'] = CanvasCompositeValues::getPoint($data['center']); + $result['radius'] = CanvasCompositeValues::getPoint($data['radius'], 0); + if (min($result['radius']) <= 0) { throw new InvalidArgumentException('Composite ellipse radii must be positive.'); } + } + } + $this->data = $result; + } + + public static function getMasks(mixed $values): array + { + return array_map(static function ($data): array { + if (!is_array($data)) { throw new InvalidArgumentException('Composite mask must be a descriptor.'); } + return new self($data)->data; + }, CanvasCompositeValues::getList($values, 0, 8)); + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasCompositeOperation.php b/src/Rendering/Presentation/Canvas/CanvasCompositeOperation.php new file mode 100644 index 00000000..8ecdb133 --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasCompositeOperation.php @@ -0,0 +1,73 @@ + ['asset', 'destination'], 'fill' => ['destination', 'brush'], 'stroke' => ['points', 'width', 'brush'], + default => throw new InvalidArgumentException('Unsupported composite operation.'), + }; + CanvasCompositeValues::validateKeys($data, ['type', ...$required], + ['opacity', 'blend', 'masks', ...($type === 'image' ? ['source', 'displacement'] : [])]); + $blend = $data['blend'] ?? 'source_over'; + if (!in_array($blend, ['source_over', 'screen'], true)) { throw new InvalidArgumentException('Unsupported composite blend.'); } + $result = ['type' => $type, 'opacity' => CanvasCompositeValues::getNumber($data['opacity'] ?? 1, 0, 1), + 'blend' => $blend, 'masks' => CanvasCompositeMask::getMasks($data['masks'] ?? [])]; + if ($type !== 'stroke') { $result['destination'] = CanvasCompositeValues::getRectangle($data['destination']); } + else { + $result['points'] = array_map(CanvasCompositeValues::getPoint(...), CanvasCompositeValues::getList($data['points'], 2, 256)); + $result['width'] = CanvasCompositeValues::getNumber($data['width'], 0, 256); + if ($result['width'] <= 0) { throw new InvalidArgumentException('Composite strokes require positive width.'); } + } + if ($type !== 'image') { + if (!is_array($data['brush'])) { throw new InvalidArgumentException('Composite brush must be a descriptor.'); } + $result['brush'] = new CanvasCompositeBrush($data['brush'])->data; + } else { + $result['asset'] = CanvasCompositeValues::getAsset($data['asset']); + if (isset($data['source'])) { $result['source'] = CanvasCompositeValues::getRectangle($data['source'], true); } + if (isset($data['displacement'])) { + $grid = $data['displacement']; + CanvasCompositeValues::validateKeys($grid, ['columns', 'rows', 'offsets'], ['masks']); + foreach (['columns', 'rows'] as $axis) { + if (!is_int($grid[$axis]) || $grid[$axis] < 2 || $grid[$axis] > 64) { + throw new InvalidArgumentException('Composite displacement axes must be in 2..64.'); + } + } + $count = $grid['columns'] * $grid['rows']; + $result['displacement'] = ['columns' => $grid['columns'], 'rows' => $grid['rows'], + 'offsets' => array_map(static fn($pair) => CanvasCompositeValues::getPoint($pair, -4096, 4096), + CanvasCompositeValues::getList($grid['offsets'], $count, $count)), + 'masks' => CanvasCompositeMask::getMasks($grid['masks'] ?? [])]; + } + } + $this->data = $result; + } + + public function assertWithin(int $width, int $height): void + { + if (isset($this->data['destination'])) { + new CanvasRectangle(...array_values($this->data['destination']))->assertWithin($width, $height); + } + } + + /** @return list */ + public function getAssets(): array + { + $assets = isset($this->data['asset']) ? [$this->data['asset']] : []; + foreach ([...$this->data['masks'], ...($this->data['displacement']['masks'] ?? [])] as $mask) { + if (isset($mask['asset'])) { $assets[] = $mask['asset']; } + } + return array_values(array_unique($assets)); + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasCompositeValues.php b/src/Rendering/Presentation/Canvas/CanvasCompositeValues.php new file mode 100644 index 00000000..46d637ff --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasCompositeValues.php @@ -0,0 +1,82 @@ + $max) { + throw new InvalidArgumentException('Composite value is outside its finite range.'); + } + return (float)$value; + } + + public static function getList(mixed $values, int $min, int $max): array + { + if (!is_array($values) || !array_is_list($values) || count($values) < $min || count($values) > $max) { + throw new InvalidArgumentException('Composite list exceeds its bounds.'); + } + return $values; + } + + public static function getPoint(mixed $value, float $min = -16384, float $max = 16384): array + { + return array_map(static fn($n) => self::getNumber($n, $min, $max), self::getList($value, 2, 2)); + } + + public static function getRectangle(mixed $value, bool $normalized = false): array + { + self::validateKeys($value, ['x', 'y', 'width', 'height']); + $max = $normalized ? 1 : 16384; + $rect = new CanvasRectangle(...array_map(static fn($key) => self::getNumber($value[$key], 0, $max), + ['x', 'y', 'width', 'height'])); + $rect->assertWithin($max, $max); + return $rect->toArray(); + } + + public static function getBoolean(mixed $value): bool + { + if (!is_bool($value)) { throw new InvalidArgumentException('Composite flag must be boolean.'); } + return $value; + } + + public static function getAsset(mixed $value): string + { + if (!is_string($value) || strlen($value) > 4096) { throw new InvalidArgumentException('Invalid composite PNG path.'); } + SpriteValidation::validateAssetPath($value); + return $value; + } + + public static function getColor(mixed $value): array + { + $kind = is_array($value) ? ($value['kind'] ?? '') : ''; + $keys = match ($kind) { 'rgb' => ['r', 'g', 'b'], 'ansi16', 'ansi256' => ['index'], + default => throw new InvalidArgumentException('Invalid composite color kind.') }; + self::validateKeys($value, ['kind', ...$keys]); + $parts = array_map(static function ($key) use ($value): int { + if (!is_int($value[$key])) { throw new InvalidArgumentException('Composite color components must be integers.'); } + return $value[$key]; + }, $keys); + return match ($kind) { + 'rgb' => PresentationColor::rgb($parts[0], $parts[1], $parts[2])->toArray(), + 'ansi16' => PresentationColor::ansi16($parts[0])->toArray(), + 'ansi256' => PresentationColor::ansi256($parts[0])->toArray(), + }; + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasImagePreflight.php b/src/Rendering/Presentation/Canvas/CanvasImagePreflight.php new file mode 100644 index 00000000..c88efe3a --- /dev/null +++ b/src/Rendering/Presentation/Canvas/CanvasImagePreflight.php @@ -0,0 +1,65 @@ + $images + * @return array{sources: array, regions: array} + */ + public static function inspect(array $images, string $root, array $composites = []): array + { + $sources = $regions = $names = []; + foreach ($images as $image) { + $size = PngAssetPreflight::inspect($root, $image->asset); + $crop = PngAssetPreflight::inspect($root, $image->asset, $image->sourceRect); + $path = realpath($root . DIRECTORY_SEPARATOR . $image->asset); + $name = $names[$path] ??= $image->asset; + $sources[$name] = $size['width'] * $size['height'] * 4; + $rect = [$image->sourceRect?->x ?? 0, $image->sourceRect?->y ?? 0, $crop['width'], $crop['height']]; + // Native canvas sampling adds a two-pixel guard on each edge, even for whole images. + $regions[$name . ':' . implode(',', $rect)] = ($crop['width'] + 4) * ($crop['height'] + 4) * 4; + } + foreach ($composites as $composite) { + foreach ($composite->operations as $operation) { + foreach ($operation->getAssets() as $asset) { + $size = PngAssetPreflight::inspect($root, $asset); + $path = realpath($root . DIRECTORY_SEPARATOR . $asset); + $name = $names[$path] ??= $asset; + $sources[$name] = $size['width'] * $size['height'] * 4; + } + } + } + if (count($sources) > 1024 || array_sum($sources) > self::MAX_BYTES) { + throw new RuntimeException('Canvas PNG sources exceed the native 64 MiB / 1024-image limit.'); + } + if (count($regions) > 4096 || array_sum($regions) > self::MAX_BYTES) { + throw new RuntimeException('Canvas prepared regions exceed the native 64 MiB / 4096-region limit.'); + } + return ['sources' => $sources, 'regions' => $regions]; + } + + /** Source regions are independent of display size; retain nine-slice cuts for accounting. + * @param list $textures + * @return list + */ + public static function textures(array $textures): array + { + $images = []; + foreach ($textures as $index => $texture) { + $bounds = new CanvasRectangle(0, 0, + max($texture->minimumWidth, $texture->source->width / $texture->density), + max($texture->minimumHeight, $texture->source->height / $texture->density)); + array_push($images, ...$texture->images('preflight-' . $index, $bounds, 0)); + } + return $images; + } +} diff --git a/src/Rendering/Presentation/Canvas/CanvasNineSlice.php b/src/Rendering/Presentation/Canvas/CanvasNineSlice.php index 2d2f1058..57ef3634 100644 --- a/src/Rendering/Presentation/Canvas/CanvasNineSlice.php +++ b/src/Rendering/Presentation/Canvas/CanvasNineSlice.php @@ -5,7 +5,9 @@ namespace Ichiloto\Engine\Rendering\Presentation\Canvas; use Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect; +use Ichiloto\Engine\Rendering\Sprites\PngAssetPreflight; use Ichiloto\Engine\Rendering\Sprites\SpriteValidation; +use Ichiloto\Engine\Util\Debug; use InvalidArgumentException; /** Source-pixel cuts with shared destination edges; zero cuts also describe a plain image. */ @@ -32,6 +34,29 @@ public function __construct( } } + /** Whole-file artwork needs a path and authored border intent, not duplicate image dimensions. */ + public static function fromPng(string $root, string $asset, int $left = 0, int $top = 0, + int $right = 0, int $bottom = 0, int $density = 1, float $minimumWidth = 0, float $minimumHeight = 0): self + { + if (min($left, $top, $right, $bottom) < 0) { + throw new InvalidArgumentException('Image border cuts must be nonnegative.'); + } + $size = PngAssetPreflight::inspect($root, $asset); + $fit = static function (int $first, int $last, int $available): array { + if ($first + $last <= $available) { return [$first, $last]; } + $start = (int)floor($available * ($first / ((float)$first + $last))); + return [$start, $available - $start]; + }; + // Keep a center pixel even when replacement artwork is smaller than the authored borders. + [$l, $r] = $fit($left, $right, $size['width'] - 1); + [$t, $b] = $fit($top, $bottom, $size['height'] - 1); + if ([$l, $t, $r, $b] !== [$left, $top, $right, $bottom]) { + Debug::warn("Image border cuts reconciled to the current PNG: {$asset}"); + } + return new self($asset, new SpriteSourceRect(0, 0, $size['width'], $size['height']), + $l, $t, $r, $b, $density, $minimumWidth, $minimumHeight); + } + /** @return list */ public function images(string $id, CanvasRectangle $destination, int $layer, ?CanvasRectangle $clip = null): array { diff --git a/src/Rendering/Presentation/Canvas/CanvasValidation.php b/src/Rendering/Presentation/Canvas/CanvasValidation.php index 49f418df..a330958f 100644 --- a/src/Rendering/Presentation/Canvas/CanvasValidation.php +++ b/src/Rendering/Presentation/Canvas/CanvasValidation.php @@ -17,7 +17,7 @@ public static function id(string $id): void } } - /** @template T of CanvasImage|CanvasIndicator|CanvasTextLayer + /** @template T of CanvasImage|CanvasIndicator|CanvasTextLayer|CanvasComposite * @param list $items * @param class-string $type * @return list diff --git a/src/Rendering/Presentation/Canvas/PresentationCanvas.php b/src/Rendering/Presentation/Canvas/PresentationCanvas.php index 6cf510a5..e8f86447 100644 --- a/src/Rendering/Presentation/Canvas/PresentationCanvas.php +++ b/src/Rendering/Presentation/Canvas/PresentationCanvas.php @@ -13,6 +13,8 @@ public array $indicators; /** @var list */ public array $textLayers; + /** @var list */ + public array $composites; /** @param list $images * @param list $indicators @@ -24,6 +26,7 @@ public function __construct( array $images = [], array $indicators = [], array $textLayers = [], + array $composites = [], ) { if ($width < 1 || $height < 1 || $width > CanvasValidation::MAX_EXTENT || $height > CanvasValidation::MAX_EXTENT) { @@ -32,6 +35,19 @@ public function __construct( $this->images = CanvasValidation::orderedList($images, CanvasImage::class, 1024); $this->indicators = CanvasValidation::orderedList($indicators, CanvasIndicator::class, 2048); $this->textLayers = CanvasValidation::orderedList($textLayers, CanvasTextLayer::class, 64); + $this->composites = CanvasValidation::orderedList($composites, CanvasComposite::class, 8); + $pixels = $operations = $nodes = 0; + foreach ($this->composites as $composite) { + $composite->destination->assertWithin($width, $height); + $composite->clipRect?->assertWithin($width, $height); + $pixels += $composite->width * $composite->height; + $operations += count($composite->operations); + foreach ($composite->operations as $operation) { $nodes += count($operation->data['displacement']['offsets'] ?? []); } + } + if ($pixels > 8388608 || $operations > 256 || $nodes > 16384) { + throw new InvalidArgumentException('Canvas composites exceed their combined raster, operation or displacement budget.'); + } + CanvasCompositeBudget::getWork($this->composites); $imageIds = []; foreach ($this->images as $image) { $image->destination->assertWithin($width, $height); @@ -63,6 +79,7 @@ public function toArray(): array return ['width' => $this->width, 'height' => $this->height, 'images' => array_map(static fn(CanvasImage $image) => $image->toArray(), $this->images), 'indicators' => array_map(static fn(CanvasIndicator $indicator) => $indicator->toArray(), $this->indicators), - 'textLayers' => array_map(static fn(CanvasTextLayer $text) => $text->toArray(), $this->textLayers)]; + 'textLayers' => array_map(static fn(CanvasTextLayer $text) => $text->toArray(), $this->textLayers), + ...($this->composites === [] ? [] : ['composites' => array_map(static fn($item) => $item->toArray(), $this->composites)])]; } } diff --git a/src/Rendering/Presentation/PresentationFrame.php b/src/Rendering/Presentation/PresentationFrame.php index 2412b2ea..670dce1d 100644 --- a/src/Rendering/Presentation/PresentationFrame.php +++ b/src/Rendering/Presentation/PresentationFrame.php @@ -2,8 +2,8 @@ namespace Ichiloto\Engine\Rendering\Presentation; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; use Ichiloto\Engine\Rendering\Transport\RendererMessage; -use Ichiloto\Engine\Rendering\Transport\RendererMessageType; use InvalidArgumentException; /** An atomic full replacement. Frame numbers are labels, never simulation clocks. */ diff --git a/src/Rendering/Presentation/RendererPresentation.php b/src/Rendering/Presentation/RendererPresentation.php index f759ebaf..a7e3778a 100644 --- a/src/Rendering/Presentation/RendererPresentation.php +++ b/src/Rendering/Presentation/RendererPresentation.php @@ -47,7 +47,7 @@ public function present(ConsoleFrameSnapshot|ConsolePresentationSnapshot $snapsh throw new RendererProtocolException('Graphical terrain requires protocol v2 and negotiated tile_batches support. Request it at startup and install an updated renderer.'); } foreach ($tileBatches as $batch) { $batch->assertWithin($this->grid); } - $preparation = LatencyTrace::now(); + $preparation = LatencyTrace::getTimeNow(); $number = $this->frameNumber < PHP_INT_MAX ? $this->frameNumber + 1 : $this->frameNumber; $message = $snapshot instanceof ConsolePresentationSnapshot ? new StyledPresentationFrame($number, $snapshot->textLayers, $sprites, $tileBatches)->toRendererMessage() @@ -62,6 +62,9 @@ public function presentCanvas(PresentationCanvas $canvas): bool if (!$this->client->supports(RendererSessionConfig::GRAPHICAL_CANVAS)) { throw new RendererProtocolException('Canvas presentation requires negotiated graphical_canvas support.'); } + if ($canvas->composites !== [] && !$this->client->supports(RendererSessionConfig::CANVAS_COMPOSITING)) { + throw new RendererProtocolException('Canvas raster operations require negotiated canvas_compositing support.'); + } foreach ($canvas->images as $image) { if ($image->sourceRect !== null && !$this->client->supports(RendererSessionConfig::SPRITE_SOURCE_RECT)) { throw new RendererProtocolException('Canvas image crops require negotiated sprite_source_rect support.'); @@ -82,7 +85,7 @@ public function presentCanvas(PresentationCanvas $canvas): bool private function queue(RendererMessage $message): bool { - $comparison = LatencyTrace::now(); + $comparison = LatencyTrace::getTimeNow(); $content = $message->payload; $previous = $this->lastMessage?->payload ?? []; unset($content['frame'], $previous['frame']); diff --git a/src/Rendering/Presentation/StyledPresentationFrame.php b/src/Rendering/Presentation/StyledPresentationFrame.php index 01b5b5f0..70a21f9a 100644 --- a/src/Rendering/Presentation/StyledPresentationFrame.php +++ b/src/Rendering/Presentation/StyledPresentationFrame.php @@ -3,9 +3,9 @@ namespace Ichiloto\Engine\Rendering\Presentation; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererMessage; -use Ichiloto\Engine\Rendering\Transport\RendererMessageType; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use InvalidArgumentException; final readonly class StyledPresentationFrame diff --git a/src/Rendering/RendererClient.php b/src/Rendering/RendererClient.php index fff41e35..33316af6 100644 --- a/src/Rendering/RendererClient.php +++ b/src/Rendering/RendererClient.php @@ -3,12 +3,13 @@ namespace Ichiloto\Engine\Rendering; use Ichiloto\Engine\Diagnostics\LatencyTrace; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; +use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; +use Ichiloto\Engine\Rendering\Transport\Interfaces\RendererTransportInterface; use Ichiloto\Engine\Rendering\Transport\RendererEvent; -use Ichiloto\Engine\Rendering\Transport\RendererEventType; use Ichiloto\Engine\Rendering\Transport\RendererMessage; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; -use Ichiloto\Engine\Rendering\Transport\RendererTransportInterface; use InvalidArgumentException; use SplQueue; @@ -68,9 +69,15 @@ private function receive(bool $discardKeys = false): void $keys = $this->keys->count(); $events = $this->events->count(); $bytes = $this->queuedBytes; + $capabilities = $this->capabilities; foreach ($batch as $event) { if ($event->type === RendererEventType::READY) { $event->requireCapabilities($this->requiredCapabilities); + $capabilities = array_values(array_intersect($this->requiredCapabilities, $event->capabilities)); + } + if ($event->type === RendererEventType::WINDOW_ACTIVATION + && !in_array(RendererSessionConfig::WINDOW_ACTIVATION, $capabilities, true)) { + throw new RendererProtocolException('Window activation requires negotiated window_activation support.'); } if ($discardKeys && $event->type === RendererEventType::KEY) { continue; @@ -196,6 +203,6 @@ public function shutdown(): ?int private static function eventBytes(RendererEvent $event): int { return strlen($event->key ?? '') + strlen($event->message ?? '') - + array_sum(array_map(strlen(...), $event->capabilities)); + + array_sum(array_map(strlen(...), $event->capabilities)) + ($event->active === null ? 0 : 1); } } diff --git a/src/Rendering/Runtime/RendererRuntime.php b/src/Rendering/Runtime/RendererRuntime.php index 48a37795..ce272c81 100644 --- a/src/Rendering/Runtime/RendererRuntime.php +++ b/src/Rendering/Runtime/RendererRuntime.php @@ -7,20 +7,20 @@ use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\IO\InputSources\InputSourceInterface; use Ichiloto\Engine\IO\InputSources\RendererInputSource; -use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; use Ichiloto\Engine\Rendering\Presentation\PresentationTileBatch; +use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\RendererClient; use Ichiloto\Engine\Rendering\Sprites\GraphicalSpriteCollector; use Ichiloto\Engine\Rendering\Tiles\GraphicalTileProviderHostInterface; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; +use Ichiloto\Engine\Rendering\Transport\Interfaces\RendererTransportInterface; use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; -use Ichiloto\Engine\Rendering\Transport\RendererEventType; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; -use Ichiloto\Engine\Rendering\Transport\RendererTransportInterface; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; use InvalidArgumentException; use LogicException; @@ -38,6 +38,7 @@ final class RendererRuntime private bool $started = false; private bool $closed = false; private bool $closeRequested = false; + public private(set) bool $windowActive = true; public function __construct(private readonly RendererRuntimeConfig $config, ?RendererTransportInterface $transport = null) { @@ -87,6 +88,8 @@ public function pump(): void foreach ($this->client->drainEvents() as $event) { if ($event->type === RendererEventType::CLOSE_REQUESTED) { $this->closeRequested = true; + } elseif ($event->type === RendererEventType::WINDOW_ACTIVATION) { + $this->windowActive = $event->active ?? throw new LogicException('Window activation lacks its validated state.'); } elseif ($event->type === RendererEventType::ERROR) { throw new RendererTransportException('Renderer error: ' . $event->message, $this->transport->getDiagnostics(), $this->transport->getExitCode()); @@ -99,7 +102,7 @@ public function pump(): void public function present(?SceneInterface $scene): bool { - $started = LatencyTrace::now(); + $started = LatencyTrace::getTimeNow(); LatencyTrace::record('presentation.begin', ['scene' => $scene === null ? null : $scene::class]); $this->pump(); if ($this->presentation === null || $this->closed) { @@ -112,7 +115,7 @@ public function present(?SceneInterface $scene): bool LatencyTrace::end('presentation.end', $started, ['changed' => $changed]); return $changed; } - $collection = LatencyTrace::now(); + $collection = LatencyTrace::getTimeNow(); $sprites = $this->collector->collect($scene); LatencyTrace::end('presentation.sprites', $collection, ['count' => count($sprites)]); if (LatencyTrace::enabled()) { @@ -125,7 +128,7 @@ public function present(?SceneInterface $scene): bool $visible = array_filter($sprites, static fn($sprite) => $sprite->x >= 0 && $sprite->x < Console::getWidth() && $sprite->y >= 0 && $sprite->y < Console::getHeight()); $excluded = array_map(static fn($sprite) => $sprite->id, $visible); - $tilesStart = LatencyTrace::now(); + $tilesStart = LatencyTrace::getTimeNow(); $tiles = $this->config->protocol === RendererProtocolVersion::V2 && $scene instanceof GraphicalTileProviderHostInterface ? $scene->getGraphicalTileBatches() : []; $replaced = []; @@ -137,7 +140,7 @@ public function present(?SceneInterface $scene): bool } } LatencyTrace::end('presentation.tiles', $tilesStart, ['batches' => count($tiles), 'cells' => $tileCount]); - $snapshotStart = LatencyTrace::now(); + $snapshotStart = LatencyTrace::getTimeNow(); $snapshot = $this->config->protocol === RendererProtocolVersion::V2 ? Console::presentationSnapshot($excluded, $replaced) : Console::snapshot($excluded); LatencyTrace::end('presentation.snapshot', $snapshotStart); diff --git a/src/Rendering/Runtime/RendererRuntimeConfig.php b/src/Rendering/Runtime/RendererRuntimeConfig.php index 5f7581e7..0ef47cf9 100644 --- a/src/Rendering/Runtime/RendererRuntimeConfig.php +++ b/src/Rendering/Runtime/RendererRuntimeConfig.php @@ -2,9 +2,9 @@ namespace Ichiloto\Engine\Rendering\Runtime; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; final readonly class RendererRuntimeConfig diff --git a/src/Rendering/Sprites/DirectionalGraphicalSpriteSet.php b/src/Rendering/Sprites/DirectionalGraphicalSpriteSet.php index 51b1266a..24b3eb1c 100644 --- a/src/Rendering/Sprites/DirectionalGraphicalSpriteSet.php +++ b/src/Rendering/Sprites/DirectionalGraphicalSpriteSet.php @@ -45,21 +45,8 @@ public static function fromArray(array $data): self if (!is_array($entry)) { throw new InvalidArgumentException("Graphical sprite direction '$direction' must contain a definition array."); } - $entry += ['anchor' => PresentationSpriteAnchor::BOTTOM_CENTER->value, 'layer' => 0]; - if (array_diff_key($entry, array_flip(['asset', 'width', 'height', 'anchor', 'layer'])) !== [] - || !is_string($entry['asset'] ?? null) - || !is_int($entry['width'] ?? null) || !is_int($entry['height'] ?? null) - || !is_string($entry['anchor']) || !is_int($entry['layer'])) { - throw new InvalidArgumentException("Graphical sprite direction '$direction' requires a string asset/anchor and integer width/height/layer, with no unknown fields."); - } - $anchor = PresentationSpriteAnchor::tryFrom($entry['anchor']); - if ($anchor === null) { - throw new InvalidArgumentException("Graphical sprite direction '$direction' has an unsupported anchor."); - } try { - $definitions[$direction] = new GraphicalSpriteDefinition( - $entry['asset'], $entry['width'], $entry['height'], $anchor, $entry['layer'], - ); + $definitions[$direction] = GraphicalSpriteDefinition::fromArray($entry); } catch (InvalidArgumentException $exception) { throw new InvalidArgumentException("Graphical sprite direction '$direction': " . $exception->getMessage(), 0, $exception); } diff --git a/src/Rendering/Sprites/GraphicalSpriteDefinition.php b/src/Rendering/Sprites/GraphicalSpriteDefinition.php index e440aa76..6ecbf220 100644 --- a/src/Rendering/Sprites/GraphicalSpriteDefinition.php +++ b/src/Rendering/Sprites/GraphicalSpriteDefinition.php @@ -4,6 +4,7 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationSpriteAnchor; use Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect; +use InvalidArgumentException; /** Immutable graphical intent, independent of world position, Camera and transport. */ final readonly class GraphicalSpriteDefinition @@ -29,4 +30,33 @@ public function atFrame(int $frame): self return $this->sheet === null ? $this : new self($this->asset, $this->width, $this->height, $this->anchor, $this->layer, $this->sheet->sourceRect($frame), $this->sheet); } + + /** A single pose, optionally cropped from a sheet; no playback state is stored here. */ + public static function fromArray(array $data): self + { + $data += ['anchor' => 'bottom_center', 'layer' => 0]; + if (array_diff_key($data, array_flip(['asset', 'width', 'height', 'anchor', 'layer', 'sourceRect'])) !== [] + || !is_string($data['asset'] ?? null) || !is_string($data['anchor']) + || !is_int($data['width'] ?? null) || !is_int($data['height'] ?? null) || !is_int($data['layer'])) { + throw new InvalidArgumentException('Graphical sprite requires a string asset/anchor and integer width/height/layer, with no unknown fields.'); + } + $anchor = PresentationSpriteAnchor::tryFrom($data['anchor']); + if ($anchor === null) { + throw new InvalidArgumentException('Graphical sprite has an unsupported anchor.'); + } + $source = null; + if (array_key_exists('sourceRect', $data)) { + $rect = $data['sourceRect']; + if (!is_array($rect) || count($rect) !== 4) { + throw new InvalidArgumentException('Sprite sourceRect requires integer x, y, width and height.'); + } + foreach (['x', 'y', 'width', 'height'] as $key) { + if (!is_int($rect[$key] ?? null)) { + throw new InvalidArgumentException('Sprite sourceRect requires integer x, y, width and height.'); + } + } + $source = new SpriteSourceRect($rect['x'], $rect['y'], $rect['width'], $rect['height']); + } + return new self($data['asset'], $data['width'], $data['height'], $anchor, $data['layer'], $source); + } } diff --git a/src/Rendering/Transport/Enumerations/RendererEventType.php b/src/Rendering/Transport/Enumerations/RendererEventType.php new file mode 100644 index 00000000..37c9bf4b --- /dev/null +++ b/src/Rendering/Transport/Enumerations/RendererEventType.php @@ -0,0 +1,12 @@ + + */ + public function pollEvents(float $waitSeconds = 0.0): array; + + /** Graceful shutdown with bounded forced cleanup; returns the known exit code. */ + public function shutdown(): ?int; + public function getState(): RendererTransportState; + public function getExitCode(): ?int; + public function getDiagnostics(): string; +} diff --git a/src/Rendering/Transport/ProcessRendererTransport.php b/src/Rendering/Transport/ProcessRendererTransport.php index 90f542d0..ad398b03 100644 --- a/src/Rendering/Transport/ProcessRendererTransport.php +++ b/src/Rendering/Transport/ProcessRendererTransport.php @@ -3,10 +3,15 @@ namespace Ichiloto\Engine\Rendering\Transport; use Ichiloto\Engine\Diagnostics\LatencyTrace; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererTransportState; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProcessExitedException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererStartupException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; +use Ichiloto\Engine\Rendering\Transport\Interfaces\RendererTransportInterface; use Ichiloto\Engine\Rendering\Transport\Internal\RendererWriteBuffer; use InvalidArgumentException; use JsonException; @@ -39,6 +44,12 @@ final class ProcessRendererTransport implements RendererTransportInterface /** @var list */ private array $requiredCapabilities = []; + private float|int $now { + get { + return hrtime(true) / 1_000_000_000; + } + } + public function __construct(private readonly RendererProcessConfig $config) { $this->outbound = new RendererWriteBuffer($config->maxOutboundBytes); @@ -60,7 +71,7 @@ public function start(RendererSessionConfig $session): void $this->closeRequested = false; $this->terminationSignal = 0; $this->outbound = new RendererWriteBuffer($this->config->maxOutboundBytes); - $deadline = self::now() + $this->config->startupTimeout; + $deadline = $this->now + $this->config->startupTimeout; try { if (PHP_OS_FAMILY === 'Windows') { @@ -85,8 +96,8 @@ public function start(RendererSessionConfig $session): void // proc_open uses file-descriptor pipes, not buffered stdio FILE streams. // Their writes already go directly to the nonblocking descriptor. $this->queue($session->hello()); - while (self::now() < $deadline) { - $this->serviceIo(min(self::STATUS_INTERVAL, max(0.0, $deadline - self::now()))); + while ($this->now < $deadline) { + $this->serviceIo(min(self::STATUS_INTERVAL, max(0.0, $deadline - $this->now))); foreach ($this->events as $event) { if ($event->type === RendererEventType::ERROR) { throw new RendererStartupException('Renderer rejected startup: ' . substr($event->message ?? '', 0, 1024)); @@ -170,17 +181,17 @@ public function shutdown(): ?int if ($this->isRunning()) { $this->state = RendererTransportState::STOPPING; } - $deadline = self::now() + $this->config->shutdownTimeout; + $deadline = $this->now + $this->config->shutdownTimeout; $shutdown = (new RendererMessage(RendererMessageType::SHUTDOWN, protocol: $this->protocol))->encode(); $queued = false; try { - while ($this->process !== null && self::now() < $deadline) { + while ($this->process !== null && $this->now < $deadline) { if (! $queued && ! $this->closeRequested && $this->isRunning() && $this->outbound->pendingBytes() + strlen($shutdown) <= $this->config->maxOutboundBytes) { $this->outbound->append($shutdown); $queued = true; } - $this->serviceIo(min(self::STATUS_INTERVAL, max(0.0, $deadline - self::now()))); + $this->serviceIo(min(self::STATUS_INTERVAL, max(0.0, $deadline - $this->now))); if (($queued || $this->closeRequested) && $this->outbound->pendingBytes() === 0) { $this->closePipe(0); } @@ -226,7 +237,7 @@ public function __destruct() private function queue(RendererMessage $message): void { - $encoding = LatencyTrace::now(); + $encoding = LatencyTrace::getTimeNow(); try { $line = $message->encode(); } catch (JsonException $error) { @@ -423,7 +434,7 @@ private function failureWithDiagnostics(): RendererTransportException private function signalTermination(int $signal): void { $this->terminationSignal = $signal; - $this->terminationDeadline = self::now() + $this->config->terminationTimeout; + $this->terminationDeadline = $this->now + $this->config->terminationTimeout; if ($this->isRunning()) { @proc_terminate($this->process, $signal); } @@ -434,7 +445,7 @@ private function cleanupStep(float $wait): void { $read = array_values(array_intersect_key($this->pipes, [1 => true, 2 => true])); $write = $except = []; - $micros = (int) (min($wait, self::STATUS_INTERVAL, max(0.0, $this->terminationDeadline - self::now())) * 1_000_000); + $micros = (int) (min($wait, self::STATUS_INTERVAL, max(0.0, $this->terminationDeadline - $this->now)) * 1_000_000); if ($read !== []) { @stream_select($read, $write, $except, 0, $micros); } elseif ($micros > 0) { @@ -452,7 +463,7 @@ private function cleanupStep(float $wait): void if (! $this->isRunning() && ! isset($this->pipes[1]) && ! isset($this->pipes[2])) { $this->releaseProcess(); $this->terminationSignal = 0; - } elseif (self::now() >= $this->terminationDeadline) { + } elseif ($this->now >= $this->terminationDeadline) { if ($this->terminationSignal === self::TERMINATE_SIGNAL) { $this->signalTermination(self::KILL_SIGNAL); } else { @@ -505,11 +516,6 @@ private function releaseProcess(): void } } - private static function now(): float - { - return hrtime(true) / 1_000_000_000; - } - /** Return codes are converted to transport exceptions, not leaked to host error handlers. */ private static function performIo(callable $operation, ?string &$diagnostic = null): mixed { diff --git a/src/Rendering/Transport/RendererEvent.php b/src/Rendering/Transport/RendererEvent.php index 35750b02..f0d1f2af 100644 --- a/src/Rendering/Transport/RendererEvent.php +++ b/src/Rendering/Transport/RendererEvent.php @@ -2,6 +2,8 @@ namespace Ichiloto\Engine\Rendering\Transport; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use JsonException; use stdClass; @@ -15,6 +17,7 @@ private function __construct( public ?string $message = null, public RendererProtocolVersion $protocol = RendererProtocolVersion::V1, public array $capabilities = [], + public ?bool $active = null, ) { } @@ -45,6 +48,14 @@ public static function fromJson(string $line): self if ($type === RendererEventType::ERROR && ! is_string($message)) { throw new RendererProtocolException('Renderer error event requires a message string.'); } + $active = $object->active ?? null; + if ($type === RendererEventType::WINDOW_ACTIVATION + && ($protocol !== RendererProtocolVersion::V2 || !is_bool($active))) { + throw new RendererProtocolException('Window activation requires protocol 2 and a boolean active state.'); + } + if ($type !== RendererEventType::WINDOW_ACTIVATION && property_exists($object, 'active')) { + throw new RendererProtocolException('Only window activation events carry active state.'); + } $capabilities = property_exists($object, 'capabilities') ? $object->capabilities : []; if (!is_array($capabilities) || !array_is_list($capabilities) || count($capabilities) > 32) { throw new RendererProtocolException('Renderer capabilities must be a bounded list of strings.'); @@ -59,7 +70,7 @@ public static function fromJson(string $line): self throw new RendererProtocolException('Capabilities must be unique and only appear on ready.'); } return new self($type, $type === RendererEventType::KEY ? $key : null, - $type === RendererEventType::ERROR ? $message : null, $protocol, $capabilities); + $type === RendererEventType::ERROR ? $message : null, $protocol, $capabilities, $active); } /** @param list $required */ diff --git a/src/Rendering/Transport/RendererEventType.php b/src/Rendering/Transport/RendererEventType.php index 7911e564..ce09e437 100644 --- a/src/Rendering/Transport/RendererEventType.php +++ b/src/Rendering/Transport/RendererEventType.php @@ -2,10 +2,5 @@ namespace Ichiloto\Engine\Rendering\Transport; -enum RendererEventType: string -{ - case READY = 'ready'; - case KEY = 'key'; - case CLOSE_REQUESTED = 'close_requested'; - case ERROR = 'error'; -} +/** @deprecated Use Enumerations\RendererEventType; retained for existing transport integrations. */ +class_alias(Enumerations\RendererEventType::class, __NAMESPACE__ . '\\RendererEventType'); diff --git a/src/Rendering/Transport/RendererMessage.php b/src/Rendering/Transport/RendererMessage.php index b7a53a84..20495db2 100644 --- a/src/Rendering/Transport/RendererMessage.php +++ b/src/Rendering/Transport/RendererMessage.php @@ -2,6 +2,8 @@ namespace Ichiloto\Engine\Rendering\Transport; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use InvalidArgumentException; use JsonException; diff --git a/src/Rendering/Transport/RendererMessageType.php b/src/Rendering/Transport/RendererMessageType.php index 5771bde3..53ad0401 100644 --- a/src/Rendering/Transport/RendererMessageType.php +++ b/src/Rendering/Transport/RendererMessageType.php @@ -2,10 +2,5 @@ namespace Ichiloto\Engine\Rendering\Transport; -/** Wire vocabulary only; no frame generation or graphics models. */ -enum RendererMessageType: string -{ - case HELLO = 'hello'; - case FRAME = 'frame'; - case SHUTDOWN = 'shutdown'; -} +/** @deprecated Use Enumerations\RendererMessageType; retained for existing transport integrations. */ +class_alias(Enumerations\RendererMessageType::class, __NAMESPACE__ . '\\RendererMessageType'); diff --git a/src/Rendering/Transport/RendererProtocolVersion.php b/src/Rendering/Transport/RendererProtocolVersion.php index 81086e31..965f54c3 100644 --- a/src/Rendering/Transport/RendererProtocolVersion.php +++ b/src/Rendering/Transport/RendererProtocolVersion.php @@ -2,8 +2,5 @@ namespace Ichiloto\Engine\Rendering\Transport; -enum RendererProtocolVersion: int -{ - case V1 = 1; - case V2 = 2; -} +/** @deprecated Use Enumerations\RendererProtocolVersion; retained for existing transport integrations. */ +class_alias(Enumerations\RendererProtocolVersion::class, __NAMESPACE__ . '\\RendererProtocolVersion'); diff --git a/src/Rendering/Transport/RendererSessionConfig.php b/src/Rendering/Transport/RendererSessionConfig.php index 6363c436..2b1d0a57 100644 --- a/src/Rendering/Transport/RendererSessionConfig.php +++ b/src/Rendering/Transport/RendererSessionConfig.php @@ -2,6 +2,8 @@ namespace Ichiloto\Engine\Rendering\Transport; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use InvalidArgumentException; final readonly class RendererSessionConfig @@ -11,6 +13,8 @@ public const string GRAPHICAL_CANVAS = 'graphical_canvas'; public const string CANVAS_CLIP_OPACITY = 'canvas_clip_opacity'; public const string CANVAS_GLYPH_EFFECTS = 'canvas_glyph_effects'; + public const string CANVAS_COMPOSITING = 'canvas_compositing'; + public const string WINDOW_ACTIVATION = 'window_activation'; public string $assetRoot; /** @param list $requiredCapabilities */ @@ -23,13 +27,15 @@ public function __construct( ) { $allowed = $protocol === RendererProtocolVersion::V2 - ? [self::SPRITE_SOURCE_RECT, self::TILE_BATCHES, self::GRAPHICAL_CANVAS, self::CANVAS_CLIP_OPACITY, self::CANVAS_GLYPH_EFFECTS] : [self::SPRITE_SOURCE_RECT]; + ? [self::SPRITE_SOURCE_RECT, self::TILE_BATCHES, self::GRAPHICAL_CANVAS, self::CANVAS_CLIP_OPACITY, self::CANVAS_GLYPH_EFFECTS, + self::CANVAS_COMPOSITING, self::WINDOW_ACTIVATION] : [self::SPRITE_SOURCE_RECT]; foreach ($requiredCapabilities as $capability) { if (!in_array($capability, $allowed, true)) { throw new InvalidArgumentException('Unsupported renderer capability for this protocol.'); } } - if ((in_array(self::CANVAS_CLIP_OPACITY, $requiredCapabilities, true) || in_array(self::CANVAS_GLYPH_EFFECTS, $requiredCapabilities, true)) + if ((in_array(self::CANVAS_CLIP_OPACITY, $requiredCapabilities, true) || in_array(self::CANVAS_GLYPH_EFFECTS, $requiredCapabilities, true) + || in_array(self::CANVAS_COMPOSITING, $requiredCapabilities, true)) && !in_array(self::GRAPHICAL_CANVAS, $requiredCapabilities, true)) { throw new InvalidArgumentException('Canvas compositing capabilities require graphical_canvas.'); } diff --git a/src/Rendering/Transport/RendererTransportInterface.php b/src/Rendering/Transport/RendererTransportInterface.php index 2ee19e8c..51b83f96 100644 --- a/src/Rendering/Transport/RendererTransportInterface.php +++ b/src/Rendering/Transport/RendererTransportInterface.php @@ -2,21 +2,5 @@ namespace Ichiloto\Engine\Rendering\Transport; -interface RendererTransportInterface -{ - public function start(RendererSessionConfig $session): void; - public function isRunning(): bool; - public function send(RendererMessage $message): void; - - /** - * Service bounded I/O; zero wait by default. Continue pumping after OS exit to drain final events. - * @return list - */ - public function pollEvents(float $waitSeconds = 0.0): array; - - /** Graceful shutdown with bounded forced cleanup; returns the known exit code. */ - public function shutdown(): ?int; - public function getState(): RendererTransportState; - public function getExitCode(): ?int; - public function getDiagnostics(): string; -} +/** @deprecated Use Interfaces\RendererTransportInterface; retained for existing transport integrations. */ +class_alias(Interfaces\RendererTransportInterface::class, __NAMESPACE__ . '\\RendererTransportInterface'); diff --git a/src/Rendering/Transport/RendererTransportState.php b/src/Rendering/Transport/RendererTransportState.php index cb8d6654..b251fdf7 100644 --- a/src/Rendering/Transport/RendererTransportState.php +++ b/src/Rendering/Transport/RendererTransportState.php @@ -2,12 +2,5 @@ namespace Ichiloto\Engine\Rendering\Transport; -enum RendererTransportState -{ - case NEW; - case STARTING; - case RUNNING; - case STOPPING; - case STOPPED; - case FAILED; -} +/** @deprecated Use Enumerations\RendererTransportState; retained for existing transport integrations. */ +class_alias(Enumerations\RendererTransportState::class, __NAMESPACE__ . '\\RendererTransportState'); diff --git a/src/Scenes/AbstractScene.php b/src/Scenes/AbstractScene.php index 69436150..d06567b8 100644 --- a/src/Scenes/AbstractScene.php +++ b/src/Scenes/AbstractScene.php @@ -255,6 +255,10 @@ public function onScreenResize(int $width, int $height): void protected function initializeEventHandlers(): void { $this->modalEventHandler = function (ModalEvent $event) { + if ($this->sceneManager->currentScene !== $this) { + return; + } + switch ($event->modalEventType) { case ModalEventType::OPEN: $this->suspend(); diff --git a/src/Scenes/Battle/BattleLoader.php b/src/Scenes/Battle/BattleLoader.php index d61d737a..85561d81 100644 --- a/src/Scenes/Battle/BattleLoader.php +++ b/src/Scenes/Battle/BattleLoader.php @@ -66,8 +66,17 @@ public function newConfig( $settings = [ 'engine' => $systemData->getBattleEngineType()->value, 'activeTime' => (array) $systemData->getActiveTimeSettings(), + 'opening' => (array) $systemData->getOpeningSettings(), ]; + // Per-encounter overrides outrank project defaults, including legacy ATB keys. + foreach (['surpriseAttackChancePercent' => 'preemptiveChancePercent', 'backAttackChancePercent' => 'ambushChancePercent'] as $legacy => $shared) { + if (array_key_exists($legacy, $extraSettings['activeTime'] ?? [])) { + $settings['opening'][$shared] = $extraSettings['activeTime'][$legacy]; + } + } + $extraSettings['opening'] = array_replace($settings['opening'], $extraSettings['opening'] ?? []); + // A troop that declares its own battle music (e.g. a boss theme) // overrides the project-wide battle theme for this encounter. if ($troop->backgroundMusic !== null) { diff --git a/src/Scenes/Battle/BattleScene.php b/src/Scenes/Battle/BattleScene.php index 82e462b4..b8a41f65 100644 --- a/src/Scenes/Battle/BattleScene.php +++ b/src/Scenes/Battle/BattleScene.php @@ -4,13 +4,23 @@ use Ichiloto\Engine\Battle\BattleResult; use Ichiloto\Engine\Battle\Presentation\BattleCanvasUiAdapter; +use Ichiloto\Engine\Battle\Presentation\BattleCanvasLayout; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Battle\Presentation\GraphicalBattlePresentation; +use Ichiloto\Engine\Battle\Presentation\GraphicalBattleHud; use Ichiloto\Engine\Battle\Presentation\BattleHudSnapshot; +use Ichiloto\Engine\Battle\Presentation\BattleResultsSkin; +use Ichiloto\Engine\Battle\Presentation\BattleResultsPlayback; +use Ichiloto\Engine\Battle\Presentation\GraphicalBattleResults; +use Ichiloto\Engine\Battle\Presentation\BattlePauseSkin; +use Ichiloto\Engine\Battle\Presentation\GraphicalBattlePause; +use Ichiloto\Engine\IO\InputManager; +use Ichiloto\Engine\UI\Accessibility; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\TurnBasedEngine; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\PlayerActionState; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Ichiloto\Engine\Battle\Entry\BattleEntryRuleCatalog; use Ichiloto\Engine\Battle\Entry\BattleEntryRuleRunner; use Ichiloto\Engine\Core\GameState; @@ -18,6 +28,7 @@ use Ichiloto\Engine\Battle\UI\BattleResultWindow; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Entities\Party; +use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\Troop; use Ichiloto\Engine\Scenes\AbstractScene; use Ichiloto\Engine\Scenes\Battle\States\BattleEndState; @@ -30,7 +41,9 @@ use Ichiloto\Engine\Scenes\Interfaces\SceneConfigurationInterface; use Ichiloto\Engine\Scenes\SceneStateContext; use Override; +use Ichiloto\Engine\Util\Debug; use RuntimeException; +use Throwable; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Util\Config\ConfigStore; @@ -42,20 +55,72 @@ class BattleScene extends AbstractScene implements CanvasProviderInterface { public private(set) ?GraphicalBattlePresentation $graphicalPresentation = null; + public private(set) ?BattleCanvasLayout $battleUiLayout = null; + public private(set) ?BattleResultsSkin $resultsSkin = null; + public private(set) ?BattleResultsPlayback $resultsPlayback = null; + public private(set) ?BattlePauseSkin $pauseSkin = null; + private ?PresentationCanvas $resultsBattlefield = null; + + public function beginResults(): void + { + $rewards = $this->result?->rewards; + if ($rewards === null) { return; } + $this->resultsPlayback = new BattleResultsPlayback($rewards, Accessibility::prefersReducedMotion()); + $layout = $this->graphicalPresentation?->arena ?? $this->battleUiLayout; + if ($this->resultsSkin !== null && $layout !== null) { + $field = array_values(array_filter(BattleCanvasUiAdapter::collect($this, $layout, true), + static fn($layer) => $layer->id === 'battle-field')); + $this->resultsBattlefield = $this->graphicalPresentation?->frame() + ?? new PresentationCanvas($layout->width, $layout->height, textLayers: $field); + } + } + + public function endResults(): void + { + $this->resultsPlayback = null; + $this->resultsBattlefield = null; + } + + #[Override] + public function stop(): void + { + try { + $this->ui?->resumeTiming(discard: true); + if ($this->pauseState?->hasOwnedResources()) { $this->pauseState->exit(); } + $this->endResults(); + } finally { + parent::stop(); + } + } + + public function hasGraphicalResults(): bool + { + return $this->resultsPlayback !== null && $this->resultsSkin !== null && $this->resultsBattlefield !== null; + } public function getPresentationCanvas(): ?PresentationCanvas { - if ($this->graphicalPresentation === null || $this->state instanceof BattleStartState) { return null; } + if ($this->state instanceof BattlePauseState) { return $this->state->canvas(); } + if ($this->hasGraphicalResults()) { + return GraphicalBattleResults::frame($this->resultsBattlefield, $this->resultsSkin, $this->resultsPlayback); + } + $layout = $this->graphicalPresentation?->arena ?? $this->battleUiLayout; + if ($layout === null || $this->state instanceof BattleStartState) { return null; } $focus = null; - if ($this->graphicalPresentation->arena->skin !== null && $this->state instanceof BattleRunState) { + if ($layout->skin !== null && $this->state instanceof BattleRunState) { $engine = $this->getGame()->engine; if ($engine instanceof TurnBasedEngine && $engine->state instanceof PlayerActionState) { $focus = $engine->state->getSelectionMode(); } } - return $this->graphicalPresentation->frame($this->ui?->fieldWindow, - BattleCanvasUiAdapter::collect($this, $this->graphicalPresentation->arena), - $this->ui === null ? null : BattleHudSnapshot::fromScreen($this->ui), $focus); + $ui = BattleCanvasUiAdapter::collect($this, $layout, includeField: $this->graphicalPresentation === null); + $hud = $this->ui === null ? null : BattleHudSnapshot::fromScreen($this->ui); + if ($this->graphicalPresentation !== null) { + return $this->graphicalPresentation->frame($this->ui?->fieldWindow, $ui, $hud, $focus); + } + $composition = $hud === null ? null : GraphicalBattleHud::compose($layout, $hud, $focus, hrtime(true) / 1_000_000_000); + return new PresentationCanvas($layout->width, $layout->height, $composition?->images ?? [], + textLayers: [...$ui, ...($composition?->textLayers ?? [])]); } /** * @var BattleConfig|null The configuration of the scene. @@ -193,6 +258,34 @@ public function setState(BattleSceneState $state): void $this->state->enter(); } + /** Pause retains the running state and its engine context; it is not a new battle entry. */ + public function pauseBattle(): void + { + if (!$this->state instanceof BattleRunState || $this->pauseState === null + || $this->getGame()->hasStopped() || $this->getGame()->sceneManager->currentScene !== $this) { return; } + $frame = $this->getPresentationCanvas(); + $runtime = $this->getGame()->getRendererRuntime(); + if ($frame !== null && $this->pauseSkin !== null && $runtime !== null) { + GraphicalBattlePause::preflight($this->pauseSkin, $runtime->getAssetRoot(), $frame->images); + } + $this->pauseState->retainFrame($frame); + $this->state->suspend(); + $this->state = $this->pauseState; + $this->state->enter(); + } + + public function resumeBattle(): void + { + if (!$this->state instanceof BattlePauseState || $this->runState === null + || $this->state->menu === null + || $this->getGame()->hasStopped() || $this->getGame()->sceneManager->currentScene !== $this) { return; } + $this->state->exit(); + InputManager::resetState(true); + $this->state = $this->runState; + $this->state->resume(); + Console::recomposeFrame(fn() => $this->ui?->refresh()); + } + /** * @inheritDoc */ @@ -204,20 +297,72 @@ public function configure(SceneConfigurationInterface $config): void } $presentation = null; + $layout = null; + $resultsSkin = null; + $pauseSkin = null; $runtime = $this->getGame()->getRendererRuntime(); // Terminal play never loads the optional catalog or inspects any PNG. + // Graphical presentation is optional by contract: any failure while + // assembling it - a stale crop, a missing texture, an undersized skin, + // a missing capability - is logged loudly and the battle degrades to + // the terminal presentation instead of failing to start. Presentation + // must never change whether combat happens. if ($runtime !== null) { - $catalog = BattlePresentationCatalog::load($runtime->getAssetRoot()); + try { + $catalog = BattlePresentationCatalog::load($runtime->getAssetRoot()); + if ($catalog === null && isset($config->settings['battleArena'])) { + throw new RuntimeException('An explicit battleArena requires a battle presentation catalog.'); + } if ($catalog !== null) { $presentation = GraphicalBattlePresentation::prepare($config, $catalog, $runtime->getAssetRoot()); - foreach ($presentation?->requiredCapabilities() ?? [] as $capability) { + $layout = $presentation?->arena ?? $catalog->ui; + $resultsSkin = $catalog->results; + $pauseSkin = $catalog->pause; + if ($pauseSkin !== null) { + if ($layout === null || $layout->width < 520 || $layout->height < 320) { + throw new RuntimeException('The Pause skin requires a battle canvas of at least 520 by 320.'); + } + GraphicalBattlePause::preflight($pauseSkin, $runtime->getAssetRoot(), $presentation?->frame()->images ?? []); + } + if ($resultsSkin !== null && ($layout === null || $layout->width !== 1350 || $layout->height !== 720)) { + throw new RuntimeException('The Results skin requires a 1350 by 720 battle canvas.'); + } + if ($resultsSkin !== null) { + $resultsSkin = GraphicalBattleResults::prepare($resultsSkin, $runtime->getAssetRoot(), + $presentation?->frame()->images ?? [], + array_map(static fn(Character $member): string => $member->actorId, $config->party->members->toArray())); + } + if ($presentation === null && $layout !== null) { + GraphicalBattleHud::preflight($layout, $runtime->getAssetRoot()); + } + $capabilities = $presentation?->requiredCapabilities() + ?? ($layout === null ? [] : [RendererSessionConfig::SPRITE_SOURCE_RECT, ...$catalog->requiredCapabilities()]); + $capabilities = array_unique([...$capabilities, ...($resultsSkin === null ? [] : + [RendererSessionConfig::GRAPHICAL_CANVAS, RendererSessionConfig::SPRITE_SOURCE_RECT, + RendererSessionConfig::CANVAS_CLIP_OPACITY, RendererSessionConfig::CANVAS_GLYPH_EFFECTS])]); + foreach ($capabilities as $capability) { if (!$runtime->supports($capability)) { throw new RuntimeException("Configured graphical battles require the negotiated {$capability} capability."); } } } + } catch (Throwable $presentationFailure) { + Debug::error(sprintf( + 'Graphical battle presentation degraded to the terminal presentation: %s', + $presentationFailure->getMessage(), + )); + $presentation = null; + $layout = null; + $resultsSkin = null; + $pauseSkin = null; + } } $this->graphicalPresentation = $presentation; + $this->battleUiLayout = $layout; + $this->resultsSkin = $resultsSkin; + $this->pauseSkin = $pauseSkin; + if ($this->pauseState?->hasOwnedResources()) { $this->pauseState->exit(); } + $this->endResults(); // The field HUD only exists once the game scene has built it. A battle // started from anywhere else (the arena) has none to hide. @@ -248,7 +393,16 @@ public function configure(SceneConfigurationInterface $config): void */ public function update(): void { + if ($this->getGame()->hasStopped() || $this->getGame()->sceneManager->currentScene !== $this) { return; } + if ($this->state instanceof BattlePauseState) { + $this->state->execute($this->sceneStateContext); + return; + } parent::update(); + if ($this->getGame()->hasStopped() || $this->getGame()->sceneManager->currentScene !== $this) { return; } + if (!$this->state) { + throw new RuntimeException('Battle scene state is not initialized.'); + } $this->state->execute($this->sceneStateContext); } @@ -263,7 +417,15 @@ public function update(): void #[Override] public function resume(): void { + if ($this->getGame()->hasStopped() || $this->getGame()->sceneManager->currentScene !== $this) { return; } parent::resume(); + + if ($this->state instanceof BattlePauseState) { + if ($this->state->menu === null) { return; } + Console::recomposeFrame(fn() => $this->ui?->refresh()); + $this->state->resume(); + return; + } $this->state?->resume(); if (! $this->ui || $this->state instanceof BattleStartState) { @@ -323,7 +485,7 @@ public function onScreenResize(int $width, int $height): void $this->ui->refresh(); if ($this->state instanceof BattlePauseState) { - $this->state->enter(); + $this->state->render(); } } diff --git a/src/Scenes/Battle/States/BattlePauseState.php b/src/Scenes/Battle/States/BattlePauseState.php index 425f3e48..19adf819 100644 --- a/src/Scenes/Battle/States/BattlePauseState.php +++ b/src/Scenes/Battle/States/BattlePauseState.php @@ -2,9 +2,32 @@ namespace Ichiloto\Engine\Scenes\Battle\States; +use Ichiloto\Engine\Battle\Presentation\BattlePauseMenu; +use Ichiloto\Engine\Battle\Presentation\BattleCanvasUiAdapter; +use Ichiloto\Engine\Battle\Presentation\GraphicalBattlePause; +use Ichiloto\Engine\Battle\Presentation\PauseAction; +use Ichiloto\Engine\Core\Menu\MainMenu\ConfigMenu; +use Ichiloto\Engine\Core\Menu\MainMenu\MainMenuSettingsManager; +use Ichiloto\Engine\Core\Menu\MainMenu\Windows\ConfigSelectionWindow; +use Ichiloto\Engine\Core\Menu\MainMenu\Windows\ConfigDetailPanel; +use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; +use Ichiloto\Engine\IO\InputManager; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasTextLayer; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Scenes\SceneStateContext; +use Ichiloto\Engine\Scenes\Title\TitleScene; +use Ichiloto\Engine\UI\Accessibility; +use Ichiloto\Engine\UI\Presentation\ConfigMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvas; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\Util\Debug; +use RuntimeException; +use Throwable; /** * Represents the battle pause state. @@ -14,15 +37,63 @@ class BattlePauseState extends BattleSceneState { const string PAUSE_TEXT = "PAUSED"; + public private(set) ?BattlePauseMenu $menu = null; + public private(set) ?ConfigMenu $configMenu = null; + private ?PresentationCanvas $battlefield = null; + private bool $configReturned = false; + private bool $ownsOverlay = false; + private bool $ownsConfigLayer = false; + private ?MenuPresentationCatalog $configTheme = null; + private bool $configThemeLoaded = false; + private ?string $configPresentationError = null; + private float $configPresentationStart = 0; + + public function retainFrame(?PresentationCanvas $frame): void { $this->battlefield = $frame; } + + public function hasOwnedResources(): bool + { + return $this->menu !== null || $this->configMenu !== null || $this->battlefield !== null + || $this->ownsOverlay || $this->ownsConfigLayer; + } /** * @inheritDoc */ public function execute(?SceneStateContext $context = null): void { - if (Input::isButtonDown("pause")) { - $this->setState($this->scene->runState); + if (!$this->ownsInput()) { return; } + if ($this->configMenu !== null) { + $this->drawConfig(fn() => $this->configMenu?->update()); + if ($this->configReturned) { $this->returnFromConfig(); } + return; + } + $menu = $this->menu; + if ($menu === null) { return; } + $menu->reducedMotion = Accessibility::prefersReducedMotion(); + $ready = $menu->isReady(); + $action = $menu->tick(); + if ($action !== null) { + InputManager::resetState(true); + if (!$this->ownsInput()) { return; } + match ($action) { + PauseAction::RESUME => $this->scene->resumeBattle(), + PauseAction::CONFIG => $this->openConfig(), + PauseAction::TITLE => $this->scene->getGame()->sceneManager->loadScene(TitleScene::class), + PauseAction::EXIT => $this->scene->getGame()->quit(), + }; + return; + } + // The frame that finishes a transition cannot also activate the next view. + if (!$ready) { InputManager::resetState(); $this->render(); return; } + if (Input::isButtonDown('cancel')) { $menu->back(); } + elseif (Input::isButtonDown('pause')) { $menu->back(pauseShortcut: true); } + elseif (Input::isButtonDown('confirm')) { $menu->confirm(); } + else { + $axis = Input::getAxis($menu->confirmation === null ? AxisName::VERTICAL : AxisName::HORIZONTAL); + if ($axis !== 0.0) { $menu->navigate($axis > 0 ? 1 : -1); } } + InputManager::resetState(); + $this->render(); } /** @@ -30,13 +101,183 @@ public function execute(?SceneStateContext $context = null): void */ public function enter(): void { - $pauseTextLength = strlen(self::PAUSE_TEXT); - $leftMargin = intval((get_screen_width() - $pauseTextLength) / 2); - $topMargin = intval((get_screen_height() - 1) / 2); + $this->menu = $this->createMenu(); + $this->menu->open(); + InputManager::resetState(true); + $this->render(); + } + + protected function createMenu(): BattlePauseMenu { return new BattlePauseMenu(Accessibility::prefersReducedMotion()); } + + public function exit(): void + { + if (!$this->hasOwnedResources()) { return; } + $this->menu?->close(); + $this->menu = null; + $this->configMenu = null; + $this->configTheme = null; + $this->configThemeLoaded = false; + $this->configPresentationError = null; + $this->configReturned = false; + $this->battlefield = null; + try { + $this->releaseConfigLayer(); + } finally { + $this->releaseOverlay(); + } + } + + public function resume(): void { $this->render(); } + + /** Repaint and reposition, never re-enter or reset focus. */ + public function render(): void + { + if (!$this->ownsInput()) { return; } + if ($this->configMenu !== null) { + $this->releaseConfigLayer(); + $width = $this->configMenu->selection->getWidth(); + $height = $this->configMenu->selection->getHeight() + $this->configMenu->detail->getHeight(); + $x = max(0, intdiv(get_screen_width() - $width, 2)); + $y = max(0, intdiv(get_screen_height() - $height, 2)); + $this->configMenu->selection->setPosition(new Vector2($x, $y)); + $this->configMenu->detail->setPosition(new Vector2($x, $y + $this->configMenu->selection->getHeight())); + $this->drawConfig(fn() => $this->configMenu?->render()); + return; + } + $menu = $this->menu; + if ($menu === null || $menu->isClosed()) { return; } + $width = min(52, get_screen_width()); + $inside = $width - 2; + $center = static fn(string $label): string => '|' . str_pad($label, $inside, ' ', STR_PAD_BOTH) . '|'; + $lines = ['+' . str_repeat('-', $inside) . '+', $center($menu->heading()), $center('')]; + if ($menu->confirmation !== null) { $lines[] = $center(BattlePauseMenu::WARNING); $lines[] = $center(''); } + if ($menu->confirmation !== null) { + $half = intdiv($inside, 2); + $choices = []; + foreach ($menu->labels() as $index => $label) { + $line = str_pad($label, $index === 0 ? $half : $inside - $half, ' ', STR_PAD_BOTH); + if ($menu->selection === $index) { $line[1] = '>'; } + $choices[] = $line; + } + $lines[] = '|' . implode('', $choices) . '|'; + } else { + foreach ($menu->labels() as $index => $label) { + $line = $center($label); + if ($menu->selection === $index) { $line[2] = '>'; } + $lines[] = $line; + } + } + $lines[] = $center(''); + $lines[] = '+' . str_repeat('-', $inside) . '+'; + $this->ownsOverlay = true; + Console::replaceOverlay('battle-pause', $lines, max(0, intdiv(get_screen_width() - $width, 2)), + max(0, intdiv(get_screen_height() - count($lines), 2)), 10000); + } + + public function canvas(): ?PresentationCanvas + { + $field = $this->battlefield; + if ($field === null || $this->menu === null) { return null; } + if ($this->configMenu !== null) { + $config = $this->configCanvas($field); + if ($config !== null) { return $config; } + } + if ($this->configMenu === null && $this->scene->pauseSkin !== null) { + return GraphicalBattlePause::frame($field, $this->scene->pauseSkin, $this->menu); + } + $columns = get_screen_width(); + $rows = get_screen_height(); + $grid = new RendererGridConfig($columns, $rows, max(1, intdiv($field->width, $columns)), max(1, intdiv($field->height, $rows))); + $layers = []; + foreach (Console::presentationSnapshot()->textLayers as $layer) { + if ($layer->id !== ($this->configMenu === null ? 'battle-pause' : 'pause-config')) { continue; } + $layers[] = new CanvasTextLayer($layer->id, 10000, ($field->width - $grid->columns * $grid->cellWidth) / 2, + ($field->height - $grid->rows * $grid->cellHeight) / 2, $grid, BattleCanvasUiAdapter::opaqueRuns($layer->runs)); + } + return new PresentationCanvas($field->width, $field->height, $field->images, $field->indicators, [...$field->textLayers, ...$layers]); + } + + private function openConfig(): void + { + $this->releaseOverlay(); + $this->configTheme = null; + $this->configThemeLoaded = false; + $this->configPresentationError = null; + $this->configPresentationStart = hrtime(true) / 1e9; + $manager = new MainMenuSettingsManager(); + $width = min(110, get_screen_width()); + $height = min(32, get_screen_height()); + $this->configMenu = new ConfigMenu($manager, + new ConfigSelectionWindow(new Rect(0, 0, $width, $height - 5), $manager), + new ConfigDetailPanel(new Rect(0, 0, $width, 5)), function (): void { $this->configReturned = true; }); + $this->render(); + $this->drawConfig(fn() => $this->configMenu?->enter()); + } + + /** Optional menu artwork is independent of the battle skin; retain the existing Console fallback. */ + private function configCanvas(PresentationCanvas $field): ?PresentationCanvas + { + $runtime = $this->scene->getGame()->getRendererRuntime(); + if ($runtime === null || $this->configMenu === null) { return null; } + try { + if (!$this->configThemeLoaded) { + $this->configThemeLoaded = true; + $theme = MenuPresentationCatalog::load($runtime->getAssetRoot()); + if ($theme !== null) { + foreach (MenuPresentationCatalog::CAPABILITIES as $capability) { + if (!$runtime->supports($capability)) { throw new RuntimeException("Config renderer lacks {$capability}."); } + } + } + $this->configTheme = $theme; + } + if ($this->configTheme === null) { return null; } + $config = ConfigMenuPresentation::compose($this->configMenu, $this->configTheme, + max(0, hrtime(true) / 1e9 - $this->configPresentationStart), $field->width, $field->height); + return MenuCanvas::overlay($field, $config, $this->configTheme); + } catch (Throwable $error) { + if ($this->configPresentationError !== $error->getMessage()) { + Debug::error('Config presentation degraded to terminal: ' . $error->getMessage()); + $this->configPresentationError = $error->getMessage(); + } + return null; + } + } + + private function returnFromConfig(): void + { + $this->configReturned = false; + $this->configMenu = null; + $this->configTheme = null; + InputManager::resetState(true); + if (!$this->ownsInput()) { return; } + $this->releaseConfigLayer(); + $this->menu?->open(1); + $this->render(); + } + + private function drawConfig(callable $draw): void + { + $this->ownsConfigLayer = true; + Console::withLayer('pause-config', $draw, 10000); + } - // Pause text is part of the battle frame. Keep it in the canonical - // buffer and on the same ordered descriptor as every other draw so a - // later resume can erase it deterministically. - Console::write(self::PAUSE_TEXT, $leftMargin, $topMargin); + private function releaseConfigLayer(): void + { + if (!$this->ownsConfigLayer) { return; } + Console::removeLayer('pause-config', repaint: !$this->scene->getGame()->hasStopped()); + $this->ownsConfigLayer = false; + } + + private function releaseOverlay(): void + { + if (!$this->ownsOverlay) { return; } + Console::removeOverlay('battle-pause'); + $this->ownsOverlay = false; + } + + private function ownsInput(): bool + { + return !$this->scene->getGame()->hasStopped() && $this->scene->state === $this + && $this->scene->getGame()->sceneManager->currentScene === $this->scene; } } diff --git a/src/Scenes/Battle/States/BattleRunState.php b/src/Scenes/Battle/States/BattleRunState.php index 99991e5d..a642b4a0 100644 --- a/src/Scenes/Battle/States/BattleRunState.php +++ b/src/Scenes/Battle/States/BattleRunState.php @@ -24,6 +24,9 @@ class BattleRunState extends BattleSceneState */ protected ?BattleEngineContextInterface $battleEngineContext = null; + public function suspend(): void { $this->ui->pauseTiming(); } + public function resume(): void { $this->ui->resumeTiming(); } + /** * @inheritDoc */ @@ -55,10 +58,10 @@ public function enter(): void strval($activeTimeSettings['mode'] ?? 'wait'), max(1.0, floatval($activeTimeSettings['baseFillRate'] ?? 35)), max(0.0, floatval($activeTimeSettings['speedFactorPercent'] ?? 100)) / 100, - max(0.0, floatval($activeTimeSettings['openingVariance'] ?? 24)), - max(0.0, floatval($activeTimeSettings['openingSpeedFactorPercent'] ?? 250)) / 100, - min(100, max(0, intval($activeTimeSettings['surpriseAttackChancePercent'] ?? 8))), - min(100, max(0, intval($activeTimeSettings['backAttackChancePercent'] ?? 6))), + max(0.0, floatval($activeTimeSettings['openingVariance'] ?? ActiveTimeBattleConfig::DEFAULT_OPENING_VARIANCE)), + max(0.0, floatval($activeTimeSettings['openingSpeedFactorPercent'] ?? ActiveTimeBattleConfig::DEFAULT_OPENING_SPEED_FACTOR * 100)) / 100, + min(100, max(0, intval($settings['opening']['preemptiveChancePercent'] ?? $activeTimeSettings['surpriseAttackChancePercent'] ?? 8))), + min(100, max(0, intval($settings['opening']['ambushChancePercent'] ?? $activeTimeSettings['backAttackChancePercent'] ?? 6))), $settings, )); } else { @@ -81,9 +84,12 @@ public function enter(): void public function execute(?SceneStateContext $context = null): void { $this->handleActions(); + if ($this->scene->getGame()->hasStopped() || $this->scene->state !== $this + || $this->scene->getGame()->sceneManager->currentScene !== $this->scene) { return; } $this->engine->run($this->battleEngineContext ?? throw new RuntimeException('Battle engine context is not set.')); - if ($this->scene->getGame()->sceneManager->currentScene !== $this->scene) { + if ($this->scene->getGame()->hasStopped() || $this->scene->state !== $this + || $this->scene->getGame()->sceneManager->currentScene !== $this->scene) { return; } @@ -98,10 +104,11 @@ protected function handleActions(): void { if (Input::isButtonDown('quit')) { $this->scene->getGame()->quit(); + return; } if (Input::isButtonDown('pause')) { - $this->setState($this->scene->pauseState); + $this->scene->pauseBattle(); } } -} \ No newline at end of file +} diff --git a/src/Scenes/Battle/States/BattleVictoryState.php b/src/Scenes/Battle/States/BattleVictoryState.php index 8bbe6a60..e9077e4c 100644 --- a/src/Scenes/Battle/States/BattleVictoryState.php +++ b/src/Scenes/Battle/States/BattleVictoryState.php @@ -3,18 +3,32 @@ namespace Ichiloto\Engine\Scenes\Battle\States; use Ichiloto\Engine\IO\Input; +use Ichiloto\Engine\Core\Time; +use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\Scenes\SceneStateContext; use RuntimeException; class BattleVictoryState extends BattleSceneState { + private bool $resetClockBoundary = true; + + public function resume(): void + { + $this->resetClockBoundary = true; + } /** * @inheritDoc */ public function enter(): void { + $this->resetClockBoundary = true; $this->playVictoryMusic(); $this->ui->hideControls(); + $this->scene->beginResults(); + if ($this->scene->resultsPlayback !== null) { + if (!$this->scene->hasGraphicalResults()) { $this->scene->resultWindow?->displayPlayback($this->scene->resultsPlayback); } + return; + } $this->scene->resultWindow?->display($this->scene->result ?? throw new RuntimeException('Battle result is not set.')); } @@ -44,6 +58,20 @@ protected function playVictoryMusic(): void */ public function execute(?SceneStateContext $context = null): void { + if (($playback = $this->scene->resultsPlayback) !== null) { + $playback->update($this->resetClockBoundary ? 0 : max(0, Time::getDeltaTime())); + $this->resetClockBoundary = false; + $direction = Input::getAxis(AxisName::VERTICAL); + if ($direction !== 0.0) { $playback->navigate($direction > 0 ? 1 : -1); } + $confirmed = Input::isButtonDown('action') && $playback->confirm(); + if ($confirmed || $playback->isFinished()) { + $this->scene->shouldLoadGameOver = false; + $this->setState($this->scene->endState); + return; + } + if (!$this->scene->hasGraphicalResults()) { $this->scene->resultWindow?->displayPlayback($playback); } + return; + } $revealedThisFrame = $this->scene->resultWindow?->update() ?? false; if (Input::isButtonDown('action')) { @@ -65,5 +93,6 @@ public function execute(?SceneStateContext $context = null): void public function exit(): void { $this->scene->resultWindow?->erase(); + $this->scene->endResults(); } } diff --git a/src/Scenes/Game/GameScene.php b/src/Scenes/Game/GameScene.php index 4b034ec4..e47c1fe6 100644 --- a/src/Scenes/Game/GameScene.php +++ b/src/Scenes/Game/GameScene.php @@ -65,14 +65,21 @@ use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Debug; use Override; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; /** * Class GameScene. Represents the game scene. * * @package Ichiloto\Engine\Scenes\Game */ -class GameScene extends AbstractScene implements GraphicalSpriteProviderHostInterface, GraphicalTileProviderHostInterface +class GameScene extends AbstractScene implements GraphicalSpriteProviderHostInterface, GraphicalTileProviderHostInterface, CanvasProviderInterface { + public function getPresentationCanvas(): ?PresentationCanvas + { + return $this->state instanceof CanvasProviderInterface ? $this->state->getPresentationCanvas() : null; + } + private ?string $inheritedMapMusic = null; private ?string $lastFieldMusic = null; private bool $fieldMusicApplied = false; @@ -84,7 +91,12 @@ public function getGraphicalSpriteProviders(): iterable { if ($this->hasGraphicalFieldPresentation()) { // Dialogue borrows field input; it does not replace field presentation. - yield $this->player; + if (!($this->cinematicStage?->suppresses($this->player) ?? false)) { + yield $this->player; + } + foreach ($this->cinematicStage?->all() ?? [] as $actor) { + yield $actor; + } } } @@ -97,7 +109,7 @@ public function getGraphicalTileBatches(): array private function hasGraphicalFieldPresentation(): bool { return $this->state instanceof FieldState && $this->state === $this->fieldState - && $this->cinematicController?->active() === null && (bool)$this->player?->isActive; + && (bool)$this->player?->isActive; } /** @@ -227,6 +239,8 @@ public function __construct(SceneManager $sceneManager, string $name) protected(set) bool $hasDeferredAutoSave = false; /** Whether the destination map still needs its automatic entry triggers evaluated. */ protected bool $hasPendingAutomaticTriggerEvaluation = false; + /** Prevent teardown callbacks from starting fresh field work. */ + public private(set) bool $isStopping = false; /** * @var SkitManager|null The skit manager. */ @@ -413,6 +427,9 @@ public function getBackgroundMusic(): ?string */ public function refreshFieldMusic(bool $force = false, bool $keepSilence = false): void { + if ($this->isStopping) { + return; + } $this->fieldMusicPending = $this->fieldMusicPending || $force; if ($this->mapManager === null) { return; @@ -479,6 +496,7 @@ public function releaseFieldMusic(): void */ public function loadMap(string $mapFilename, Player $player): void { + $this->cinematicStage?->clear(); $this->currentMapId = preg_replace('/(\.(data|map|event))?\.php$/', '', $mapFilename) ?: $mapFilename; $this->mapManager->loadMap($mapFilename, $player); } @@ -503,9 +521,19 @@ public function setState(GameSceneState $state): void */ public function update(): void { + if ($this->isStopping) { + return; + } $this->player?->advanceGraphicalAnimation(max(0.0, Time::getDeltaTime())); + $this->cinematicStage?->advanceGraphicalAnimation(max(0.0, Time::getDeltaTime())); parent::update(); + if ($this->isStopping || $this->sceneManager->currentScene !== $this) { + return; + } $this->state->execute($this->sceneStateContext); + if ($this->isStopping || $this->sceneManager->currentScene !== $this) { + return; + } $this->refreshFieldMusic(); } @@ -515,6 +543,10 @@ public function update(): void #[Override] public function resume(): void { + if ($this->isStopping) { + return; + } + parent::resume(); $this->state->resume(); } @@ -530,6 +562,44 @@ public function suspend(): void $this->state->suspend(); } + #[Override] + public function start(): void + { + $this->isStopping = false; + parent::start(); + } + + #[Override] + public function stop(): void + { + if ($this->isStopping) { + return; + } + + $this->isStopping = true; + $this->hasDeferredAutoSave = false; + $this->hasPendingAutomaticTriggerEvaluation = false; + $failure = null; + foreach ([ + fn() => $this->cinematicController?->shutdown(), + fn() => $this->eventInterpreter?->failActiveSession('Event interrupted by field shutdown.'), + fn() => $this->cinematicStage?->clear(), + fn() => $this->cinematicPresentation?->clear(), + fn() => parent::stop(), + ] as $cleanup) { + try { + $cleanup(); + } catch (\Throwable $error) { + $failure ??= $error; + } + } + $this->started = false; + + if ($failure !== null) { + throw $failure; + } + } + /** * Creates a snapshot of the live field state for persistence. * @@ -650,7 +720,7 @@ protected function finalizePlayerTransfer(): void /** Runs deferred destination triggers once no event session owns the interpreter. */ protected function evaluatePendingAutomaticTriggers(): void { - if (! $this->hasPendingAutomaticTriggerEvaluation || $this->hasUnstableEventSession()) { + if ($this->isStopping || ! $this->hasPendingAutomaticTriggerEvaluation || $this->hasUnstableEventSession()) { return; } @@ -669,7 +739,7 @@ protected function evaluatePendingAutomaticTriggers(): void */ public function autoSave(): void { - if (! config(ProjectConfig::class, 'save.autosave', false)) { + if ($this->isStopping || ! config(ProjectConfig::class, 'save.autosave', false)) { return; } @@ -700,6 +770,10 @@ public function startEventScript( array $origin = [], ): ?EventExecutionSession { + if ($this->isStopping) { + return null; + } + return $this->eventInterpreter?->run($commands, $scriptId, $completionTarget, $origin); } @@ -709,6 +783,10 @@ public function startCinematic( ?EventSessionCompletionTargetInterface $completionTarget = null, ): ?EventExecutionSession { + if ($this->isStopping) { + return null; + } + $definition = is_string($cinematic) ? (new CinematicLibrary())->load($cinematic) : $cinematic; @@ -773,6 +851,13 @@ public function onEventSessionStarted(EventExecutionSession $session): void */ public function onEventSessionFinished(EventExecutionSession $session, bool $completed): void { + if ($this->isStopping || (isset($this->sceneManager) && $this->sceneManager->currentScene !== $this)) { + if (! $completed) { + $this->hasDeferredAutoSave = false; + } + return; + } + $this->refreshFieldMusic(); Debug::info(sprintf( 'Event session %d %s.', @@ -808,7 +893,8 @@ public function onEventSessionFinished(EventExecutionSession $session, bool $com */ public function restoreFieldAfterOverlay(): void { - if ($this->state === $this->fieldState) { + if (! $this->isStopping && $this->state === $this->fieldState + && (! isset($this->sceneManager) || $this->sceneManager->currentScene === $this)) { $this->fieldState?->renderTheField(); $this->fieldPresentationIsDirty = false; } @@ -851,7 +937,8 @@ public function requestFieldPresentationReconciliation(): void public function reconcileFieldPresentation(): void { if ( - ! $this->fieldPresentationIsDirty + $this->isStopping + || ! $this->fieldPresentationIsDirty || $this->state !== $this->fieldState || $this->hasUnstableEventSession() ) { @@ -884,7 +971,7 @@ public function onScreenResize(int $width, int $height): void { parent::onScreenResize($width, $height); - if ($this->player) { + if ($this->player && $this->camera->followsPlayer) { $this->camera->resetPosition($this->player); } diff --git a/src/Scenes/Game/States/AbilityMenuState.php b/src/Scenes/Game/States/AbilityMenuState.php index 2c2a7e3e..0222e0ad 100644 --- a/src/Scenes/Game/States/AbilityMenuState.php +++ b/src/Scenes/Game/States/AbilityMenuState.php @@ -20,14 +20,86 @@ use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\UI\Presentation\MenuRow; +use Ichiloto\Engine\UI\Presentation\MenuRowKind; +use Ichiloto\Engine\UI\Presentation\MenuRowValue; +use Ichiloto\Engine\UI\Presentation\SkillMenuContent; +use Ichiloto\Engine\UI\Presentation\SkillMenuPresentation; /** * Displays the field ability-management screen for a single party member. * * @package Ichiloto\Engine\Scenes\Game\States */ -class AbilityMenuState extends GameSceneState +class AbilityMenuState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + $content = $this->getPresentationContent(); + return $content === null ? null : SkillMenuPresentation::compose($content, $theme, $time); + } + + public function getPresentationContent(): ?SkillMenuContent + { + $actor = $this->character; + if ($actor === null) { return null; } + $tab = $this->tabs[$this->activeTabIndex] ?? 'Ready'; + $index = $this->getActiveEntryIndex(); + $book = $actor->abilityBook; + $events = $this->getGameScene()->storyEvents; + $time = $this->getElapsedPlayTime(); + $rows = []; + if ($tab === 'Sort') { + foreach ($this->sortOptions as $i => $label) { + $rows[] = new MenuRow((string)$i, $label, kind: MenuRowKind::COMMAND, + selected: $label === $book->getSortOrder()->value, focused: $i === $index); + } + } else { + foreach ($tab === 'Learn' ? $book->getLearnableAbilities() : $book->getLearnedAbilities() as $i => $entry) { + $skill = $entry instanceof LearnableAbility ? $entry->skill : $entry; + $values = $entry instanceof LearnableAbility + ? [new MenuRowValue($entry->getStatusLabel($actor, $this->party, $events, $time))] + : [new MenuRowValue($this->formatOccasionLabel($skill->occasion)), new MenuRowValue($skill->cost . ' MP')]; + $rows[] = new MenuRow((string)$i, $skill->name, $values, icon: 'skill.ability', + selected: $i === $index, focused: $i === $index); + } + } + $learnable = $tab === 'Learn' ? $this->getActiveLearnableAbility() : null; + $skill = $tab === 'Learn' ? $learnable?->skill : ($tab === 'Ready' ? $this->getActiveReadyAbility() : null); + $fields = []; + $detail = ''; + $empty = $tab === 'Learn' ? 'No discovered abilities.' : 'No learned abilities.'; + if ($skill !== null) { + $fields = ['MP Cost' => (string)$skill->cost, 'Occasion' => $this->formatOccasionLabel($skill->occasion), + 'Scope' => $skill->scope->side->value]; + if ($learnable !== null) { + $fields['Status'] = $learnable->getStatusLabel($actor, $this->party, $events, $time); + $fields['Source'] = $learnable->note !== '' ? $learnable->note : 'Unrecorded'; + $detail = $learnable->requirement->describeProgress($actor, $this->party, $events, $time); + if ($detail === '') { $detail = 'No additional requirements.'; } + } + } elseif ($tab === 'Sort') { + $fields = ['Current' => $book->getSortOrder()->value]; + $detail = "A-Z keeps learned abilities alphabetical.\nZ-A reverses the learned ability list.\nApply a sort order to reorganize the Ready tab."; + } else { + $detail = $tab === 'Learn' ? 'Discovered abilities and their unlock requirements will appear here.' + : 'Battle abilities will appear here once this character has unlocked or learned them.'; + } + return new SkillMenuContent($actor, 'Abilities', $this->tabs, $this->activeTabIndex, + ['Learned Abilities' => (string)count($book->getLearnedAbilities()), + 'Ready to Learn' => (string)$book->getReadyToLearnCount($actor, $this->party, $events, $time), + 'Current Order' => $book->getSortOrder()->value], + $skill?->name ?? ($tab === 'Sort' ? 'Ability Order' : $empty), $fields, $detail, $rows, + $this->getListPanelTitle(), $index, $empty, $this->getPresentationDescription(), $this->statusMessage, + match ($tab) { 'Learn' => 'Learn', 'Sort' => 'Apply', default => 'View' }, infoModel: $this->menuInfoText); + } + protected const int ABILITY_MENU_WIDTH = 110; protected const int ABILITY_MENU_HEIGHT = 35; protected const int SUMMARY_PANEL_HEIGHT = 7; @@ -103,12 +175,14 @@ class AbilityMenuState extends GameSceneState * @var string|null The latest short status message. */ protected ?string $statusMessage = null; + private array $infoSelection = []; /** * @inheritDoc */ public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->character ??= $this->getGameScene()->party->leader; @@ -207,6 +281,7 @@ protected function normalizeSelectionIndexes(): void */ protected function refreshUI(): void { + $this->syncInfoSelection(); $this->summaryPanel?->setContent($this->buildSummaryLines()); $this->summaryPanel?->render(); @@ -218,7 +293,6 @@ protected function refreshUI(): void $this->listPanel?->setTitle($this->getListPanelTitle()); $this->listPanel?->setEntries($this->buildListEntries(), $this->getActiveEntryIndex()); - $this->infoPanel?->setHelp($this->getInfoHelpText()); $this->infoPanel?->setContent($this->buildInfoLines()); $this->infoPanel?->render(); } @@ -553,23 +627,27 @@ protected function getInfoHelpText(): string */ protected function buildInfoLines(): array { - $availableLines = self::INFO_PANEL_HEIGHT - 2; - $availableWidth = self::ABILITY_MENU_WIDTH - 4; - $description = match ($this->tabs[$this->activeTabIndex] ?? 'Ready') { + $page = $this->menuInfoText->getPage($this->getPresentationDescription(), $this->statusMessage, + max(1, ($this->infoPanel?->getContentWidth() ?? self::ABILITY_MENU_WIDTH - 2) - 2)); + $this->infoPanel?->setHelp($page->total > 2 ? $page->range() : $this->getInfoHelpText()); + return array_pad($page->lines, 2, ''); + } + + private function syncInfoSelection(): void + { + $selection = [$this->character, $this->activeTabIndex, $this->getActiveEntryIndex()]; + if ($selection !== $this->infoSelection) { $this->menuInfoText->reset(); } + $this->infoSelection = $selection; + } + + private function getPresentationDescription(): string + { + return match ($this->tabs[$this->activeTabIndex] ?? 'Ready') { 'Ready' => $this->getActiveReadyAbility()?->description ?? 'Review the abilities this character can use in battle.', 'Learn' => $this->getActiveLearnableAbility()?->skill->description ?? 'Review discovered abilities and what each one requires to learn.', 'Sort' => 'Reorder learned abilities to fit how you like to browse battle commands.', default => '', }; - - $lines = explode("\n", wrap_text($description, max(1, $availableWidth))); - $lines = array_slice($lines, 0, $availableLines); - - if ($this->statusMessage !== null) { - $lines[$availableLines - 1] = TerminalText::truncateToWidth($this->statusMessage, $availableWidth); - } - - return array_slice(array_pad($lines, $availableLines, ''), 0, $availableLines); } /** @@ -577,6 +655,13 @@ protected function buildInfoLines(): array */ public function execute(?SceneStateContext $context = null): void { + $this->syncInfoSelection(); + if ($this->handleMenuInfoInput($this->getPresentationDescription(), $this->statusMessage, + max(1, ($this->infoPanel?->getContentWidth() ?? self::ABILITY_MENU_WIDTH - 2) - 2))) { + $this->infoPanel?->setContent($this->buildInfoLines()); + $this->infoPanel?->render(); + return; + } if ($this->handleCharacterCycling()) { return; } diff --git a/src/Scenes/Game/States/ControlsMenuState.php b/src/Scenes/Game/States/ControlsMenuState.php index 4022ac52..9d714c31 100644 --- a/src/Scenes/Game/States/ControlsMenuState.php +++ b/src/Scenes/Game/States/ControlsMenuState.php @@ -5,12 +5,19 @@ use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\IO\ActionHints; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Enumerations\KeyCode; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\IO\InputBindings; use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\Scenes\SceneStateContext; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\UI\Presentation\ControlsMenuContent; +use Ichiloto\Engine\UI\Presentation\ControlsMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; use Ichiloto\Engine\UI\SelectionStyle; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; @@ -24,8 +31,25 @@ * * @package Ichiloto\Engine\Scenes\Game\States */ -class ControlsMenuState extends GameSceneState +class ControlsMenuState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + return ControlsMenuPresentation::compose($this->getPresentationContent(), $theme, $time); + } + + public function getPresentationContent(): ControlsMenuContent + { + $rows = []; + foreach ($this->rows as $row) { + $rows[] = ['action' => $row['action'], 'description' => $row['description'], + 'keys' => $this->bindings->describeKeys($row['action']), + 'control' => ActionHints::resolve($row['action'], $row['description'])->control]; + } + return new ControlsMenuContent($rows, $this->activeIndex, $this->listening, $this->status); + } protected const int MENU_WIDTH = 110; protected const int LIST_PANEL_HEIGHT = 24; protected const int INFO_PANEL_HEIGHT = 4; @@ -62,13 +86,14 @@ class ControlsMenuState extends GameSceneState */ public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->bindings = new InputBindings(); $this->activeIndex = 0; $this->scrollOffset = 0; $this->listening = false; - $this->status = 'Select an action and press enter to rebind it.'; + $this->status = 'Select an action to rebind. R restores defaults.'; $this->reloadRows(); $this->calculateMargins(); $this->initializeUI(); @@ -86,9 +111,14 @@ public function execute(?SceneStateContext $context = null): void return; } - $this->handleNavigation(); + if (Input::isButtonDown('back') || Input::isButtonDown('cancel')) { + InputManager::resetState(true); + $this->setState($this->getGameScene()->mainMenuState); - if (Input::isButtonDown('confirm')) { + return; + } + + if (Input::isButtonDown('confirm') && $this->activeRow() !== null) { $this->listening = true; $this->status = sprintf('Press a key for "%s". Escape cancels.', $this->activeRow()['action'] ?? ''); // The confirm key is still down; ignore it so it does not bind itself. @@ -103,14 +133,13 @@ public function execute(?SceneStateContext $context = null): void ? 'Controls restored to the defaults.' : 'Controls restored for this session; the file could not be written.'; $this->reloadRows(); + InputManager::resetState(true); $this->refreshUI(); return; } - if (Input::isButtonDown('back') || Input::isButtonDown('cancel')) { - $this->setState($this->getGameScene()->mainMenuState); - } + $this->handleNavigation(); } /** @@ -130,6 +159,7 @@ protected function captureBinding(): void if ($key === KeyCode::ESCAPE) { $this->status = 'Rebinding cancelled.'; + InputManager::resetState(true); $this->refreshUI(); return; @@ -137,9 +167,13 @@ protected function captureBinding(): void $action = $this->activeRow()['action'] ?? ''; - $this->status = $this->bindings->rebind($action, $key) + $persisted = $this->bindings->rebind($action, $key); + $applied = (InputManager::getBindings()[$action]['keys'] ?? null) === [$key]; + $this->status = $persisted ? sprintf('%s is now bound to %s.', ucfirst(str_replace('_', ' ', $action)), $key->name) - : sprintf('%s could not be rebound.', ucfirst(str_replace('_', ' ', $action))); + : ($applied ? sprintf('%s is bound to %s for this session; the file could not be written.', + ucfirst(str_replace('_', ' ', $action)), $key->name) + : sprintf('%s could not be rebound.', ucfirst(str_replace('_', ' ', $action)))); $this->reloadRows(); InputManager::resetState(true); diff --git a/src/Scenes/Game/States/EquipmentMenuState.php b/src/Scenes/Game/States/EquipmentMenuState.php index 19d21257..302e36af 100644 --- a/src/Scenes/Game/States/EquipmentMenuState.php +++ b/src/Scenes/Game/States/EquipmentMenuState.php @@ -8,6 +8,7 @@ use Ichiloto\Engine\Core\Menu\EquipmentMenu\EquipmentMenu; use Ichiloto\Engine\Core\Menu\EquipmentMenu\Modes\EquipmentMenuMode; use Ichiloto\Engine\Core\Menu\EquipmentMenu\Modes\EquipmentMenuCommandSelectionMode; +use Ichiloto\Engine\Core\Menu\EquipmentMenu\Modes\EquipmentSelectionMode; use Ichiloto\Engine\Core\Menu\EquipmentMenu\Windows\CharacterDetailPanel; use Ichiloto\Engine\Core\Menu\EquipmentMenu\Windows\EquipmentAssignmentPanel; use Ichiloto\Engine\Core\Menu\EquipmentMenu\Windows\EquipmentCommandPanel; @@ -21,14 +22,25 @@ use Ichiloto\Engine\Scenes\SceneStateContext; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\UI\Presentation\CharacterMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; /** * Represents the equipment menu state. * * @package Ichiloto\Engine\Scenes\Game\States */ -class EquipmentMenuState extends GameSceneState +class EquipmentMenuState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + return CharacterMenuPresentation::equipment($this, $this->mode, $theme, $time); + } /** * The width and height of the equipment menu. */ @@ -122,17 +134,46 @@ class EquipmentMenuState extends GameSceneState * @var EquipmentMenuMode|null The equipment menu mode. */ protected ?EquipmentMenuMode $mode = null; + private array $infoSelection = []; /** * @inheritDoc */ public function execute(?SceneStateContext $context = null): void { + $this->syncInfoSelection(); + if ($this->handleMenuInfoInput($this->equipmentInfoPanel?->getPresentationText() ?? '', + columns: max(1, ($this->equipmentInfoPanel?->getContentWidth() ?? 3) - 2))) { + $this->refreshInfoText(); + return; + } if ($this->handleCharacterCycling()) { + $this->refreshInfoText(); return; } $this->mode->update(); + $this->refreshInfoText(); + } + + private function syncInfoSelection(): void + { + $selection = [$this->character, $this->mode, $this->equipmentMenu?->activeIndex, + $this->equipmentAssignmentPanel?->activeSlotIndex, + $this->mode instanceof EquipmentSelectionMode ? $this->mode->getPresentationIndex() : null]; + if ($selection !== $this->infoSelection) { $this->menuInfoText->reset(); } + $this->infoSelection = $selection; + } + + private function refreshInfoText(): void + { + if ($this->equipmentInfoPanel === null) { return; } + $this->syncInfoSelection(); + $page = $this->menuInfoText->getPage($this->equipmentInfoPanel->getPresentationText(), null, + max(1, $this->equipmentInfoPanel->getContentWidth() - 2)); + $this->equipmentInfoPanel->setContent(array_pad($page->lines, 2, '')); + $this->equipmentInfoPanel->setHelp($page->total > 2 ? $page->range() : ''); + $this->equipmentInfoPanel->render(); } /** @@ -140,6 +181,7 @@ public function execute(?SceneStateContext $context = null): void */ public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->character ??= $this->getGameScene()->party->leader; @@ -307,6 +349,7 @@ public function setMode(EquipmentMenuMode $mode): void $this->mode?->exit(); $this->mode = $mode; $this->mode->enter(); + $this->refreshInfoText(); } /** diff --git a/src/Scenes/Game/States/FieldState.php b/src/Scenes/Game/States/FieldState.php index 0cba0ff9..5293ca65 100644 --- a/src/Scenes/Game/States/FieldState.php +++ b/src/Scenes/Game/States/FieldState.php @@ -78,6 +78,9 @@ public function execute(?SceneStateContext $context = null): void $scene = $this->context->getScene(); assert($scene instanceof GameScene); + if ($scene->isStopping) { + return; + } $scene->reconcileFieldPresentation(); // A story event owns field input while it is running. Its pending @@ -93,7 +96,8 @@ public function execute(?SceneStateContext $context = null): void $scene->updateEventSession(Time::getDeltaTime()); - if ($scene->cinematicController?->active() !== null) { + if (! $scene->isStopping && $scene->cinematicController?->active() !== null + && $scene->sceneManager->currentScene === $scene) { $this->renderTheField(); } return; @@ -101,7 +105,7 @@ public function execute(?SceneStateContext $context = null): void $this->handleActions($scene); - if ($scene->hasUnstableEventSession()) { + if ($scene->isStopping || $scene->hasUnstableEventSession()) { return; } @@ -114,7 +118,7 @@ public function execute(?SceneStateContext $context = null): void $this->handleNavigation($scene); - if ($scene->hasUnstableEventSession()) { + if ($scene->isStopping || $scene->hasUnstableEventSession()) { return; } @@ -137,6 +141,7 @@ protected function handleActions(GameScene $scene): void get_message("confirm.quit", "Are you sure you want to quit?"), config(ProjectConfig::class, 'vocab.game.shutdown', 'Exit Game'))) { $scene->getGame()->quit(); + return; } if (Input::isButtonDown("menu")) { @@ -148,6 +153,10 @@ protected function handleActions(GameScene $scene): void $scene->player->interact(); } + if ($scene->isStopping) { + return; + } + if (Input::isButtonDown("map")) { $this->showInGameMap(); } @@ -156,6 +165,10 @@ protected function handleActions(GameScene $scene): void $scene->skitManager?->playNextAvailableSkit(); } + if ($scene->isStopping) { + return; + } + // F5 quick-saves from the field, the way a PC RPG player expects. if (Input::isKeyDown(KeyCode::F5)) { try { diff --git a/src/Scenes/Game/States/ItemMenuState.php b/src/Scenes/Game/States/ItemMenuState.php index 0bee20dd..6d4f3fe7 100644 --- a/src/Scenes/Game/States/ItemMenuState.php +++ b/src/Scenes/Game/States/ItemMenuState.php @@ -24,6 +24,11 @@ use Ichiloto\Engine\UI\Windows\CommandPanel; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Symfony\Component\Console\Output\ConsoleOutput; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\UI\Presentation\ItemMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; /** * The ItemMenu state allows the player to manage items in their inventory. @@ -36,8 +41,14 @@ * * @package Ichiloto\Engine\Scenes\Game\States */ -class ItemMenuState extends GameSceneState implements CanRender +class ItemMenuState extends GameSceneState implements CanRender, CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + return ItemMenuPresentation::compose($this, $theme, $time); + } /** * The width of the item menu. */ @@ -122,12 +133,14 @@ class ItemMenuState extends GameSceneState implements CanRender * @var ItemMenuMode|null The mode. */ protected(set) ?ItemMenuMode $mode = null; + private array $infoSelection = []; /** * @inheritDoc */ public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->calculateMargins(); @@ -146,7 +159,31 @@ public function enter(): void */ public function execute(?SceneStateContext $context = null): void { + $this->syncInfoSelection(); + if ($this->handleMenuInfoInput($this->infoPanel?->text ?? '', columns: max(1, ($this->infoPanel?->getContentWidth() ?? 3) - 2))) { + $this->refreshInfoText(); + return; + } $this->mode->update(); + $this->refreshInfoText(); + } + + private function syncInfoSelection(): void + { + $selection = [$this->mode, $this->itemMenu?->activeIndex, $this->selectionPanel?->activeItem, + $this->targetSelectionPanel?->activeCharacter]; + if ($selection !== $this->infoSelection) { $this->menuInfoText->reset(); } + $this->infoSelection = $selection; + } + + private function refreshInfoText(): void + { + if ($this->infoPanel === null) { return; } + $this->syncInfoSelection(); + $page = $this->menuInfoText->getPage($this->infoPanel->text, null, max(1, $this->infoPanel->getContentWidth() - 2)); + $this->infoPanel->setContent(array_pad($page->lines, 2, '')); + $this->infoPanel->setHelp($page->total > 2 ? $page->range() : ''); + $this->infoPanel->render(); } /** @@ -334,6 +371,7 @@ public function setMode(ItemMenuMode $mode): void $this->mode?->exit(); $this->mode = $mode; $this->mode->enter(); + $this->refreshInfoText(); } /** diff --git a/src/Scenes/Game/States/MagicMenuState.php b/src/Scenes/Game/States/MagicMenuState.php index 5dabc120..414699de 100644 --- a/src/Scenes/Game/States/MagicMenuState.php +++ b/src/Scenes/Game/States/MagicMenuState.php @@ -25,14 +25,104 @@ use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\UI\Presentation\MenuRow; +use Ichiloto\Engine\UI\Presentation\MenuRowKind; +use Ichiloto\Engine\UI\Presentation\MenuRowValue; +use Ichiloto\Engine\UI\Presentation\SkillMenuContent; +use Ichiloto\Engine\UI\Presentation\SkillMenuPresentation; /** * Displays the field magic-management screen for a single party member. * * @package Ichiloto\Engine\Scenes\Game\States */ -class MagicMenuState extends GameSceneState +class MagicMenuState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + $content = $this->getPresentationContent(); + return $content === null ? null : SkillMenuPresentation::compose($content, $theme, $time); + } + + public function getPresentationContent(): ?SkillMenuContent + { + $actor = $this->character; + if ($actor === null) { return null; } + $tab = $this->tabs[$this->activeTabIndex] ?? 'Use'; + $index = $this->getActiveEntryIndex(); + $book = $actor->spellbook; + $events = $this->getGameScene()->storyEvents; + $targeting = $this->isSelectingTarget(); + $rows = []; + if ($targeting) { + foreach ($this->targetCandidates as $i => $target) { + $stats = $target->effectiveStats; + $rows[] = new MenuRow((string)$i, $target->name, + [new MenuRowValue('HP ' . number_format($stats->currentHp) . '/' . number_format($stats->totalHp)), + new MenuRowValue('MP ' . number_format($stats->currentMp) . '/' . number_format($stats->totalMp))], + selected: $i === $index, focused: $i === $index); + } + } elseif ($tab === 'Sort') { + foreach ($this->sortOptions as $i => $label) { + $rows[] = new MenuRow((string)$i, $label, kind: MenuRowKind::COMMAND, + selected: $label === $book->getSortOrder()->value, focused: $i === $index); + } + } else { + foreach ($tab === 'Learn' ? $book->getLearnableSpells() : $book->getLearnedSpells() as $i => $entry) { + $skill = $entry instanceof LearnableSpell ? $entry->skill : $entry; + $values = $entry instanceof LearnableSpell + ? [new MenuRowValue($entry->getStatusLabel($actor, $this->party, $events))] + : [new MenuRowValue($this->formatOccasionLabel($skill->occasion)), new MenuRowValue($skill->cost . ' MP')]; + $rows[] = new MenuRow((string)$i, $skill->name, $values, icon: 'skill.magic', + selected: $i === $index, focused: $i === $index); + } + } + $learnable = !$targeting && $tab === 'Learn' ? $this->getActiveLearnableSpell() : null; + $skill = $targeting ? $this->pendingUseSpell + : ($tab === 'Learn' ? $learnable?->skill : ($tab === 'Use' ? $this->getActiveUseSpell() : null)); + $fields = []; + $detail = ''; + $empty = $targeting ? 'No eligible target.' : ($tab === 'Learn' ? 'No discovered magic.' : 'No learned magic.'); + if ($skill !== null) { + $fields = ['MP Cost' => (string)$skill->cost, 'Occasion' => $this->formatOccasionLabel($skill->occasion), + 'Scope' => $skill->scope->side->value]; + if ($targeting) { + $target = $this->targetCandidates[$index] ?? null; + $fields['Caster'] = $actor->name; + if ($target !== null) { + $fields['Target'] = $target->name; + $fields['Status'] = $target->isKnockedOut ? 'Knocked Out' : 'Ready'; + $fields['HP'] = number_format($target->effectiveStats->currentHp) . ' / ' . number_format($target->effectiveStats->totalHp); + $fields['MP'] = number_format($target->effectiveStats->currentMp) . ' / ' . number_format($target->effectiveStats->totalMp); + } + } elseif ($learnable !== null) { + $fields['Status'] = $learnable->getStatusLabel($actor, $this->party, $events); + $detail = $learnable->requirement->describeProgress($actor, $this->party, $learnable->trainingHours, $events); + if ($detail === '') { $detail = 'No additional requirements.'; } + } + } elseif ($tab === 'Sort') { + $fields = ['Current' => $book->getSortOrder()->value]; + $detail = "A-Z keeps learned magic alphabetical.\nZ-A reverses the learned magic list.\nApply a sort order to reorganize the Use tab."; + } else { + $detail = $tab === 'Learn' ? 'Discovered spells and their learning requirements will appear here.' + : 'Use magic will appear here once this character has learned or acquired spells.'; + } + return new SkillMenuContent($actor, 'Magic', $this->tabs, $this->activeTabIndex, + ['Learned Magic' => (string)count($book->getLearnedSpells()), + 'Ready to Learn' => (string)$book->getReadyToLearnCount($actor, $this->party, $events), + 'Current Order' => $book->getSortOrder()->value], + $skill?->name ?? ($tab === 'Sort' ? 'Spell Order' : $empty), $fields, $detail, $rows, + $this->getListPanelTitle(), $index, $empty, $this->getPresentationDescription(), $this->statusMessage, + $targeting ? 'Cast' : match ($tab) { 'Learn' => 'Learn', 'Sort' => 'Apply', default => 'Cast' }, $targeting, + infoModel: $this->menuInfoText); + } + protected const int MAGIC_MENU_WIDTH = 110; protected const int MAGIC_MENU_HEIGHT = 35; protected const int SUMMARY_PANEL_HEIGHT = 7; @@ -108,6 +198,7 @@ class MagicMenuState extends GameSceneState * @var string|null The latest short status message. */ protected ?string $statusMessage = null; + private array $infoSelection = []; /** * @var MagicSkill|null The spell awaiting a one-target party selection. */ @@ -130,6 +221,7 @@ class MagicMenuState extends GameSceneState */ public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->character ??= $this->getGameScene()->party->leader; @@ -228,6 +320,7 @@ protected function normalizeSelectionIndexes(): void */ protected function refreshUI(): void { + $this->syncInfoSelection(); $this->summaryPanel?->setContent($this->buildSummaryLines()); $this->summaryPanel?->render(); @@ -239,7 +332,6 @@ protected function refreshUI(): void $this->listPanel?->setTitle($this->getListPanelTitle()); $this->listPanel?->setEntries($this->buildListEntries(), $this->getActiveEntryIndex()); - $this->infoPanel?->setHelp($this->getInfoHelpText()); $this->infoPanel?->setContent($this->buildInfoLines()); $this->infoPanel?->render(); } @@ -407,11 +499,11 @@ protected function buildLearnDetailLines(): array ]; } - $progress = $learnableSpell->requirement->describeProgress($this->character, $this->party, $learnableSpell->trainingHours); + $progress = $learnableSpell->requirement->describeProgress($this->character, $this->party, $learnableSpell->trainingHours, $this->getGameScene()->storyEvents); return [ sprintf('%s %s', $learnableSpell->skill->icon, $learnableSpell->skill->name), - sprintf('Status : %s', $learnableSpell->getStatusLabel($this->character, $this->party)), + sprintf('Status : %s', $learnableSpell->getStatusLabel($this->character, $this->party, $this->getGameScene()->storyEvents)), sprintf('Occasion: %s', $this->formatOccasionLabel($learnableSpell->skill->occasion)), $learnableSpell->note !== '' ? sprintf('Source : %s', $learnableSpell->note) : 'Source : Unrecorded', '', @@ -554,7 +646,7 @@ protected function buildLearnEntries(): array foreach ($this->character?->spellbook->getLearnableSpells() ?? [] as $learnableSpell) { $status = $this->character instanceof Character - ? $learnableSpell->getStatusLabel($this->character, $this->party) + ? $learnableSpell->getStatusLabel($this->character, $this->party, $this->getGameScene()->storyEvents) : 'Unknown'; $label = TerminalText::padRight(sprintf('%s %s', $learnableSpell->skill->icon, $learnableSpell->skill->name), 44); $statusText = TerminalText::padLeft($status, 12); @@ -634,9 +726,23 @@ protected function getInfoHelpText(): string */ protected function buildInfoLines(): array { - $availableLines = self::INFO_PANEL_HEIGHT - 2; - $availableWidth = self::MAGIC_MENU_WIDTH - 4; - $description = $this->isSelectingTarget() + $page = $this->menuInfoText->getPage($this->getPresentationDescription(), $this->statusMessage, + max(1, ($this->infoPanel?->getContentWidth() ?? self::MAGIC_MENU_WIDTH - 2) - 2)); + $this->infoPanel?->setHelp($page->total > 2 ? $page->range() : $this->getInfoHelpText()); + return array_pad($page->lines, 2, ''); + } + + private function syncInfoSelection(): void + { + $selection = [$this->character, $this->activeTabIndex, $this->getActiveEntryIndex(), + $this->pendingUseSpell, $this->activeTargetIndex]; + if ($selection !== $this->infoSelection) { $this->menuInfoText->reset(); } + $this->infoSelection = $selection; + } + + private function getPresentationDescription(): string + { + return $this->isSelectingTarget() ? $this->pendingUseSpell?->description ?? 'Choose a party member.' : match ($this->tabs[$this->activeTabIndex] ?? 'Use') { 'Use' => $this->getActiveUseSpell()?->description ?? 'Review learned magic and cast the spells that work from the field.', @@ -644,15 +750,6 @@ protected function buildInfoLines(): array 'Sort' => 'Reorder learned magic to fit how you like to browse spells.', default => '', }; - - $lines = explode("\n", wrap_text($description, max(1, $availableWidth))); - $lines = array_slice($lines, 0, $availableLines); - - if ($this->statusMessage !== null) { - $lines[$availableLines - 1] = TerminalText::truncateToWidth($this->statusMessage, $availableWidth); - } - - return array_slice(array_pad($lines, $availableLines, ''), 0, $availableLines); } /** @@ -660,6 +757,13 @@ protected function buildInfoLines(): array */ public function execute(?SceneStateContext $context = null): void { + $this->syncInfoSelection(); + if ($this->handleMenuInfoInput($this->getPresentationDescription(), $this->statusMessage, + max(1, ($this->infoPanel?->getContentWidth() ?? self::MAGIC_MENU_WIDTH - 2) - 2))) { + $this->infoPanel?->setContent($this->buildInfoLines()); + $this->infoPanel?->render(); + return; + } if ($this->isSelectingTarget()) { $this->handleTargetSelection(); return; @@ -1060,7 +1164,7 @@ protected function learnSelectedSpell(): void return; } - $progress = $learnableSpell->requirement->describeProgress($this->character, $this->party, $learnableSpell->trainingHours); + $progress = $learnableSpell->requirement->describeProgress($this->character, $this->party, $learnableSpell->trainingHours, $this->getGameScene()->storyEvents); $this->statusMessage = $progress !== '' ? $progress : sprintf('%s still needs more progress.', $learnableSpell->skill->name); diff --git a/src/Scenes/Game/States/MainMenuState.php b/src/Scenes/Game/States/MainMenuState.php index 894d5f39..8f5115bc 100644 --- a/src/Scenes/Game/States/MainMenuState.php +++ b/src/Scenes/Game/States/MainMenuState.php @@ -24,6 +24,7 @@ use Ichiloto\Engine\Core\Menu\MainMenu\MainMenu; use Ichiloto\Engine\Core\Menu\MainMenu\MainMenuSettingsManager; use Ichiloto\Engine\Core\Menu\MainMenu\Modes\MainMenuCommandSelectionMode; +use Ichiloto\Engine\Core\Menu\MainMenu\Modes\MainMenuConfigMode; use Ichiloto\Engine\Core\Menu\MainMenu\Windows\AccountBalancePanel; use Ichiloto\Engine\Core\Menu\MainMenu\Windows\ConfigDetailPanel; use Ichiloto\Engine\Core\Menu\MainMenu\Windows\ConfigSelectionWindow; @@ -33,11 +34,17 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Time; use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Scenes\SceneStateContext; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; +use Ichiloto\Engine\UI\Presentation\MainMenuPresentation; +use Ichiloto\Engine\UI\Presentation\ConfigMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; use Symfony\Component\Console\Output\ConsoleOutput; /** @@ -51,8 +58,44 @@ * * @package Ichiloto\Engine\Scenes\Game\States */ -class MainMenuState extends GameSceneState implements CanRender +class MainMenuState extends GameSceneState implements CanRender, CanvasProviderInterface { + use MenuCanvasState { getPresentationCanvas as private getActiveMenuCanvas; } + + public function getPresentationCanvas(): ?PresentationCanvas + { + return $this->mode === null || (!$this->supplementalPanelsVisible && !$this->mode instanceof MainMenuConfigMode) + ? null : $this->getActiveMenuCanvas(); + } + + public function getPresentationMode(): ?MainMenuModeInterface + { + return $this->mode; + } + + /** The existing windows own formatting and refresh timing. */ + public function getPresentationSummaries(): array + { + $summaries = []; + foreach (['time' => $this->playTimePanel, 'money' => $this->accountBalancePanel, + 'location' => $this->locationDetailPanel] as $id => $window) { + if ($window !== null) { + $summaries[$id] = ['title' => $window->getTitle(), + 'lines' => array_map(trim(...), $window->getContent())]; + } + } + return $summaries; + } + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + if ($this->mode instanceof MainMenuConfigMode) { + $config = $this->mode->getConfigMenu(); + return $config === null ? null : ConfigMenuPresentation::compose($config, $theme, $time); + } + return $this->supplementalPanelsVisible ? MainMenuPresentation::compose($this, $theme, $time) : null; + } + /** * The width of the main menu. */ @@ -167,6 +210,7 @@ class MainMenuState extends GameSceneState implements CanRender */ public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->calculateMargins(); diff --git a/src/Scenes/Game/States/QuestMenuState.php b/src/Scenes/Game/States/QuestMenuState.php index 884df1e8..62f1b2be 100644 --- a/src/Scenes/Game/States/QuestMenuState.php +++ b/src/Scenes/Game/States/QuestMenuState.php @@ -2,423 +2,229 @@ namespace Ichiloto\Engine\Scenes\Game\States; +use Ichiloto\Engine\Core\Menu\MagicMenu\Windows\MagicTabPanel; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\Console; -use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Quests\Quest; -use Ichiloto\Engine\Quests\QuestManager; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; use Ichiloto\Engine\Scenes\SceneStateContext; +use Ichiloto\Engine\UI\Presentation\JournalMenuContent; +use Ichiloto\Engine\UI\Presentation\JournalDocument; +use Ichiloto\Engine\UI\Presentation\JournalSection; +use Ichiloto\Engine\UI\Presentation\JournalMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\UI\SelectionStyle; +use Ichiloto\Engine\UI\Text\TextPage; +use Ichiloto\Engine\UI\Text\TextViewport; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; -use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; +use Ichiloto\Engine\UI\Windows\Enumerations\WindowHeightPolicy; use Ichiloto\Engine\UI\Windows\Window; -/** - * Displays the party's quest journal. - * - * Two tabs — active and completed — list the party's quests; opening an - * entry shows the full journal page with the giver, description, each - * objective's progress, and the rewards. - * - * @package Ichiloto\Engine\Scenes\Game\States - */ -class QuestMenuState extends GameSceneState +/** Active/completed lists and a same-panel, fully readable quest journal. */ +class QuestMenuState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + protected const int MENU_WIDTH = 110; protected const int SUMMARY_PANEL_HEIGHT = 4; protected const int LIST_PANEL_HEIGHT = 27; protected const int INFO_PANEL_HEIGHT = 4; - - /** - * @var bool True when the completed tab is shown. - */ protected bool $showingCompleted = false; - /** - * @var Quest[] The quests on the visible tab. - */ + /** @var list */ protected array $quests = []; - /** - * @var int The active list index. - */ protected int $activeIndex = 0; - /** - * @var bool Whether the journal detail view is open for the active quest. - */ protected bool $viewingDetail = false; - /** - * @var int The centered left margin. - */ - protected int $leftMargin = 0; - /** - * @var int The centered top margin. - */ - protected int $topMargin = 0; - /** - * @var BorderPackInterface|null The border pack for the screen. - */ - protected ?BorderPackInterface $borderPack = null; - /** - * @var Window|null The tab summary panel. - */ protected ?Window $summaryPanel = null; - /** - * @var Window|null The quest list panel. - */ protected ?Window $listPanel = null; - /** - * @var Window|null The bottom description panel. - */ protected ?Window $infoPanel = null; + private TextViewport $reading; + private string $readingKey = ''; + private ?TextPage $displayedPage = null; - /** - * @inheritDoc - */ public function enter(): void { Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); + $this->resetMenuPresentation(); + $this->reading = new TextViewport(); + $this->readingKey = ''; + $this->displayedPage = null; $this->showingCompleted = false; $this->activeIndex = 0; $this->viewingDetail = false; $this->reloadQuests(); - $this->calculateMargins(); $this->initializeUI(); $this->refreshUI(); } - /** - * @inheritDoc - */ public function execute(?SceneStateContext $context = null): void { - if ($this->handleTabSwitching()) { + $this->syncReading($this->getPresentationContent()); + // One semantic edge owns this tick, including bindings shared with navigation. + if (Input::isButtonDown('back') || Input::isButtonDown('cancel')) { + if ($this->viewingDetail) { + $this->viewingDetail = false; + $this->reading->reset(); + $this->refreshUI(); + } else { $this->setState($this->getGameScene()->mainMenuState); } return; } - - $this->handleNavigation(); - $this->handleActions(); + if (!$this->viewingDetail && ($this->isNextCharacterRequested() || $this->isPreviousCharacterRequested())) { + $this->showingCompleted = !$this->showingCompleted; + $this->quests = []; + $this->activeIndex = 0; + $this->reloadQuests(); + $this->refreshUI(); + return; + } + if (Input::isButtonDown('confirm')) { + if (!$this->viewingDetail && isset($this->quests[$this->activeIndex])) { + $this->viewingDetail = true; + $this->reading->reset(); + $this->refreshUI(); + } + return; + } + $vertical = Input::getAxis(AxisName::VERTICAL); + if (abs($vertical) === 0.0 || $this->quests === []) { return; } + if ($this->viewingDetail && $this->displayedPage !== null) { + $this->reading->scroll($vertical > 0 ? 1 : -1, $this->displayedPage); + } else { $this->activeIndex = wrap($this->activeIndex + ($vertical > 0 ? 1 : -1), 0, count($this->quests) - 1); } + $this->refreshUI(); } - /** - * Reloads the visible tab's quests. - * - * @return void - */ protected function reloadQuests(): void { - $questManager = $this->getGameScene()->questManager; - - $this->quests = $this->showingCompleted - ? ($questManager?->completedQuests() ?? []) - : ($questManager?->activeQuests() ?? []); - $this->activeIndex = min($this->activeIndex, max(0, count($this->quests) - 1)); + $oldId = $this->quests[$this->activeIndex]->id ?? null; + $manager = $this->getGameScene()->questManager; + $this->quests = $this->showingCompleted ? ($manager?->completedQuests() ?? []) : ($manager?->activeQuests() ?? []); + $index = array_find_key($this->quests, fn($quest) => $quest->id === $oldId); + $this->activeIndex = $index ?? min($this->activeIndex, max(0, count($this->quests) - 1)); + if ($this->quests === []) { $this->viewingDetail = false; } } - /** - * Centers the screen inside the terminal. - * - * @return void - */ - protected function calculateMargins(): void + public function getPresentationContent(): JournalMenuContent { - $totalHeight = self::SUMMARY_PANEL_HEIGHT + self::LIST_PANEL_HEIGHT + self::INFO_PANEL_HEIGHT; - $this->leftMargin = max(0, intdiv(get_screen_width() - self::MENU_WIDTH, 2)); - $this->topMargin = max(0, intdiv(get_screen_height() - $totalHeight, 2)); + $manager = $this->getGameScene()->questManager; + $rows = []; + foreach ($this->quests as $quest) { + $rows[] = ['label' => $quest->name !== '' ? $quest->name : $quest->id, + 'values' => [$quest->giver !== '' ? $quest->giver : '-', $this->describeOverallProgress($quest)], + 'icon' => $this->showingCompleted ? 'quest.complete' : ($quest->isOptional ? 'quest.optional' : 'quest.entry')]; + } + $quest = $this->quests[$this->activeIndex] ?? null; + $sections = []; + if ($quest !== null) { + $identity = [['text' => 'Giver: ' . ($quest->giver !== '' ? $quest->giver : '-')], + ['text' => $this->describeOverallProgress($quest)]]; + if ($quest->isOptional) { $identity[] = ['text' => 'Side Quest']; } + $sections[] = new JournalSection($quest->name !== '' ? $quest->name : $quest->id, $identity, $rows[$this->activeIndex]['icon']); + $sections[] = new JournalSection('Description', [['text' => $quest->description]], 'journal.description'); + $objectives = []; + foreach ($quest->objectives as $index => $objective) { + $progress = $manager?->getObjectiveProgress($quest, $index) ?? 0; + $complete = $progress >= $objective->quantity; + $objectives[] = ['text' => sprintf('%s %s (%d/%d)', $complete ? '[Complete]' : '[Open]', + $manager?->describeObjective($objective) ?? $objective->description, $progress, $objective->quantity), + 'icon' => $complete ? 'objective.complete' : 'objective.open', 'color' => $complete ? 'increase' : 'text']; + } + $sections[] = new JournalSection('Objectives', $objectives, 'journal.objectives'); + if (($rewards = $quest->describeRewards()) !== '') { + $sections[] = new JournalSection('Rewards', [['text' => 'Reward: ' . $rewards]], 'journal.rewards'); + } + } + $document = new JournalDocument($sections); + return new JournalMenuContent('Quests', ['Active (' . count($manager?->activeQuests() ?? []) . ')', + 'Completed (' . count($manager?->completedQuests() ?? []) . ')'], $this->showingCompleted ? 1 : 0, '', $rows, + $this->activeIndex, $this->showingCompleted ? 'No completed quests.' : 'No active quests.', + $quest?->id ?? '', $document->text, $quest?->description ?? '', $this->viewingDetail, true, $document); } - /** - * Builds the screen's windows. - * - * @return void - */ - protected function initializeUI(): void + /** Reading a different renderer's geometry cannot navigate or reset the owner. */ + public function getPresentationPage(int $columns, int $rows): TextPage { - $this->borderPack = new DefaultBorderPack(); - - $this->summaryPanel = new Window( - 'Quests', - '', - new Vector2($this->leftMargin, $this->topMargin), - self::MENU_WIDTH, - self::SUMMARY_PANEL_HEIGHT, - $this->borderPack - ); - - $this->listPanel = new Window( - '', - '', - new Vector2($this->leftMargin, $this->topMargin + self::SUMMARY_PANEL_HEIGHT), - self::MENU_WIDTH, - self::LIST_PANEL_HEIGHT, - $this->borderPack - ); - - $this->infoPanel = new Window( - 'Info', - 'enter:Details tab:Tab c:Cancel', - new Vector2($this->leftMargin, $this->topMargin + self::SUMMARY_PANEL_HEIGHT + self::LIST_PANEL_HEIGHT), - self::MENU_WIDTH, - self::INFO_PANEL_HEIGHT, - $this->borderPack - ); + $content = $this->getPresentationContent(); + $reading = clone $this->reading; + if ($content->entryKey !== $this->readingKey) { $reading->reset(); } + $reading->setText($content->detailText); + return $reading->page($columns, $rows); } - /** - * Redraws every panel from the current state. - * - * @return void - */ - protected function refreshUI(): void + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas { - $this->refreshSummaryPanel(); - - if ($this->viewingDetail) { - $this->refreshDetailPanel(); - } else { - $this->refreshListPanel(); - } - - $this->refreshInfoPanel(); - } - - /** - * Redraws the tab summary panel. - * - * @return void - */ - protected function refreshSummaryPanel(): void - { - $questManager = $this->getGameScene()->questManager; - $activeCount = count($questManager?->activeQuests() ?? []); - $completedCount = count($questManager?->completedQuests() ?? []); - - $activeTab = sprintf('%s Active (%d)', $this->showingCompleted ? ' ' : '▶', $activeCount); - $completedTab = sprintf('%s Completed (%d)', $this->showingCompleted ? '▶' : ' ', $completedCount); - - $this->summaryPanel->setContent([ - sprintf(' %s %s', $activeTab, $completedTab), - ' Use the arrow keys to select a quest.', - ]); - $this->summaryPanel->render(); + $content = $this->getPresentationContent(); + $page = $this->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme)); + $canvas = JournalMenuPresentation::compose($content, $theme, $page, $time); + $this->displayedPage = $page; + return $canvas; } - /** - * Redraws the quest list panel. - * - * @return void - */ - protected function refreshListPanel(): void + private function syncReading(JournalMenuContent $content): void { - $content = []; - - if (empty($this->quests)) { - $content[] = $this->showingCompleted - ? ' No completed quests.' - : ' No active quests.'; - } - - foreach ($this->quests as $index => $quest) { - $prefix = $index === $this->activeIndex ? '>' : ' '; - $name = TerminalText::padRight($quest->name, 40); - $giver = TerminalText::padRight($quest->giver !== '' ? $quest->giver : '-', 24); - $content[] = sprintf(' %s %s %s %s', $prefix, $name, $giver, $this->describeOverallProgress($quest)); - } - - $content = array_pad($content, self::LIST_PANEL_HEIGHT - 2, ''); - $this->listPanel->setContent($content); - $this->listPanel->render(); + if ($content->entryKey !== $this->readingKey) { $this->reading->reset(); } + $this->readingKey = $content->entryKey; + $this->reading->setText($content->detailText); } - /** - * Redraws the journal page for the active quest. - * - * @return void - */ - protected function refreshDetailPanel(): void + protected function initializeUI(): void { - $quest = $this->quests[$this->activeIndex] ?? null; - - if ($quest === null) { - return; - } - - $questManager = $this->getGameScene()->questManager; - $innerWidth = self::MENU_WIDTH - 6; - $content = []; - - $header = sprintf(' %s', $quest->name); - - if ($quest->giver !== '') { - $header .= sprintf(' | from %s', $quest->giver); - } - - if ($quest->isOptional) { - $header .= ' | Side Quest'; - } - - $content[] = $header; - $content[] = ' ' . str_repeat('─', $innerWidth); - $content[] = ''; - - foreach (explode("\n", wordwrap($quest->description, $innerWidth, "\n", true)) as $line) { - $content[] = ' ' . $line; - } - - $content[] = ''; - $content[] = ' Objectives'; - - foreach ($quest->objectives as $index => $objective) { - $progress = $questManager?->getObjectiveProgress($quest, $index) ?? 0; - $mark = $progress >= $objective->quantity ? '✓' : '·'; - $count = $objective->quantity > 1 ? sprintf(' (%d/%d)', $progress, $objective->quantity) : ''; - $content[] = sprintf( - ' %s %s%s', - $mark, - $questManager?->describeObjective($objective) ?? $objective->description, - $count, - ); - } - - if (($rewards = $quest->describeRewards()) !== '') { - $content[] = ''; - $content[] = sprintf(' Reward: %s', $rewards); - } - - $content = array_pad($content, self::LIST_PANEL_HEIGHT - 2, ''); - $this->listPanel->setContent(array_slice($content, 0, self::LIST_PANEL_HEIGHT - 2)); - $this->listPanel->render(); + $width = min(self::MENU_WIDTH, max(20, get_screen_width())); + $left = max(0, intdiv(get_screen_width() - $width, 2)); + $top = max(0, intdiv(get_screen_height() - 35, 2)); + $border = new DefaultBorderPack(); + $this->summaryPanel = new MagicTabPanel('Quests', '', new Vector2($left, $top), $width, self::SUMMARY_PANEL_HEIGHT, $border, heightPolicy: WindowHeightPolicy::FIXED); + $this->listPanel = new Window('', '', new Vector2($left, $top + self::SUMMARY_PANEL_HEIGHT), $width, self::LIST_PANEL_HEIGHT, $border, heightPolicy: WindowHeightPolicy::FIXED); + $this->infoPanel = new Window('Info', '', new Vector2($left, $top + self::SUMMARY_PANEL_HEIGHT + self::LIST_PANEL_HEIGHT), $width, self::INFO_PANEL_HEIGHT, $border, heightPolicy: WindowHeightPolicy::FIXED); } - /** - * Redraws the bottom description panel. - * - * @return void - */ - protected function refreshInfoPanel(): void + protected function refreshUI(): void { - $quest = $this->quests[$this->activeIndex] ?? null; - + $content = $this->getPresentationContent(); + $this->syncReading($content); + $width = $this->listPanel->getContentWidth() - 2; + $capacity = $this->listPanel->getContentHeight(); + assert($this->summaryPanel instanceof MagicTabPanel); + $this->summaryPanel->setTabs($content->tabs, $content->tabIndex); + $this->summaryPanel->setContent([...$this->summaryPanel->getContent(), $content->summary]); + $page = $this->reading->page($width, $capacity); + $this->displayedPage = $page; if ($this->viewingDetail) { - $this->infoPanel->setHelp('c:Back'); - $this->infoPanel->setContent([ - ' ' . ($quest?->name ?? ''), - '', - ]); - } else { - $this->infoPanel->setHelp('enter:Details tab:Tab c:Cancel'); - $firstLine = strtok($quest?->description ?? '', "\n"); - $this->infoPanel->setContent([ - ' ' . trim($firstLine !== false ? $firstLine : ''), - '', - ]); + $lines = $page->lines; + $this->listPanel->setHelp($page->range()); + } + else { + $first = max(0, min($this->activeIndex - $capacity + 1, count($content->rows) - $capacity)); + $lines = []; + foreach (array_slice($content->rows, $first, $capacity, true) as $i => $row) { + $line = ($i === $this->activeIndex ? '> ' : ' ') . TextViewport::preview(implode(' | ', [$row['label'], ...$row['values']]), $width); + $lines[] = $i === $this->activeIndex ? SelectionStyle::apply($line) : $line; + } + $this->listPanel->setHelp($content->rows === [] ? '' : sprintf('%d-%d / %d', $first + 1, $first + count($lines), count($content->rows))); + if ($content->rows === []) { $lines[] = $content->emptyText; } } - + $this->listPanel->setContent(array_pad($lines, $capacity, '')); + $this->infoPanel->setContent([$content->rows === [] || $this->viewingDetail ? '' : 'View details', + $this->viewingDetail ? '' : TextViewport::preview($content->previewText, $width)]); + $this->summaryPanel->render(); + $this->listPanel->render(); $this->infoPanel->render(); } - /** - * Summarizes a quest's overall objective progress. - * - * @param Quest $quest The quest. - * @return string The progress label. - */ protected function describeOverallProgress(Quest $quest): string { - if ($this->showingCompleted) { - return '[Complete]'; - } - - $questManager = $this->getGameScene()->questManager; + if ($this->showingCompleted) { return '[Complete]'; } + $manager = $this->getGameScene()->questManager; $satisfied = 0; - foreach ($quest->objectives as $index => $objective) { - if (($questManager?->getObjectiveProgress($quest, $index) ?? 0) >= $objective->quantity) { - $satisfied++; - } + if (($manager?->getObjectiveProgress($quest, $index) ?? 0) >= $objective->quantity) { $satisfied++; } } - return sprintf('[%d/%d]', $satisfied, count($quest->objectives)); } - /** - * Handles switching between the active and completed tabs. - * - * @return bool True when the tab changed. - */ - protected function handleTabSwitching(): bool - { - if ($this->viewingDetail) { - return false; - } - - if (! $this->isNextCharacterRequested() && ! $this->isPreviousCharacterRequested()) { - return false; - } - - $this->showingCompleted = ! $this->showingCompleted; - $this->activeIndex = 0; - $this->reloadQuests(); - $this->refreshUI(); - - return true; - } - - /** - * Handles list navigation. - * - * @return void - */ - protected function handleNavigation(): void - { - if ($this->viewingDetail) { - return; - } - - $v = Input::getAxis(AxisName::VERTICAL); - - if (abs($v) > 0 && count($this->quests) > 0) { - $this->activeIndex = wrap( - $this->activeIndex + ($v > 0 ? 1 : -1), - 0, - count($this->quests) - 1 - ); - $this->refreshListPanel(); - $this->refreshInfoPanel(); - } - } - - /** - * Handles opening the journal page and leaving the screen. - * - * @return void - */ - protected function handleActions(): void - { - if (Input::isButtonDown('back') || Input::isButtonDown('cancel')) { - if ($this->viewingDetail) { - $this->viewingDetail = false; - $this->refreshUI(); - return; - } - - $this->setState($this->getGameScene()->mainMenuState); - return; - } - - if (! Input::isButtonDown('confirm')) { - return; - } - - if (! $this->viewingDetail && isset($this->quests[$this->activeIndex])) { - $this->viewingDetail = true; - $this->refreshUI(); - } - } - - /** - * @inheritDoc - */ public function resume(): void { $this->reloadQuests(); diff --git a/src/Scenes/Game/States/RecordsMenuState.php b/src/Scenes/Game/States/RecordsMenuState.php index 974bcbc6..97da8c34 100644 --- a/src/Scenes/Game/States/RecordsMenuState.php +++ b/src/Scenes/Game/States/RecordsMenuState.php @@ -2,9 +2,9 @@ namespace Ichiloto\Engine\Scenes\Game\States; +use Ichiloto\Engine\Core\Menu\MagicMenu\Windows\MagicTabPanel; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\Console; -use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Progress\Achievement; @@ -12,38 +12,47 @@ use Ichiloto\Engine\Progress\Knowledge\KnowledgeCatalog; use Ichiloto\Engine\Progress\Knowledge\KnowledgeDepth; use Ichiloto\Engine\Progress\Knowledge\KnowledgeSubject; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\Scenes\SceneStateContext; +use Ichiloto\Engine\UI\Presentation\JournalMenuContent; +use Ichiloto\Engine\UI\Presentation\JournalMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\UI\SelectionStyle; +use Ichiloto\Engine\UI\Text\TextPage; +use Ichiloto\Engine\UI\Text\TextViewport; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; -use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; +use Ichiloto\Engine\UI\Windows\Enumerations\WindowHeightPolicy; use Ichiloto\Engine\UI\Windows\Window; use Ichiloto\Engine\Util\Config\ConfigStore; /** Achievements and the project-owned, player-earned Field Index. */ -class RecordsMenuState extends GameSceneState +class RecordsMenuState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + protected const int MENU_WIDTH = 110; protected const int SUMMARY_PANEL_HEIGHT = 4; - protected const int LIST_PANEL_HEIGHT = 27; - protected const int INFO_PANEL_HEIGHT = 4; + protected const int LIST_PANEL_HEIGHT = 23; + protected const int INFO_PANEL_HEIGHT = 8; protected const string TAB_ACHIEVEMENTS = 'achievements'; protected const string TAB_FIELD_INDEX = 'field_index'; - protected string $tab = self::TAB_ACHIEVEMENTS; - /** @var array */ + /** @var list */ protected array $rows = []; protected int $activeIndex = 0; - protected int $scrollOffset = 0; - protected int $leftMargin = 0; - protected int $topMargin = 0; - protected ?BorderPackInterface $borderPack = null; + protected bool $viewingDetail = false; protected ?Window $summaryPanel = null; protected ?Window $listPanel = null; protected ?Window $infoPanel = null; + private TextViewport $reading; + private string $readingKey = ''; + private ?TextPage $displayedPage = null; public static function projectHasRecords(): bool { $catalog = ConfigStore::get(KnowledgeCatalog::class); - return AchievementManager::projectHasAchievements() || ($catalog instanceof KnowledgeCatalog && $catalog->subjects() !== []); } @@ -52,180 +61,172 @@ public function enter(): void { Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); + $this->resetMenuPresentation(); + $this->reading = new TextViewport(); + $this->readingKey = ''; + $this->displayedPage = null; + $this->viewingDetail = false; $this->tab = AchievementManager::projectHasAchievements() ? self::TAB_ACHIEVEMENTS : self::TAB_FIELD_INDEX; $this->activeIndex = 0; - $this->scrollOffset = 0; $this->reloadRows(); - $this->calculateMargins(); $this->initializeUI(); $this->refreshUI(); } public function execute(?SceneStateContext $context = null): void { - if ($this->handleTabSwitching()) { + $this->syncReading($this->getPresentationContent()); + if (Input::isButtonDown('back') || Input::isButtonDown('cancel')) { + if ($this->viewingDetail) { + $this->viewingDetail = false; + $this->reading->reset(); + $this->refreshUI(); + } else { $this->setState($this->getGameScene()->mainMenuState); } return; } - - $this->handleNavigation(); - if (Input::isButtonDown('back') || Input::isButtonDown('cancel')) { - $this->setState($this->getGameScene()->mainMenuState); + if ($this->isNextCharacterRequested() || $this->isPreviousCharacterRequested()) { + $this->tab = $this->tab === self::TAB_ACHIEVEMENTS ? self::TAB_FIELD_INDEX : self::TAB_ACHIEVEMENTS; + $this->viewingDetail = false; + $this->activeIndex = 0; + $this->rows = []; + $this->reloadRows(); + $this->refreshUI(); + return; } + if (Input::isButtonDown('confirm')) { + if (!$this->viewingDetail && $this->rows !== []) { + $this->viewingDetail = true; + $this->reading->reset(); + $this->refreshUI(); + } + return; + } + $vertical = Input::getAxis(AxisName::VERTICAL); + if (abs($vertical) === 0.0 || $this->rows === []) { return; } + if ($this->viewingDetail && $this->displayedPage !== null) { + $this->reading->scroll($vertical > 0 ? 1 : -1, $this->displayedPage); + } else { $this->activeIndex = wrap($this->activeIndex + ($vertical > 0 ? 1 : -1), 0, count($this->rows) - 1); } + $this->refreshUI(); } protected function reloadRows(): void { + $oldId = $this->rows[$this->activeIndex]->id ?? null; $this->rows = $this->tab === self::TAB_ACHIEVEMENTS ? array_values($this->getGameScene()->achievementManager?->achievements ?? []) : $this->getGameScene()->knowledge->discoveredSubjects(); - $this->activeIndex = min($this->activeIndex, max(0, count($this->rows) - 1)); - $this->scrollOffset = 0; + $index = array_find_key($this->rows, fn($row) => $row->id === $oldId); + $this->activeIndex = $index ?? min($this->activeIndex, max(0, count($this->rows) - 1)); + if ($this->rows === []) { $this->viewingDetail = false; } } - protected function calculateMargins(): void - { - $totalHeight = self::SUMMARY_PANEL_HEIGHT + self::LIST_PANEL_HEIGHT + self::INFO_PANEL_HEIGHT; - $this->leftMargin = max(0, intdiv(get_screen_width() - self::MENU_WIDTH, 2)); - $this->topMargin = max(0, intdiv(get_screen_height() - $totalHeight, 2)); - } - - protected function initializeUI(): void - { - $this->borderPack = new DefaultBorderPack(); - $this->summaryPanel = new Window('Records', '', new Vector2($this->leftMargin, $this->topMargin), self::MENU_WIDTH, self::SUMMARY_PANEL_HEIGHT, $this->borderPack); - $this->listPanel = new Window('', '', new Vector2($this->leftMargin, $this->topMargin + self::SUMMARY_PANEL_HEIGHT), self::MENU_WIDTH, self::LIST_PANEL_HEIGHT, $this->borderPack); - $this->infoPanel = new Window('Info', 'tab:Tab c:Cancel', new Vector2($this->leftMargin, $this->topMargin + self::SUMMARY_PANEL_HEIGHT + self::LIST_PANEL_HEIGHT), self::MENU_WIDTH, self::INFO_PANEL_HEIGHT, $this->borderPack); - } - - protected function refreshUI(): void - { - $this->refreshSummaryPanel(); - $this->refreshListPanel(); - $this->refreshInfoPanel(); - } - - protected function refreshSummaryPanel(): void + public function getPresentationContent(): JournalMenuContent { $manager = $this->getGameScene()->achievementManager; - $achievementTab = sprintf( - '%s Achievements (%d/%d)', - $this->tab === self::TAB_ACHIEVEMENTS ? '▶' : ' ', - count($manager?->unlocked ?? []), - count($manager?->achievements ?? []), - ); - $fieldIndexTab = sprintf( - '%s Field Index (%d records)', - $this->tab === self::TAB_FIELD_INDEX ? '▶' : ' ', - count($this->getGameScene()->knowledge->discoveredSubjects()), - ); - - $this->summaryPanel->setContent([ - sprintf(' %s %s', $achievementTab, $fieldIndexTab), - sprintf(' Points: %d', $manager?->earnedPoints() ?? 0), - ]); - $this->summaryPanel->render(); - } - - protected function refreshListPanel(): void - { - $visibleRows = self::LIST_PANEL_HEIGHT - 2; - $this->scrollOffset = max(0, min($this->scrollOffset, max(0, count($this->rows) - $visibleRows))); - if ($this->activeIndex < $this->scrollOffset) { - $this->scrollOffset = $this->activeIndex; - } elseif ($this->activeIndex >= $this->scrollOffset + $visibleRows) { - $this->scrollOffset = $this->activeIndex - $visibleRows + 1; - } - - $content = []; - if ($this->rows === []) { - $content[] = $this->tab === self::TAB_ACHIEVEMENTS - ? ' No achievements authored.' - : ' No field records discovered.'; + $knowledge = $this->getGameScene()->knowledge; + $rows = []; + foreach ($this->rows as $row) { + if ($row instanceof Achievement) { + $unlocked = $manager?->isUnlocked($row->id) ?? false; + $hidden = $row->isSecret && !$unlocked; + $rows[] = ['label' => $hidden ? '???' : trim($row->icon . ' ' . $row->name), + 'values' => [$unlocked ? 'Unlocked' : 'Locked', $hidden ? 'Secret achievement' : $row->description]]; + } else { + $rows[] = ['label' => $row->displayName, 'values' => [$row->family ?? $row->species ?? $row->recordType, + strtolower($knowledge->progress->depth($row->id)->name)]]; + } } - foreach (array_slice($this->rows, $this->scrollOffset, $visibleRows, true) as $index => $row) { - $prefix = $index === $this->activeIndex ? '>' : ' '; - $content[] = $row instanceof Achievement - ? $this->formatAchievementRow($prefix, $row) - : $this->formatKnowledgeRow($prefix, $row); + $row = $this->rows[$this->activeIndex] ?? null; + $lines = []; + if ($row instanceof Achievement) { + $unlocked = $manager?->isUnlocked($row->id) ?? false; + $lines = $row->isSecret && !$unlocked ? ['???', 'A secret achievement. Earn it to reveal what it was.', 'Locked'] + : [trim($row->icon . ' ' . $row->name), $row->description, $unlocked ? 'Unlocked' : 'Locked', 'Points: ' . $row->points]; + } elseif ($row instanceof KnowledgeSubject) { + $depth = $knowledge->progress->depth($row->id); + $lines = [$row->displayName, 'Type: ' . $row->recordType]; + if ($row->family !== null) { $lines[] = 'Family: ' . $row->family; } + if ($row->species !== null) { $lines[] = 'Species: ' . $row->species; } + array_push($lines, 'Depth: ' . strtolower($depth->name), '', + $depth->value >= KnowledgeDepth::OBSERVED->value && $row->deepCard !== null ? $row->deepCard : $row->quickCard); + // Retain the owner's first earned report, including amended/withdrawn status. + foreach ($knowledge->catalog->reportsFor($row->id) as $report) { + $state = $knowledge->progress->reportState($row->id, $report->id); + if ($state === null) { continue; } + array_push($lines, '', $report->title, ucfirst(strval($state['status'] ?? 'active')) . ': ' . $report->summary); + if ($report->details !== null) { $lines[] = $report->details; } + break; + } } - - $this->listPanel->setContent(array_pad($content, $visibleRows, '')); - $this->listPanel->render(); + return new JournalMenuContent('Records', ['Achievements (' . count($manager?->unlocked ?? []) . '/' . count($manager?->achievements ?? []) . ')', + 'Field Index (' . count($knowledge->discoveredSubjects()) . ')'], $this->tab === self::TAB_ACHIEVEMENTS ? 0 : 1, + 'Points: ' . ($manager?->earnedPoints() ?? 0), $rows, $this->activeIndex, + $this->tab === self::TAB_ACHIEVEMENTS ? 'No achievements authored.' : 'No field records discovered.', + $row === null ? '' : $this->tab . ':' . $row->id, implode("\n", $lines), '', $this->viewingDetail, false); } - protected function formatAchievementRow(string $prefix, Achievement $achievement): string + public function getPresentationPage(int $columns, int $rows): TextPage { - $unlocked = $this->getGameScene()->achievementManager?->isUnlocked($achievement->id) ?? false; - $hidden = $achievement->isSecret && ! $unlocked; - - return sprintf( - ' %s %s %s %s', - $prefix, - $unlocked ? '✓' : ' ', - TerminalText::padRight($hidden ? '???' : trim($achievement->icon . ' ' . $achievement->name), 34), - $hidden ? 'Secret achievement' : $achievement->description, - ); + $content = $this->getPresentationContent(); + $reading = clone $this->reading; + if ($content->entryKey !== $this->readingKey) { $reading->reset(); } + $reading->setText($content->detailText); + return $reading->page($columns, $rows); } - protected function formatKnowledgeRow(string $prefix, KnowledgeSubject $subject): string + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas { - $depth = $this->getGameScene()->knowledge->progress->depth($subject->id); - $identity = $subject->family ?? $subject->species ?? $subject->recordType; - - return sprintf( - ' %s %s %-18s %s', - $prefix, - TerminalText::padRight($subject->displayName, 34), - $identity, - strtolower($depth->name), - ); + $content = $this->getPresentationContent(); + $page = $this->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme)); + $canvas = JournalMenuPresentation::compose($content, $theme, $page, $time); + $this->displayedPage = $page; + return $canvas; } - protected function refreshInfoPanel(): void + private function syncReading(JournalMenuContent $content): void { - $row = $this->rows[$this->activeIndex] ?? null; - $lines = ['', '']; - if ($row instanceof Achievement) { - $unlocked = $this->getGameScene()->achievementManager?->isUnlocked($row->id) ?? false; - $lines[0] = $row->isSecret && ! $unlocked ? ' A secret achievement. Earn it to reveal what it was.' : ' ' . $row->description; - $lines[1] = $unlocked ? ' Unlocked' : ' Locked'; - } elseif ($row instanceof KnowledgeSubject) { - $depth = $this->getGameScene()->knowledge->progress->depth($row->id); - $lines[0] = ' ' . ($depth->value >= KnowledgeDepth::OBSERVED->value && $row->deepCard !== null ? $row->deepCard : $row->quickCard); - - foreach ($this->getGameScene()->knowledge->catalog->reportsFor($row->id) as $report) { - $state = $this->getGameScene()->knowledge->progress->reportState($row->id, $report->id); - if ($state !== null) { - $lines[1] = sprintf(' %s: %s', ucfirst(strval($state['status'] ?? 'active')), $report->summary); - break; - } - } - } - - $this->infoPanel->setContent($lines); - $this->infoPanel->render(); + if ($content->entryKey !== $this->readingKey) { $this->reading->reset(); } + $this->readingKey = $content->entryKey; + $this->reading->setText($content->detailText); } - protected function handleTabSwitching(): bool + protected function initializeUI(): void { - if (! $this->isNextCharacterRequested() && ! $this->isPreviousCharacterRequested()) { - return false; - } - $this->tab = $this->tab === self::TAB_ACHIEVEMENTS ? self::TAB_FIELD_INDEX : self::TAB_ACHIEVEMENTS; - $this->activeIndex = 0; - $this->reloadRows(); - $this->refreshUI(); - return true; + $width = min(self::MENU_WIDTH, max(20, get_screen_width())); + $left = max(0, intdiv(get_screen_width() - $width, 2)); + $top = max(0, intdiv(get_screen_height() - 35, 2)); + $border = new DefaultBorderPack(); + $this->summaryPanel = new MagicTabPanel('Records', '', new Vector2($left, $top), $width, self::SUMMARY_PANEL_HEIGHT, $border, heightPolicy: WindowHeightPolicy::FIXED); + $this->listPanel = new Window('', '', new Vector2($left, $top + self::SUMMARY_PANEL_HEIGHT), $width, self::LIST_PANEL_HEIGHT, $border, heightPolicy: WindowHeightPolicy::FIXED); + $this->infoPanel = new Window('Info', '', new Vector2($left, $top + self::SUMMARY_PANEL_HEIGHT + self::LIST_PANEL_HEIGHT), $width, self::INFO_PANEL_HEIGHT, $border, heightPolicy: WindowHeightPolicy::FIXED); } - protected function handleNavigation(): void + protected function refreshUI(): void { - $vertical = Input::getAxis(AxisName::VERTICAL); - if (abs($vertical) > 0 && $this->rows !== []) { - $this->activeIndex = wrap($this->activeIndex + ($vertical > 0 ? 1 : -1), 0, count($this->rows) - 1); - $this->refreshListPanel(); - $this->refreshInfoPanel(); + $content = $this->getPresentationContent(); + $this->syncReading($content); + $width = $this->listPanel->getContentWidth() - 2; + $capacity = $this->listPanel->getContentHeight(); + assert($this->summaryPanel instanceof MagicTabPanel); + $this->summaryPanel->setTabs($content->tabs, $content->tabIndex); + $this->summaryPanel->setContent([...$this->summaryPanel->getContent(), $content->summary]); + $first = max(0, min($this->activeIndex - $capacity + 1, count($content->rows) - $capacity)); + $lines = []; + foreach (array_slice($content->rows, $first, $capacity, true) as $i => $row) { + $line = ($i === $this->activeIndex ? '> ' : ' ') . TextViewport::preview(implode(' | ', [$row['label'], ...$row['values']]), $width); + $lines[] = $i === $this->activeIndex ? SelectionStyle::apply($line) : $line; } + $this->listPanel->setHelp($content->rows === [] ? '' : sprintf('%d-%d / %d', $first + 1, $first + count($lines), count($content->rows))); + if ($content->rows === []) { $lines[] = $content->emptyText; } + $this->listPanel->setContent(array_pad($lines, $capacity, '')); + $metadata = !$this->viewingDetail && $content->rows !== [] ? ['View details'] : []; + $page = $this->reading->page($width, $this->infoPanel->getContentHeight() - count($metadata)); + $this->displayedPage = $page; + $this->infoPanel->setHelp($this->viewingDetail ? 'Details - ' . $page->range() : ''); + $this->infoPanel->setContent(array_pad([...$metadata, ...$page->lines], $this->infoPanel->getContentHeight(), '')); + $this->summaryPanel->render(); + $this->listPanel->render(); + $this->infoPanel->render(); } public function resume(): void diff --git a/src/Scenes/Game/States/SaveMenuState.php b/src/Scenes/Game/States/SaveMenuState.php index eaaacad5..ccabe25d 100644 --- a/src/Scenes/Game/States/SaveMenuState.php +++ b/src/Scenes/Game/States/SaveMenuState.php @@ -10,7 +10,12 @@ use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\IO\Saves\SaveSlot; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\Scenes\SceneStateContext; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; +use Ichiloto\Engine\UI\Presentation\SaveLoadMenuPresentation; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\SaveSlotWindow; use Ichiloto\Engine\UI\Windows\Window; @@ -21,8 +26,17 @@ * * @package Ichiloto\Engine\Scenes\Game\States */ -class SaveMenuState extends GameSceneState implements CanRender +class SaveMenuState extends GameSceneState implements CanRender, CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + return SaveLoadMenuPresentation::compose($this->slots, $this->activeSlotIndex, $theme, + $this->menuInfoText, $this->statusMessage, $time, title: 'Save', + prompt: 'Which file would you like to save to?', statusColor: $this->statusColor); + } + protected const int SAVE_MENU_WIDTH = 110; protected const int SAVE_INFO_HEIGHT = 3; protected const int SAVE_HELP_HEIGHT = 4; @@ -60,12 +74,16 @@ class SaveMenuState extends GameSceneState implements CanRender * @var string|null The latest short status message. */ protected ?string $statusMessage = null; + protected string $statusColor = 'accent'; /** * @inheritDoc */ public function enter(): void { + $this->resetMenuPresentation(); + $this->statusMessage = null; + $this->statusColor = 'accent'; Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->calculateMargins(); @@ -230,6 +248,7 @@ protected function saveToActiveSlot(): void $savedSlot = $this->getGameScene()->sceneManager->saveManager->save($this->getGameScene(), $slot->slot); } catch (ActiveEventSaveException $exception) { $this->statusMessage = $exception->getMessage(); + $this->statusColor = 'decrease'; Debug::warn($this->statusMessage); $this->render(); alert($this->statusMessage, 'Save Unavailable'); @@ -239,6 +258,7 @@ protected function saveToActiveSlot(): void $this->getGameScene()->getGame()->audioManager->playSystemSound(SystemSound::SAVE); $this->refreshSlots(); $this->statusMessage = sprintf('Saved to File %d.', $savedSlot->slot); + $this->statusColor = 'increase'; $this->render(); alert($this->statusMessage, 'Save Complete'); } diff --git a/src/Scenes/Game/States/ShopState.php b/src/Scenes/Game/States/ShopState.php index 502516f6..d7b404c5 100644 --- a/src/Scenes/Game/States/ShopState.php +++ b/src/Scenes/Game/States/ShopState.php @@ -137,13 +137,13 @@ class ShopState extends GameSceneState } } /** - * @var InventoryItem[] The player's items eligible for sale. Key items are excluded. + * @var InventoryItem[] The player's held items whose definitions permit sale. */ public array $sellableItems { get { return array_values(array_filter( $this->inventory->all->toArray(), - static fn(InventoryItem $item): bool => ! $item->isKeyItem + static fn(InventoryItem $item): bool => $item->isSellable && $item->quantity > 0 )); } } diff --git a/src/Scenes/Game/States/StatusViewState.php b/src/Scenes/Game/States/StatusViewState.php index c2d131de..afecd5e0 100644 --- a/src/Scenes/Game/States/StatusViewState.php +++ b/src/Scenes/Game/States/StatusViewState.php @@ -13,14 +13,25 @@ use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; +use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; +use Ichiloto\Engine\UI\Presentation\CharacterMenuPresentation; +use Ichiloto\Engine\UI\Presentation\MenuCanvasState; +use Ichiloto\Engine\UI\Presentation\MenuPresentationCatalog; /** * Displays one party member's full profile and allows cycling between members. * * @package Ichiloto\Engine\Scenes\Game\States */ -class StatusViewState extends GameSceneState +class StatusViewState extends GameSceneState implements CanvasProviderInterface { + use MenuCanvasState; + + protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas + { + return $this->character === null ? null : CharacterMenuPresentation::status($this->character, $theme, $time); + } protected const int PROFILE_SUMMARY_PANEL_WIDTH = 110; protected const int PROFILE_SUMMARY_PANEL_HEIGHT = 9; protected const int STATS_SUMMARY_PANEL_WIDTH = 40; @@ -76,6 +87,7 @@ class StatusViewState extends GameSceneState public function enter(): void { + $this->resetMenuPresentation(); Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); $this->character ??= $this->getGameScene()->party->leader; diff --git a/src/Scenes/SceneManager.php b/src/Scenes/SceneManager.php index fb0e73fb..504c6650 100644 --- a/src/Scenes/SceneManager.php +++ b/src/Scenes/SceneManager.php @@ -25,6 +25,7 @@ use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Scenes\GameOver\GameOverScene; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; +use Throwable; /** * SceneManager is a class that manages scenes. @@ -153,8 +154,21 @@ public function start(): void */ public function stop(): void { + $failure = null; foreach ($this->scenes as $scene) { - $scene->stop(); + try { + // Include the current scene even if startup failed partway through. + if ($scene === $this->currentScene || $scene->isStarted()) { + $scene->stop(); + } + } catch (Throwable $error) { + $failure ??= $error; + } + } + $this->sceneBeforeBattle = null; + + if ($failure !== null) { + throw $failure; } } @@ -200,26 +214,56 @@ public function findScene(string $className): ?SceneInterface * Load a scene. * * @param string|int $index The index of the scene to load. + * @param bool $suspendCurrent Retain the battle caller recorded by loadBattleScene(). * @return SceneManager The scene manager. * @throws NotFoundException */ - public function loadScene(string|int $index): self + public function loadScene(string|int $index, bool $suspendCurrent = false): self { $sceneToLoad = match(true) { is_int($index) => $this->scenes->toArray()[$index] ?? throw new NotFoundException($index), - default => $this->scenes->find(fn(SceneInterface $scene) => $scene::class === $index) ?? throw new NotFoundException($index), + default => $this->findScene($index) ?? throw new NotFoundException($index), }; + if ($suspendCurrent && (! $sceneToLoad instanceof BattleScene + || $this->currentScene instanceof BattleScene + || ($this->currentScene !== null && $this->sceneBeforeBattle !== $this->currentScene::class))) { + throw new \LogicException('Only a recorded battle caller can be suspended for a battle.'); + } + if ($this->currentScene === $sceneToLoad) { return $this; } $this->eventManager->dispatchEvent(new SceneEvent(SceneEventType::LOAD_START, $this->currentScene)); - $this->unloadScene($this->currentScene); + if ($suspendCurrent) { + $this->currentScene?->suspend(); + $this->eventManager->dispatchEvent(new SceneEvent(SceneEventType::SUSPEND, $this->currentScene)); + } else { + $suspended = $this->sceneBeforeBattle === null ? null : $this->findScene($this->sceneBeforeBattle); + $this->sceneBeforeBattle = null; + $outgoing = [$this->currentScene]; + // Going somewhere other than the battle's caller abandons that caller. + if ($suspended !== null && $suspended !== $sceneToLoad && $suspended !== $this->currentScene) { + $outgoing[] = $suspended; + } + $failure = null; + foreach ($outgoing as $scene) { + try { + $this->unloadScene($scene); + } catch (Throwable $error) { + $failure ??= $error; + } + } + if ($failure !== null) { + throw $failure; + } + } $this->currentScene = $sceneToLoad; if ($this->currentScene?->isStarted()) { $this->currentScene?->resume(); + $this->eventManager->dispatchEvent(new SceneEvent(SceneEventType::RESUME, $this->currentScene)); } else { $this->currentScene?->start(); } @@ -278,9 +322,10 @@ public function unloadScene(?SceneInterface $scene): self return $this; } - if ($this->scenes->contains($scene)) { + if ($this->scenes->contains($scene) + && ($scene->isStarted() || ($scene instanceof GameScene && $scene->hasUnstableEventSession()))) { $scene->stop(); - $this->eventManager->dispatchEvent(new SceneEvent(SceneEventType::UNLOAD, $this->currentScene)); + $this->eventManager->dispatchEvent(new SceneEvent(SceneEventType::UNLOAD, $scene)); } return $this; @@ -327,12 +372,9 @@ public function loadGameOverScene(): void */ public function loadBattleScene(Party $party, Troop $troop, array $events = [], array $extraSettings = []): void { - // Remembered so the battle goes back where it came from. A fight started - // from the field returns to the field; one started from the arena returns - // to the arena. - $this->sceneBeforeBattle = $this->currentScene instanceof BattleScene - ? $this->sceneBeforeBattle - : $this->currentScene::class; + if ($this->currentScene instanceof BattleScene || $this->sceneBeforeBattle !== null) { + throw new \LogicException('A battle already owns the scene transition.'); + } if ($party->isDefeated()) { $this->loadGameOverScene(); @@ -343,7 +385,9 @@ public function loadBattleScene(Party $party, Troop $troop, array $events = [], $config = $this->battleLoader->newConfig($party, $troop, $events, $extraSettings); $this->game->useBattleEngineType(BattleEngineType::fromValue($config->settings['engine'] ?? null)); - $currentScene = $this->loadScene(BattleScene::class)->currentScene; + // Only acquire the return owner after configuration preparation succeeds. + $this->sceneBeforeBattle = $this->currentScene === null ? null : $this->currentScene::class; + $currentScene = $this->loadScene(BattleScene::class, suspendCurrent: true)->currentScene; if (! $currentScene instanceof BattleScene) { throw new NotFoundException('The current scene is not a battle scene.'); @@ -365,19 +409,16 @@ public function loadBattleScene(Party $party, Troop $troop, array $events = [], */ public function returnFromBattleScene(): void { - $battleResult = $this->currentScene instanceof BattleScene - ? $this->currentScene->result - : null; + if (! $this->currentScene instanceof BattleScene) { + return; + } - $this->loadScene($this->sceneToReturnTo()); - $this->sceneBeforeBattle = null; + $battleResult = $this->currentScene->result; - if ($this->currentScene instanceof GameScene) { - $this->currentScene->fieldState?->resume(); + $this->loadScene($this->sceneToReturnTo()); - if ($battleResult !== null) { - $this->currentScene->resumeEventAfterBattle($battleResult); - } + if ($this->currentScene instanceof GameScene && $battleResult !== null) { + $this->currentScene->resumeEventAfterBattle($battleResult); } } diff --git a/src/Scenes/Title/TitleCanvasPresentation.php b/src/Scenes/Title/TitleCanvasPresentation.php new file mode 100644 index 00000000..0439086c --- /dev/null +++ b/src/Scenes/Title/TitleCanvasPresentation.php @@ -0,0 +1,143 @@ +titleCatalog = null; + $this->titleCatalogLoaded = false; + $this->titlePresentationError = null; + $this->titleSuspended = false; + $this->titleInfoText = new MenuInfoText(); + $this->titlePlayback = new TitlePlayback(); + $this->advanceTitlePresentation(); + } + + protected function advanceTitlePresentation(): void + { + if ($this->titlePlayback === null) { return; } + $now = hrtime(true) / 1e9; + $game = isset($this->sceneManager) ? $this->getGame() : null; + $windowActive = $game?->getRendererRuntime()?->windowActive ?? true; + $modal = isset($game->modalManager) ? $game->modalManager->currentModal : null; + $this->titlePlayback->setObscured(!$windowActive || $this->titleSuspended || $this->showingOptions + || $this->showingContinueMenu || $this->creditsPlayback !== null || ($modal?->isShowing() ?? false), $now); + $this->titlePlayback->advance($now, null, Accessibility::prefersReducedMotion()); + $this->creditsPlayback?->advance($now, !$windowActive || $this->titleSuspended || ($modal?->isShowing() ?? false)); + } + + protected function setTitlePresentationSuspended(bool $suspended): void + { + $this->titleSuspended = $suspended; + $this->advanceTitlePresentation(); + } + + protected function stopTitlePresentation(): void + { + $this->titlePlayback = null; + $this->titleCatalog = null; + $this->titleInfoText = null; + } + + public function getPresentationCanvas(): ?PresentationCanvas + { + if ($this->titlePlayback === null || !isset($this->menu)) { return null; } + $game = $this->getGame(); + $runtime = $game->getRendererRuntime(); + if ($runtime === null) { return null; } + try { + $modal = isset($game->modalManager) ? $game->modalManager->currentModal : null; + if ($modal !== null && !$modal->isShowing()) { $modal = null; } + if ($this->creditsPlayback !== null && $this->creditsContent !== null && $this->creditsTheme !== null) { + $credits = CreditsMenuPresentation::compose($this->creditsContent, $this->creditsPlayback, $this->creditsTheme); + $canvas = $this->titleCatalog === null ? $credits : MenuCanvas::overlay( + TitleMenuPresentation::compose($this->titleCatalog, $this->titlePlayback, [], false), $credits, $this->creditsTheme); + if ($modal !== null) { + $snapshot = $modal instanceof ModalPresentationProviderInterface ? $modal->getModalPresentation() : null; + if ($snapshot === null) { throw new RuntimeException('Active credits modal has no supported canvas presentation.'); } + $canvas = MenuModalPresentation::compose($canvas, $snapshot, $this->creditsTheme); + } + return $canvas; + } + if (!$this->titleCatalogLoaded) { + $this->titleCatalogLoaded = true; + $catalog = TitlePresentationCatalog::load($runtime->getAssetRoot()); + if ($catalog !== null) { + foreach (TitlePresentationCatalog::CAPABILITIES as $capability) { + if (!$runtime->supports($capability)) { throw new RuntimeException("Title renderer lacks {$capability}."); } + } + } + $this->titleCatalog = $catalog; + } + if ($this->titleCatalog === null) { return null; } + $catalog = $this->titleCatalog; + $rows = []; + foreach ($this->menu->getItems() as $index => $command) { + $rows[] = new MenuRow('command-' . $index, $command->getLabel(), kind: MenuRowKind::BUTTON, + selected: $index === $this->menu->activeIndex, focused: $index === $this->menu->activeIndex, + disabled: $command->isDisabled(), showCursor: false); + } + $time = $this->titlePlayback->elapsed; + $canvas = TitleMenuPresentation::compose($catalog, $this->titlePlayback, $rows, + !$this->showingOptions && !$this->showingContinueMenu && $modal === null); + $info = $this->titleInfoText ??= new MenuInfoText(); + if ($this->showingOptions && $this->optionsManager !== null) { + $choices = array_map(fn($setting) => $this->optionsManager->getCurrentChoiceIndex($setting), $this->options); + $snapshot = new SettingsMenuContent('Options', $this->options, $choices, $this->activeOptionIndex, $info, + $this->optionStatusMessage, $this->optionStatusMessage !== null, 'Back', $this->isBackRowSelected(), + new CanvasRectangle(225, 60, 900, 600)); + $canvas = MenuCanvas::overlay($canvas, SettingsMenuPresentation::compose($snapshot, $catalog->theme, $time), $catalog->theme); + } elseif ($this->showingContinueMenu) { + $canvas = MenuCanvas::overlay($canvas, SaveLoadMenuPresentation::compose($this->continueSlots, + $this->activeContinueSlotIndex, $catalog->theme, $info, $this->continueStatusMessage, $time), $catalog->theme); + } + if ($modal !== null) { + $snapshot = $modal instanceof ModalPresentationProviderInterface ? $modal->getModalPresentation() : null; + if ($snapshot === null) { throw new RuntimeException('Active title modal has no supported canvas presentation.'); } + $canvas = MenuModalPresentation::compose($canvas, $snapshot, $catalog->theme, $time); + } + return $canvas; + } catch (Throwable $error) { + if ($this->creditsPlayback !== null) { $this->creditsPresentationFailed = true; } + if ($this->titlePresentationError !== $error->getMessage()) { + Debug::error('Title presentation degraded to terminal: ' . $error->getMessage()); + $this->titlePresentationError = $error->getMessage(); + } + return null; + } + } +} diff --git a/src/Scenes/Title/TitleCredits.php b/src/Scenes/Title/TitleCredits.php new file mode 100644 index 00000000..5cefbc60 --- /dev/null +++ b/src/Scenes/Title/TitleCredits.php @@ -0,0 +1,89 @@ +resetCredits(); + $content = new CreditsContent($sections); + $runtime = $this->getGame()->getRendererRuntime(); + if ($runtime !== null && !Accessibility::prefersReducedMotion()) { + try { + if (array_all(CreditsMenuPresentation::CAPABILITIES, $runtime->supports(...))) { + // Initialize the optional title backdrop without advancing any presentation clock. + $this->getPresentationCanvas(); + $theme = $this->titleCatalog?->theme ?? MenuPresentationCatalog::load($runtime->getAssetRoot()) + ?? new MenuPresentationCatalog($runtime->getAssetRoot(), ['schema' => 'ichiloto.menu/1']); + $playback = CreditsMenuPresentation::createPlayback($content, $theme); + CreditsMenuPresentation::compose($content, $playback, $theme); + $this->creditsContent = $content; + $this->creditsPlayback = $playback; + $this->creditsTheme = $theme; + $playback->advance(hrtime(true) / 1e9); + Console::clear(); + InputManager::resetState(true); + $this->advanceTitlePresentation(); + return; + } + } catch (Throwable $error) { + Debug::error('Credits presentation degraded to centered alerts: ' . $error->getMessage()); + } + } + $this->getGame()->modalManager->showCredits($content); + } + + public function closeCredits(): void + { + $this->resetCredits(); + InputManager::resetState(true); + $this->resume(); + } + + protected function updateCredits(): void + { + if ($this->creditsPlayback === null) { return; } + $runtime = $this->getGame()->getRendererRuntime(); + if ($runtime === null || $this->creditsPresentationFailed || Accessibility::prefersReducedMotion()) { + $content = $this->creditsContent; + $this->resetCredits(); + if ($content !== null) { $this->getGame()->modalManager->showCredits($content); } + return; + } + $modal = $this->getGame()->modalManager->currentModal; + $paused = $this->titleSuspended || !$runtime->windowActive || ($modal?->isShowing() ?? false); + if ($paused) { return; } + if ($this->creditsPlayback->finished || Input::isButtonDown('confirm') + || Input::isButtonDown('cancel') || Input::isButtonDown('back')) { + $this->closeCredits(); + } + } + + protected function resetCredits(): void + { + $this->creditsContent = null; + $this->creditsPlayback = null; + $this->creditsTheme = null; + $this->creditsPresentationFailed = false; + } +} diff --git a/src/Scenes/Title/TitleScene.php b/src/Scenes/Title/TitleScene.php index 434ed495..32577eab 100644 --- a/src/Scenes/Title/TitleScene.php +++ b/src/Scenes/Title/TitleScene.php @@ -18,6 +18,7 @@ use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\IO\Saves\SaveSlot; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasProviderInterface; use Ichiloto\Engine\Scenes\AbstractScene; use Ichiloto\Engine\Scenes\Game\GameLoader; use Ichiloto\Engine\UI\SelectionStyle; @@ -34,8 +35,10 @@ * * @package Ichiloto\Engine\Scenes\Title */ -class TitleScene extends AbstractScene +class TitleScene extends AbstractScene implements CanvasProviderInterface { + use TitleCanvasPresentation; + use TitleCredits; protected const int TITLE_MENU_WIDTH = 16; protected const int TITLE_OPTIONS_MIN_WIDTH = 34; protected const int TITLE_OPTIONS_HORIZONTAL_PADDING = 4; @@ -170,6 +173,7 @@ public function start(): void $this->initializeContinueMenuWindows(); $this->resetTitleInteractionState(); $this->syncContinueAvailability(); + $this->resetTitlePresentation(); $this->renderHeader(); usleep(300); @@ -183,6 +187,17 @@ public function start(): void public function update(): void { parent::update(); + $this->advanceTitlePresentation(); + + if ($this->creditsPlayback !== null) { + $this->updateCredits(); + return; + } + + if (Input::isButtonDown('info') && ($this->showingOptions || $this->showingContinueMenu)) { + $this->titleInfoText?->advance(); + return; + } if ($this->showingOptions) { $this->updateOptionsMenu(); @@ -267,9 +282,11 @@ public function getBackgroundMusic(): ?string public function resume(): void { Console::clear(); - $this->resetTitleInteractionState(); + $this->setTitlePresentationSuspended(false); $this->syncContinueAvailability(); + if ($this->creditsPlayback !== null) { return; } + if ($this->showingContinueMenu) { $this->renderContinueMenu(); return; @@ -291,9 +308,18 @@ public function resume(): void */ public function suspend(): void { + $this->setTitlePresentationSuspended(true); Console::clear(); } + #[Override] + public function stop(): void + { + $this->resetCredits(); + $this->stopTitlePresentation(); + parent::stop(); + } + /** * Re-centers the title layout after a terminal resize. * @@ -316,6 +342,8 @@ public function onScreenResize(int $width, int $height): void Console::clear(); + if ($this->creditsPlayback !== null) { return; } + if ($this->showingContinueMenu) { $this->renderContinueMenu(); return; @@ -344,6 +372,8 @@ public function openOptionsMenu(): void Console::clear(); $this->initializeOptionsWindow(); $this->showingOptions = true; + $this->optionStatusMessage = null; + $this->advanceTitlePresentation(); $this->renderOptionsMenu(); } @@ -360,6 +390,7 @@ public function openContinueMenu(): void Console::clear(); $this->initializeContinueMenuWindows(); $this->showingContinueMenu = true; + $this->advanceTitlePresentation(); $this->renderContinueMenu(); } @@ -371,6 +402,7 @@ public function openContinueMenu(): void public function closeContinueMenu(): void { $this->showingContinueMenu = false; + $this->advanceTitlePresentation(); $this->continueStatusMessage = null; Console::clear(); $this->renderHeader(); @@ -565,6 +597,7 @@ protected function updateContinueMenu(): void protected function closeOptionsMenu(): void { $this->showingOptions = false; + $this->advanceTitlePresentation(); Console::clear(); $this->renderHeader(); $this->menu->render(); @@ -615,8 +648,11 @@ protected function changeActiveOption(int $step): void try { - $label = $this->optionsManager->cycle($option, $step); - } catch (\Throwable $e) { + $this->optionsManager->cycle($option, $step); + $this->optionStatusMessage = null; + } catch (Throwable $error) { + $this->optionStatusMessage = 'Could not save settings: ' . $error->getMessage(); + Debug::error($this->optionStatusMessage); } $this->renderOptionsMenu(); } @@ -783,6 +819,7 @@ protected function resolveDefaultContinueSlotIndex(): int */ protected function resetTitleInteractionState(): void { + $this->resetCredits(); $this->showingOptions = false; $this->showingContinueMenu = false; $this->activeOptionIndex = 0; diff --git a/src/Shop/Shop.php b/src/Shop/Shop.php index dcdab78d..fa176efd 100644 --- a/src/Shop/Shop.php +++ b/src/Shop/Shop.php @@ -46,6 +46,10 @@ public function __construct( */ public function sell(InventoryItem $item, int $quantity, Trader $trader): void { + if ($quantity < 1) { + return; + } + $totalCost = (int) round($item->price * $quantity * $this->traderBuyRate); if ($trader->accountBalance < $totalCost) { @@ -53,15 +57,20 @@ public function sell(InventoryItem $item, int $quantity, Trader $trader): void return; } - if ($foundItem = $trader->inventory->items->find(fn(InventoryItem $inventoryItem) => $item->id === $inventoryItem->id)) { - if ($foundItem->quantity + $quantity > $foundItem->maxQuantity) { - alert('Not enough space in inventory!'); - return; - } + $foundItem = $trader->inventory->all->find(fn(InventoryItem $inventoryItem) => $item->id === $inventoryItem->id); + $remainingSpace = $foundItem instanceof InventoryItem + ? $foundItem->maxQuantity - $foundItem->quantity + : ($trader->inventory->isFull ? 0 : $item->maxQuantity); + if ($quantity > $remainingSpace) { + alert('Not enough space in inventory!'); + return; } + // A catalogue entry is not an owned stack. Its quantity never controls a purchase. + $purchasedItem = clone $item; + $purchasedItem->quantity = 1; for($count = 0; $count < $quantity; $count++) { - $trader->inventory->addItems($item); + $trader->inventory->addItems($purchasedItem); } $trader->debit($totalCost); @@ -81,18 +90,19 @@ public function buy(InventoryItem $item, int $quantity, Trader $trader): void return; } - if ($item->isKeyItem) { - return; - } - - if ($trader->inventory->getQuantity($item->id, 'selling an inventory item') < $quantity) { + // The offer identifies a stack; only the owned definition controls the sale. + $ownedItem = array_find( + $trader->inventory->all->toArray(), + static fn(InventoryItem $entry): bool => $entry->id === $item->id, + ); + if (! $ownedItem instanceof InventoryItem || ! $ownedItem->isSellable || $ownedItem->quantity < $quantity) { return; } - $totalPayout = (int) round($item->price * $quantity * $this->traderSellRate); + $totalPayout = (int) round($ownedItem->price * $quantity * $this->traderSellRate); for($count = 0; $count < $quantity; $count++) { - $trader->inventory->removeItems($item); + $trader->inventory->removeItems($ownedItem); } $trader->credit($totalPayout); diff --git a/src/UI/Interfaces/ModalPresentationProviderInterface.php b/src/UI/Interfaces/ModalPresentationProviderInterface.php new file mode 100644 index 00000000..6cdac98c --- /dev/null +++ b/src/UI/Interfaces/ModalPresentationProviderInterface.php @@ -0,0 +1,13 @@ +title, $this->message, $this->buttons, $this->activeIndex, + singleConfirmation: count($this->buttons) === 1); + } + /** * Constructs a new AlertModal instance. * @@ -34,4 +40,4 @@ public function __construct( new Rect(0, 0, $width, DEFAULT_DIALOG_HEIGHT) ); } -} \ No newline at end of file +} diff --git a/src/UI/Modal/ConfirmModal.php b/src/UI/Modal/ConfirmModal.php index 6bdb0b12..85d13996 100644 --- a/src/UI/Modal/ConfirmModal.php +++ b/src/UI/Modal/ConfirmModal.php @@ -13,6 +13,11 @@ */ class ConfirmModal extends Modal { + public function getModalPresentation(): ?ModalPresentation + { + return new ModalPresentation($this->title, $this->message, $this->buttons, $this->activeIndex); + } + /** * Constructs a new AlertModal instance. * @@ -48,4 +53,4 @@ public function submit(): void parent::submit(); $this->value =$this->activeIndex === 0; } -} \ No newline at end of file +} diff --git a/src/UI/Modal/CreditsModal.php b/src/UI/Modal/CreditsModal.php new file mode 100644 index 00000000..d70a022d --- /dev/null +++ b/src/UI/Modal/CreditsModal.php @@ -0,0 +1,51 @@ + */ + private array $pages; + private int $pageIndex = 0; + + public function __construct(Game $game, CreditsContent $content) + { + $width = min(DEFAULT_DIALOG_WIDTH, max(4, get_screen_width())); + // The same pages also fit the graphical alert when reduced motion is enabled. + $this->pages = $content->getPages($width - 2, min(12, max(1, get_screen_height() - 3))); + parent::__construct($game, $this->pages[0], get_message('title.credits', 'Credits'), $width); + $this->updatePage(); + } + + protected function submit(): void + { + if (!isset($this->pages[$this->pageIndex + 1])) { parent::submit(); return; } + $this->erase(); + $this->pageIndex++; + $this->updatePage(); + $this->fitContentToWidth(); + $this->positionForOpen(); + } + + private function updatePage(): void + { + $this->message = $this->pages[$this->pageIndex]; + $this->buttons = [isset($this->pages[$this->pageIndex + 1]) ? 'Next' : 'OK']; + $this->help = count($this->pages) > 1 ? sprintf('%d / %d', $this->pageIndex + 1, count($this->pages)) : ''; + } + + protected function fitContentToWidth(): void + { + // CreditsContent already wraps in terminal display cells, including wide glyphs. + $this->content = explode("\n", $this->message); + $this->contentHeight = count($this->content); + $this->rect->setHeight($this->contentHeight + 3); + $this->rebuildWindow(); + } +} diff --git a/src/UI/Modal/Modal.php b/src/UI/Modal/Modal.php index 55e0535e..2aae6be7 100644 --- a/src/UI/Modal/Modal.php +++ b/src/UI/Modal/Modal.php @@ -23,6 +23,7 @@ use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\UI\Interfaces\ModalInterface; +use Ichiloto\Engine\UI\Interfaces\ModalPresentationProviderInterface; use Ichiloto\Engine\UI\Interfaces\LayeredPresentationInterface; use Ichiloto\Engine\UI\Enumerations\PresentationPriority; use Ichiloto\Engine\UI\UIManager; @@ -39,7 +40,7 @@ * * @package Ichiloto\Engine\UI\Modal */ -abstract class Modal implements ModalInterface, LayeredPresentationInterface +abstract class Modal implements ModalInterface, LayeredPresentationInterface, ModalPresentationProviderInterface { /** * @var Window $window The window of the modal. @@ -154,6 +155,11 @@ public function render(): void PresentationLayerPolicy::ui($this, $this->window->render(...)); } + public function getModalPresentation(): ?ModalPresentation + { + return null; + } + /** * @inheritDoc */ @@ -193,7 +199,7 @@ public function update(): void $this->submit(); } - if (Input::isButtonDown("cancel")) { + elseif (Input::isButtonDown("cancel")) { $this->cancel(); } } @@ -203,6 +209,9 @@ public function update(): void */ public function show(): void { + if ($this->game->hasStopped()) { + return; + } $this->isShowing = true; $this->getUIManager()?->present($this); $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::SHOW, true)); @@ -214,10 +223,14 @@ public function show(): void public function hide(): void { if ($this->isShowing) { - $this->erase(); + if (! $this->game->hasStopped()) { + $this->erase(); + } $this->isShowing = false; $this->getUIManager()?->dismiss($this); - $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::HIDE, false)); + if (! $this->game->hasStopped()) { + $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::HIDE, false)); + } } } @@ -253,29 +266,48 @@ protected function getUIManager(): ?UIManager */ public function open(): mixed { - $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::OPEN, true)); - // Fit before centring, so the box is measured at its final height. - $this->fitContentToWidth(); - $this->positionForOpen(); - $this->show(); - $this->render(); - - while ($this->isShowing) { - $this->handleInput(); - $this->update(); - - // submit() and cancel() hide and erase the modal. Drawing again in the - // same iteration resurrects the overlay after dismissal, and close() - // will not erase it a second time because it is already hidden. - if (! $this->isShowing) { - break; + if ($this->game->hasStopped()) { + return null; + } + + try { + $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::OPEN, true)); + if ($this->game->hasStopped()) { + return null; } + // Fit before centring, so the box is measured at its final height. + $this->fitContentToWidth(); + $this->positionForOpen(); + $this->show(); + if ($this->game->hasStopped()) { + return null; + } + $this->render(); + + while ($this->isShowing && ! $this->game->hasStopped()) { + $this->handleInput(); + if ($this->game->hasStopped()) { + break; + } + $this->update(); + + // A dismissed modal or stopped game cannot own another input/render frame. + if (! $this->isShowing || $this->game->hasStopped()) { + break; + } - // Draw after background updates, but before the completed frame is presented. - Timers::wait(1 / 60, fn() => $this->render()); + // A blocked-frame callback may quit after wait begins but before it paints. + Timers::wait(1 / 60, function (): void { + if (! $this->game->hasStopped()) { + $this->render(); + } + }); + } + } finally { + $result = $this->close(); } - return $this->close(); + return $result; } /** @@ -283,10 +315,12 @@ public function open(): mixed */ public function close(): mixed { - // Remove the overlay before the scene resumes. FieldState::resume() - // redraws the world in response to CLOSE; erasing after that redraw would - // blank the modal footprint and leave dynamic field objects missing. + // Remove the overlay before the scene resumes. A stopped game only releases ownership. $this->hide(); + if ($this->game->hasStopped()) { + $this->value = null; + return null; + } $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::CLOSE, $this->value)); return $this->value; } diff --git a/src/UI/Modal/ModalManager.php b/src/UI/Modal/ModalManager.php index 5c74b32c..ba1153f1 100644 --- a/src/UI/Modal/ModalManager.php +++ b/src/UI/Modal/ModalManager.php @@ -9,6 +9,7 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\UI\Interfaces\ModalInterface; +use Ichiloto\Engine\UI\Presentation\CreditsContent; use Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition; /** @@ -112,6 +113,18 @@ public function alert(string $message, string $title = '', int $width = DEFAULT_ $this->modals->pop(); } + /** Keeps long credits bounded without turning them into character dialogue. */ + public function showCredits(CreditsContent $content): void + { + $modal = new CreditsModal($this->game, $content); + $this->modals->push($modal); + try { + $modal->open(); + } finally { + $this->modals->pop(); + } + } + /** * Displays a confirmation box with the specified message and an OK and Cancel button. * @@ -129,6 +142,19 @@ public function confirm(string $message, string $title = '', int $width = DEFAUL return (bool)$result; } + /** Selects a bounded amount without performing the caller's action. */ + public function selectQuantity(string $message, int $maximum, string $title = '', int $initial = 1, + int $width = DEFAULT_DIALOG_WIDTH): ?int + { + $modal = new QuantityModal($this->game, $message, $maximum, $title, $initial, $width); + $this->modals->push($modal); + try { + return $modal->open(); + } finally { + $this->modals->pop(); + } + } + /** * Displays a dialog box that prompts the user for input with specified message and an OK and Cancel button. * @@ -196,7 +222,7 @@ public function select( * @param string $message The message to display. * @param string $title The title of the dialog box. * @param string $help The help text to display. - * @param WindowPosition $position The position of the dialog box. + * @param WindowPosition|null $position Explicit position, or the narration/speech default. * @param float $charactersPerSecond The number of characters to display per second. * @return void */ @@ -204,7 +230,7 @@ public function showText( string $message, string $title = '', string $help = '', - WindowPosition $position = WindowPosition::BOTTOM, + ?WindowPosition $position = null, float $charactersPerSecond = 1 ): void { @@ -212,4 +238,4 @@ public function showText( $this->modals->peek()->open(); $this->modals->pop(); } -} \ No newline at end of file +} diff --git a/src/UI/Modal/ModalPresentation.php b/src/UI/Modal/ModalPresentation.php new file mode 100644 index 00000000..4ae97663 --- /dev/null +++ b/src/UI/Modal/ModalPresentation.php @@ -0,0 +1,35 @@ + */ + public array $choices; + + /** @param non-empty-list $choices */ + public function __construct(public string $title, public string $message, array $choices, + public int $activeIndex, public bool $vertical = false, public bool $singleConfirmation = false, + public ?QuantityPresentation $quantity = null) + { + if (!array_is_list($choices) || $choices === [] || !isset($choices[$activeIndex])) { + throw new InvalidArgumentException('Modal presentation requires choices and a valid owner-selected index.'); + } + if ($singleConfirmation && ($vertical || count($choices) !== 1)) { + throw new InvalidArgumentException('A single-confirmation presentation requires one horizontal choice.'); + } + $copy = []; + foreach ($choices as $choice) { + if (!is_string($choice) || trim($choice) === '') { + throw new InvalidArgumentException('Modal presentation choices must be nonempty labels.'); + } + $copy[] = $choice; + } + $this->choices = $copy; + } +} diff --git a/src/UI/Modal/QuantityModal.php b/src/UI/Modal/QuantityModal.php new file mode 100644 index 00000000..dc69983d --- /dev/null +++ b/src/UI/Modal/QuantityModal.php @@ -0,0 +1,82 @@ + $this->selector->quantity; } + public int $maximum { get => $this->selector->maximum; } + + public function __construct(Game $game, string $message, int $maximum, + string $title = '', int $initial = 1, int $width = DEFAULT_DIALOG_WIDTH) + { + $this->selector = new QuantitySelector($maximum, initial: $initial); + parent::__construct($game, $message, $title, new Rect(0, 0, $width, DEFAULT_DIALOG_HEIGHT), ['Continue']); + } + + public function getModalPresentation(): ?ModalPresentation + { + return new ModalPresentation($this->title, $this->message, $this->buttons, $this->activeIndex, + quantity: new QuantityPresentation($this->selector->minimum, $this->maximum, $this->quantity)); + } + + public function update(): void + { + if (Input::isButtonDown('cancel') || Input::isButtonDown('back')) { + $this->cancel(); + return; + } + $vertical = Input::getAxis(AxisName::VERTICAL); + $horizontal = Input::getAxis(AxisName::HORIZONTAL); + if ($vertical != 0 || $horizontal != 0) { + if ($this->selector->adjustForAxes($vertical, $horizontal)) { + $this->playInteractionSound(SystemSound::CURSOR); + $this->fitContentToWidth(); + } + return; + } + if (Input::isButtonDown('confirm')) { $this->submit(); } + } + + public function render(): void + { + $hints = []; + foreach (['up' => '+1', 'down' => '-1', 'right' => '+10', 'left' => '-10', 'confirm' => 'Continue', 'cancel' => 'Cancel'] as $action => $label) { + $hint = ActionHints::resolve($action, $label); + if ($hint->control !== null) { $hints[] = $hint->control->label . ': ' . $label; } + } + $this->help = implode(' ', $hints); + parent::render(); + } + + protected function submit(): void + { + parent::submit(); + $this->value = $this->selector->quantity; + } + + protected function fitContentToWidth(): void + { + $text = $this->message . "\n" . sprintf('Quantity: %0*d / %d', max(2, strlen((string)$this->maximum)), $this->quantity, $this->maximum); + $viewport = new TextViewport(); + $viewport->setText($text); + $this->content = $viewport->page(max(1, $this->rect->getWidth() - 2), PHP_INT_MAX)->lines; + $this->contentHeight = count($this->content); + $this->rect->setHeight($this->contentHeight + 3); + $this->rebuildWindow(); + } +} diff --git a/src/UI/Modal/QuantityPresentation.php b/src/UI/Modal/QuantityPresentation.php new file mode 100644 index 00000000..a3bed4fe --- /dev/null +++ b/src/UI/Modal/QuantityPresentation.php @@ -0,0 +1,18 @@ + $maximum) { + throw new InvalidArgumentException('Quantity presentation requires a value within nonnegative bounds.'); + } + } +} diff --git a/src/UI/Modal/SelectModal.php b/src/UI/Modal/SelectModal.php index c77d6c54..6f9cb7d8 100644 --- a/src/UI/Modal/SelectModal.php +++ b/src/UI/Modal/SelectModal.php @@ -17,10 +17,10 @@ use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; -use Ichiloto\Engine\IO\Enumerations\KeyCode; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\UI\Interfaces\ModalInterface; +use Ichiloto\Engine\UI\Interfaces\ModalPresentationProviderInterface; use Ichiloto\Engine\UI\Interfaces\LayeredPresentationInterface; use Ichiloto\Engine\UI\Enumerations\PresentationPriority; use Ichiloto\Engine\UI\UIManager; @@ -37,7 +37,7 @@ * * @package Ichiloto\Engine\UI\Modal */ -class SelectModal implements ModalInterface, LayeredPresentationInterface +class SelectModal implements ModalInterface, LayeredPresentationInterface, ModalPresentationProviderInterface { use ObservableTrait; @@ -156,6 +156,7 @@ public function __construct( $this->observers = new ItemList(ObserverInterface::class); $this->eventManager = EventManager::getInstance($game); $this->setOptions($options); + $this->activeOptionIndex = max(0, min($this->totalOptions - 1, $this->default)); $this->title = $title; $this->setHelp($help); $this->wrapMessage(); @@ -238,11 +239,16 @@ public function update(): void $this->playInteractionSound(SystemSound::CONFIRM); $this->value = $this->activeOptionIndex; $this->hide(); - } else if (Input::isAnyKeyPressed([KeyCode::C, KeyCode::c])) { + } else if (Input::isButtonDown('cancel')) { $this->cancel(); } } + public function getModalPresentation(): ?ModalPresentation + { + return new ModalPresentation($this->title, $this->message, array_values($this->options), $this->activeOptionIndex, true); + } + /** * Returns the height of the options. * @@ -281,6 +287,9 @@ public function erase(?int $x = null, ?int $y = null): void */ public function show(): void { + if ($this->game->hasStopped()) { + return; + } $this->isShowing = true; $this->getUIManager()?->present($this); $this->render(); @@ -293,10 +302,14 @@ public function show(): void public function hide(): void { if ($this->isShowing) { - $this->erase(); + if (! $this->game->hasStopped()) { + $this->erase(); + } $this->isShowing = false; $this->getUIManager()?->dismiss($this); - $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::HIDE, true)); + if (! $this->game->hasStopped()) { + $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::HIDE, true)); + } } } @@ -332,19 +345,39 @@ protected function getUIManager(): ?UIManager */ public function open(): int { - $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::OPEN, true)); - $this->show(); - - while ($this->isShowing) { - $this->handleInput(); - $this->update(); + if ($this->game->hasStopped()) { + return -1; + } - if ($this->isShowing) { - Timers::wait(1 / 60, fn() => $this->render()); + try { + $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::OPEN, true)); + if ($this->game->hasStopped()) { + return -1; } + $this->show(); + + while ($this->isShowing && ! $this->game->hasStopped()) { + $this->handleInput(); + if ($this->game->hasStopped()) { + break; + } + $this->update(); + + if (! $this->isShowing || $this->game->hasStopped()) { + break; + } + + Timers::wait(1 / 60, function (): void { + if (! $this->game->hasStopped()) { + $this->render(); + } + }); + } + } finally { + $result = $this->close(); } - return $this->close(); + return $result; } /** @@ -353,6 +386,10 @@ public function open(): int public function close(): int { $this->hide(); + if ($this->game->hasStopped()) { + $this->value = -1; + return -1; + } $this->eventManager->dispatchEvent(new ModalEvent(ModalEventType::CLOSE, true)); return $this->value; } diff --git a/src/UI/Modal/TextBoxModal.php b/src/UI/Modal/TextBoxModal.php index a5607bde..1fe97b00 100644 --- a/src/UI/Modal/TextBoxModal.php +++ b/src/UI/Modal/TextBoxModal.php @@ -15,6 +15,7 @@ use Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; +use Ichiloto\Engine\UI\Windows\WindowAlignment; /** * This class represents a modal that displays a message in a text box. @@ -76,7 +77,7 @@ class TextBoxModal extends Modal * @param string $message The message to display. * @param string $title The title of the modal. * @param string $help The help text to display. - * @param WindowPosition $position The position of the modal. + * @param WindowPosition|null $position Explicit position, or top for narration and bottom for speech. * @param BorderPackInterface $borderPack The border pack to use. * @param float $charactersPerSecond The number of characters to print per second. */ @@ -85,7 +86,7 @@ public function __construct( string $message, string $title = '', string $help = '', - WindowPosition $position = WindowPosition::BOTTOM, + ?WindowPosition $position = null, BorderPackInterface $borderPack = new DefaultBorderPack(), protected float $charactersPerSecond = 60 ) @@ -105,6 +106,7 @@ public function __construct( max(self::DEFAULT_CONTENT_LINES, min(count($wrappedLines), $linesPerPage)), ); $height = $contentLines + 2; + $position ??= trim($title) === '' ? WindowPosition::TOP : WindowPosition::BOTTOM; $positionCoordinates = $position->getCoordinates($width, $height); $this->messageLength = mb_strlen($this->currentPageMessage()); $this->authoredHelp = $help; @@ -263,7 +265,7 @@ protected function positionForOpen(): void $this->setPosition($this->rect->getX(), $this->rect->getY()); } - /** Dialogue windows retain their ordinary left-aligned text layout. */ + /** Prose and speech share left-aligned text, independent of the box's position. */ #[Override] protected function rebuildWindow(): void { @@ -274,6 +276,7 @@ protected function rebuildWindow(): void $this->rect->getWidth(), $this->rect->getHeight(), $this->borderPack, + alignment: WindowAlignment::topLeft(), heightPolicy: WindowHeightPolicy::FIXED, ); } diff --git a/src/UI/Presentation/CharacterMenuPresentation.php b/src/UI/Presentation/CharacterMenuPresentation.php new file mode 100644 index 00000000..d97408f4 --- /dev/null +++ b/src/UI/Presentation/CharacterMenuPresentation.php @@ -0,0 +1,207 @@ +character; + if ($character === null || $state->equipmentMenu === null) { return null; } + $view = new MenuCanvas($theme, time: $time); + $m = $theme->metrics; + $p = $m->panelPadding; + $info = $state->equipmentInfoPanel?->getPresentationText() ?? ''; + $hints = self::characterHints(); + $hintHeight = MenuActionHints::height($hints, $theme, self::WIDTH - 2 * $p); + $infoHeight = MenuInfoPanel::getHeight($theme, $hints, self::WIDTH); + $bodyHeight = self::HEIGHT - $infoHeight; + if ($bodyHeight < 300) { throw new RuntimeException('Equipment footer leaves insufficient menu viewport.'); } + $profile = self::box(0, 0, self::PROFILE_WIDTH, $bodyHeight); + $view->frame('equipment-profile', $profile); + $identityHeight = $view->prose('equipment-identity', $character->name . "\n" . $character->role->name, self::inset($profile, $p)); + $portraitY = self::TOP + $p + $identityHeight + $m->sectionGap; + $view->portrait($character->actorId, new CanvasRectangle(self::LEFT + (self::PROFILE_WIDTH - $m->portraitSize) / 2, + $portraitY, $m->portraitSize, $m->portraitSize)); + $statsY = $portraitY + $m->portraitSize + $m->sectionGap; + $statsBox = new CanvasRectangle($profile->x + $p, $statsY, $profile->width - 2 * $p, + $profile->y + $profile->height - $p - $statsY); + $stats = CharacterMenuRows::stats($character, $theme, true, $state->characterDetailPanel?->getPresentationPreview()); + $columns = self::numericColumns($stats); + // Compact comparison text still has explicit current/preview columns, even for large numbers. + $cells = 9 + array_sum(array_map(fn(MenuRowColumn $column) => $column->cells + $theme->rows->metrics->gapCells, $columns)); + $cw = min($m->cellWidth, (int)floor(($statsBox->width - 2 * $theme->rows->metrics->padding) / $cells)); + if ($cw < 4) { throw new RuntimeException('Equipment comparisons require more horizontal space.'); } + $rowHeight = min($m->rowHeight, (int)floor($statsBox->height / count($stats))); + $view->rows('equipment-stats', $stats, new MenuRowLayout($statsBox, $columns, $rowHeight, $cw, $m->cellHeight, true)); + + $commands = $state->equipmentMenu->getItems()->toArray(); + $commandHeight = $m->rowHeight + 2 * $p; + $commandBox = self::box(self::PROFILE_WIDTH, 0, self::WIDTH - self::PROFILE_WIDTH, $commandHeight); + $view->frame('equipment-commands', $commandBox, 'quiet'); + $buttonWidth = ($commandBox->width - 2 * $p - (count($commands) - 1) * $m->sectionGap) / max(1, count($commands)); + foreach ($commands as $index => $command) { + $selected = $index === $state->equipmentMenu->activeIndex; + $row = new MenuRow('command-' . $index, $command->getLabel(), kind: MenuRowKind::BUTTON, + selected: $selected, focused: $selected && $mode instanceof EquipmentMenuCommandSelectionMode, disabled: $command->isDisabled()); + $bounds = new CanvasRectangle($commandBox->x + $p + $index * ($buttonWidth + $m->sectionGap), + $commandBox->y + $p, $buttonWidth, $m->rowHeight); + $view->rows('equipment', [$row], self::layout($theme, $bounds)); + } + $assignment = self::box(self::PROFILE_WIDTH, $commandHeight, self::WIDTH - self::PROFILE_WIDTH, $bodyHeight - $commandHeight); + $view->frame('equipment-assignment', $assignment); + $content = self::inset($assignment, $p); + if ($mode instanceof EquipmentSelectionMode) { + $rows = []; + foreach ($mode->getPresentationCandidates() as $index => $candidate) { + $item = $candidate['equipment']; + $rows[] = new MenuRow('candidate-' . $index, $item->name, [new MenuRowValue((string)$candidate['available'])], + icon: CharacterMenuRows::icon($item, $mode->equipmentSlot?->semanticSlot), selected: $candidate['current'], + focused: $index === $mode->getPresentationIndex(), disabled: !$candidate['current'] && $candidate['available'] < 1); + } + $view->prose('equipment-slot-name', ($mode->equipmentSlot === null ? '' : $mode->equipmentSlot->name) . ' / Available', + new CanvasRectangle($content->x, $content->y, $content->width, $m->cellHeight)); + $listBox = new CanvasRectangle($content->x, $content->y + $m->cellHeight + $m->sectionGap, + $content->width, $content->height - $m->cellHeight - $m->sectionGap); + $view->rows('equipment-candidates', $rows, self::layout($theme, $listBox, self::numericColumns($rows)), $mode->getPresentationIndex()); + } else { + $index = $state->equipmentAssignmentPanel === null ? -1 : $state->equipmentAssignmentPanel->activeSlotIndex; + $rows = CharacterMenuRows::slots($character, $index, $mode instanceof EquipmentSlotSelectionMode); + $view->rows('equipment-slots', $rows, self::slotLayout($theme, $content, $rows), $index); + } + $infoBox = self::box(0, $bodyHeight, self::WIDTH, $infoHeight); + $view->frame('equipment-info', $infoBox, 'quiet'); + MenuInfoPanel::renderContent($view, 'equipment', self::inset($infoBox, $p), $info, + infoModel: $state->menuInfoText, rangeBounds: new CanvasRectangle($infoBox->x + $p, + $infoBox->y + $infoBox->height - $p, $infoBox->width - 2 * $p, $p)); + if ($hintHeight > 0) { + $view->hints('equipment-hints', $hints, new CanvasRectangle($infoBox->x + $p, + $infoBox->y + $infoHeight - $p - $hintHeight, $infoBox->width - 2 * $p, $hintHeight)); + } + return $view->finish(); + } + + public static function status(Character $character, MenuPresentationCatalog $theme, float $time = 0): PresentationCanvas + { + $view = new MenuCanvas($theme, time: $time); + $m = $theme->metrics; + $p = $m->panelPadding; + $identityWidth = max(180, $m->portraitSize); + $nameHeight = count(MenuCanvas::wrap($character->name, (int)floor($identityWidth / $m->cellWidth))) * $m->cellHeight; + $roleHeight = count(MenuCanvas::wrap($character->role->name, (int)floor(360 / $m->cellWidth))) * $m->cellHeight; + $profileHeight = max(180, 2 * $p + $roleHeight + 4 * ($m->cellHeight + 1) + $m->sectionGap, + 2 * $p + $nameHeight + $m->sectionGap + $m->portraitSize); + $profile = self::box(0, 0, self::WIDTH, $profileHeight); + $view->frame('status-profile', $profile); + $nameHeight = $view->prose('status-name', $character->name, + new CanvasRectangle($profile->x + $p, $profile->y + $p, $identityWidth, $profileHeight - 2 * $p - $m->portraitSize - $m->sectionGap)); + $view->portrait($character->actorId, new CanvasRectangle($profile->x + $p, $profile->y + $p + $nameHeight + $m->sectionGap, + $m->portraitSize, $m->portraitSize)); + $resourceX = $profile->x + $p + $identityWidth + $m->sectionGap; + $view->prose('status-role', $character->role->name, new CanvasRectangle($resourceX, $profile->y + $p, 360, $roleHeight)); + $stats = $character->effectiveStats; + $resources = [new MenuRow('level', 'Lv', [new MenuRowValue((string)$character->level)])]; + foreach (['Hp', 'Mp', 'Ap'] as $resource) { + $resources[] = new MenuRow(strtolower($resource), strtoupper($resource), + [new MenuRowValue($stats->{'current' . $resource} . ' / ' . $stats->{'total' . $resource})]); + } + $resourceBox = new CanvasRectangle($resourceX, $profile->y + $p + $roleHeight + $m->sectionGap, 360, 4 * ($m->cellHeight + 1)); + $view->rows('status-resources', $resources, new MenuRowLayout($resourceBox, self::numericColumns($resources), + $m->cellHeight + 1, $m->cellWidth, $m->cellHeight, true)); + $exp = [new MenuRow('current', 'Current EXP', [new MenuRowValue(number_format($character->currentExp))]), + new MenuRow('next', 'To Next Level', [new MenuRowValue(number_format($character->nextLevelExp))])]; + $expX = $resourceX + 360 + $m->sectionGap; + $view->rows('status-exp', $exp, self::layout($theme, new CanvasRectangle($expX, $resourceBox->y, + $profile->x + $profile->width - $p - $expX, $profileHeight - $p - ($resourceBox->y - $profile->y)), self::numericColumns($exp))); + $hints = self::characterHints(); + $hintHeight = MenuActionHints::height($hints, $theme, self::WIDTH - 2 * $p); + $infoHeight = $hintHeight > 0 ? max(80, 2 * $p + $hintHeight) : 0; + $bodyHeight = self::HEIGHT - $profileHeight - $infoHeight; + $statBox = self::box(0, $profileHeight, self::PROFILE_WIDTH, $bodyHeight); + $slotBox = self::box(self::PROFILE_WIDTH, $profileHeight, self::WIDTH - self::PROFILE_WIDTH, $bodyHeight); + $view->frame('status-stats', $statBox); + $view->frame('status-equipment', $slotBox); + $rows = CharacterMenuRows::stats($character, $theme); + $view->rows('status-stats', $rows, self::layout($theme, self::inset($statBox, $p), self::numericColumns($rows))); + $slots = CharacterMenuRows::slots($character); + $view->rows('status-slots', $slots, self::slotLayout($theme, self::inset($slotBox, $p), $slots)); + if ($hintHeight > 0) { + $infoBox = self::box(0, self::HEIGHT - $infoHeight, self::WIDTH, $infoHeight); + $view->frame('status-info', $infoBox, 'quiet'); + $view->hints('status-hints', $hints, self::inset($infoBox, $p)); + } + return $view->finish(); + } + + /** @return list */ + private static function characterHints(): array + { + // GameSceneState retains these cycling aliases even without semantic bindings. + return [ActionHints::resolve('character_next', 'Next', KeyCode::TAB), + ActionHints::resolve('character_previous', 'Prev', KeyCode::SHIFT_TAB), ActionHints::resolve('back', 'Back')]; + } + + private static function box(float $x, float $y, float $width, float $height): CanvasRectangle + { + $host = MenuLayout::getBounds(); + return new CanvasRectangle($host->x + $x, $host->y + $y, $width, $height); + } + + private static function inset(CanvasRectangle $box, int $padding): CanvasRectangle + { + return new CanvasRectangle($box->x + $padding, $box->y + $padding, $box->width - 2 * $padding, $box->height - 2 * $padding); + } + + private static function layout(MenuPresentationCatalog $theme, CanvasRectangle $box, array $columns = []): MenuRowLayout + { + $m = $theme->metrics; + return new MenuRowLayout($box, $columns, $m->rowHeight, $m->cellWidth, $m->cellHeight, true); + } + + /** @param list $rows */ + private static function slotLayout(MenuPresentationCatalog $theme, CanvasRectangle $box, array $rows): MenuRowLayout + { + $m = $theme->metrics; + $row = $theme->rows->metrics; + $label = max(1, ...array_map(fn(MenuRow $r) => mb_strlen($r->label), $rows)); + $icon = $theme->icons === null ? 0 : (int)ceil($row->iconWidth / $m->cellWidth) + $row->gapCells; + $cells = (int)floor(($box->width - 2 * $row->padding) / $m->cellWidth) - $label - $icon - $row->gapCells; + return self::layout($theme, $box, [new MenuRowColumn(max(1, $cells), HorizontalAlignment::LEFT)]); + } + + /** @param list $rows + * @return list + */ + private static function numericColumns(array $rows): array + { + $sizes = []; + foreach ($rows as $row) { + foreach ($row->values as $index => $value) { $sizes[$index] = max($sizes[$index] ?? 1, mb_strlen($value->text)); } + } + return array_map(fn(int $cells) => new MenuRowColumn($cells), $sizes); + } +} diff --git a/src/UI/Presentation/CharacterMenuRows.php b/src/UI/Presentation/CharacterMenuRows.php new file mode 100644 index 00000000..1aa83ca6 --- /dev/null +++ b/src/UI/Presentation/CharacterMenuRows.php @@ -0,0 +1,65 @@ + 'Attack', 'defence' => 'Defence', 'magicAttack' => 'M.Attack', + 'magicDefence' => 'M.Defence', 'evasion' => 'Evasion', 'speed' => 'Speed', 'grace' => 'Grace']; + + /** @return list */ + public static function stats(Character $character, MenuPresentationCatalog $theme, bool $comparison = false, ?Stats $preview = null): array + { + $rows = []; + if ($comparison) { + $rows[] = new MenuRow('heading', 'Stats', [new MenuRowValue('Current'), new MenuRowValue(''), + new MenuRowValue('Preview'), new MenuRowValue('')], MenuRowKind::HEADING); + } + $fields = $comparison ? ['totalHp' => 'HP', 'totalMp' => 'MP', ...self::STATS] : self::STATS; + $current = $character->effectiveStats; + foreach ($fields as $field => $label) { + $values = [new MenuRowValue(number_format($current->$field))]; + if ($comparison) { + $next = $preview === null ? $current->$field : $preview->$field; + $direction = $next <=> $current->$field; + $color = $theme->colors[match ($direction) { 1 => 'increase', -1 => 'decrease', default => 'text' }]; + array_push($values, MenuRowValue::getArrow(MenuDirection::RIGHT), new MenuRowValue(number_format($next), $color), + match ($direction) { + 1 => MenuRowValue::getArrow(MenuDirection::UP, $color), + -1 => MenuRowValue::getArrow(MenuDirection::DOWN, $color), + default => new MenuRowValue(''), + }); + } + $rows[] = new MenuRow($field, $label, $values); + } + return $rows; + } + + /** @return list */ + public static function slots(Character $character, int $activeIndex = -1, bool $focused = false): array + { + $rows = []; + foreach ($character->equipment as $index => $slot) { + $equipment = $slot->equipment; + $rows[] = new MenuRow('slot-' . $index, $slot->name, + [new MenuRowValue($equipment === null ? '' : $equipment->name)], icon: self::icon($equipment instanceof Equipment ? $equipment : null, $slot->semanticSlot), + selected: $index === $activeIndex, focused: $focused && $index === $activeIndex); + } + return $rows; + } + + public static function icon(?Equipment $equipment, ?EquipmentSlotType $slot = null): WeaponType|EquipmentSlotType|null + { + if ($equipment === null) { return $slot; } + return $equipment->equipmentType instanceof WeaponType ? $equipment->equipmentType : ($equipment->semanticSlot ?? $slot); + } +} diff --git a/src/UI/Presentation/ConfigMenuPresentation.php b/src/UI/Presentation/ConfigMenuPresentation.php new file mode 100644 index 00000000..597a6410 --- /dev/null +++ b/src/UI/Presentation/ConfigMenuPresentation.php @@ -0,0 +1,20 @@ +selection->getSettings(); + return SettingsMenuPresentation::compose(new SettingsMenuContent('Config', $settings, + array_map($menu->getChoiceIndex(...), $settings), $menu->selection->getActiveIndex(), + $menu->menuInfoText, $menu->getStatusMessage(), $menu->hasStatusError()), $theme, $time, $width, $height); + } +} diff --git a/src/UI/Presentation/ControlGlyphPresentation.php b/src/UI/Presentation/ControlGlyphPresentation.php new file mode 100644 index 00000000..480ba9e2 --- /dev/null +++ b/src/UI/Presentation/ControlGlyphPresentation.php @@ -0,0 +1,50 @@ +label ?? 'Unbound') + 2 + : (int)ceil($theme->rows->metrics->iconWidth / $theme->metrics->cellWidth); + } + + public static function compose(int $width, int $height, string $id, ?ControlHint $control, + MenuPresentationCatalog $theme, CanvasRectangle $bounds): PresentationCanvas + { + $m = $theme->metrics; + $cells = self::cells($control, $theme); + if ($cells * $m->cellWidth > $bounds->width || $m->cellHeight > $bounds->height) { + throw new RuntimeException('Control glyph exceeds its finite viewport.'); + } + $box = new CanvasRectangle($bounds->x + ($bounds->width - $cells * $m->cellWidth) / 2, + $bounds->y + ($bounds->height - $m->cellHeight) / 2, $cells * $m->cellWidth, $m->cellHeight); + $asset = self::asset($control, $theme); + if ($asset !== null) { + return new PresentationCanvas($width, $height, + MenuIconRegistry::containAsset($theme->assetRoot, $id, $asset, $box, 30, $bounds)); + } + return new PresentationCanvas($width, $height, textLayers: [new CanvasTextLayer($id, 30, $box->x, $box->y, + new RendererGridConfig($cells, 1, $m->cellWidth, $m->cellHeight), + [new PresentationTextRun(0, 0, ' ' . ($control?->label ?? 'Unbound') . ' ', + $theme->colors[$control === null ? 'disabled' : 'text'], $control === null ? null : $theme->colors['selected'])], $bounds)]); + } + + private static function asset(?ControlHint $control, MenuPresentationCatalog $theme): ?string + { + return $control === null ? null : ($theme->icons?->icons[$control->iconRole()] ?? null); + } +} diff --git a/src/UI/Presentation/ControlsMenuContent.php b/src/UI/Presentation/ControlsMenuContent.php new file mode 100644 index 00000000..91546d05 --- /dev/null +++ b/src/UI/Presentation/ControlsMenuContent.php @@ -0,0 +1,14 @@ + $rows */ + public function __construct(public array $rows, public int $index, public bool $listening, public string $status) {} +} diff --git a/src/UI/Presentation/ControlsMenuPresentation.php b/src/UI/Presentation/ControlsMenuPresentation.php new file mode 100644 index 00000000..76322f64 --- /dev/null +++ b/src/UI/Presentation/ControlsMenuPresentation.php @@ -0,0 +1,80 @@ +metrics; + $p = $m->panelPadding; + $gap = $m->sectionGap; + $host = new CanvasRectangle(($width - min(1100, $width - 20)) / 2, ($height - min(560, $height - 20)) / 2, + min(1100, $width - 20), min(560, $height - 20)); + $innerWidth = $host->width - 2 * $p; + $cells = (int)floor($innerWidth / $m->cellWidth); + $active = $content->rows[$content->index] ?? null; + $lookup = $active === null ? 'No rebindable actions.' : 'Keyboard bindings: ' . $active['keys']; + $info = $lookup . ($content->status === '' ? '' : "\n" . $content->status); + $infoHeight = max(80, 2 * $p + count(MenuCanvas::wrap($info, $cells)) * $m->cellHeight); + $listHeight = $host->height - $infoHeight; + if ($listHeight < 2 * $p + 2 * $m->cellHeight + $gap + $m->rowHeight) { + throw new RuntimeException('Controls lookup leaves no complete row in its finite viewport; terminal presentation retained.'); + } + $view = new MenuCanvas($theme, $width, $height, $time); + $list = new CanvasRectangle($host->x, $host->y, $host->width, $listHeight); + $footer = new CanvasRectangle($host->x, $host->y + $listHeight, $host->width, $infoHeight); + foreach (['controls-list' => $list, 'controls-info' => $footer] as $id => $box) { + $view->frame($id, $box, $id === 'controls-info' ? 'quiet' : 'panel'); + } + $view->prose('controls-title', $content->listening ? 'Controls: Rebinding' : 'Controls', + new CanvasRectangle($host->x + $p, $host->y + $p, $innerWidth, $m->cellHeight), 'accent'); + $view->prose('controls-lookup', $info, + new CanvasRectangle($host->x + $p, $footer->y + $p, $innerWidth, $infoHeight - 2 * $p)); + $bounds = new CanvasRectangle($host->x + $p, $host->y + $p + $m->cellHeight + $gap, + $innerWidth, $listHeight - 2 * $p - $m->cellHeight - $gap); + if ($content->rows === []) { + $view->prose('controls-empty', 'No rebindable actions.', $bounds, 'disabled'); + return $view->finish(); + } + $glyphCells = max(array_map(fn($row) => ControlGlyphPresentation::cells($row['control'], $theme), $content->rows)); + $descriptionCells = max(1, (int)floor(($bounds->width - 2 * $theme->rows->metrics->padding) * 0.48 / $m->cellWidth)); + $columns = [new MenuRowColumn($glyphCells, HorizontalAlignment::CENTER), new MenuRowColumn($descriptionCells, HorizontalAlignment::LEFT)]; + $layout = new MenuRowLayout($bounds, $columns, $m->rowHeight, $m->cellWidth, $m->cellHeight, true); + $layout->assertFits($theme->rows->metrics); + $rows = $heights = []; + foreach ($content->rows as $index => $entry) { + $row = new MenuRow((string)$index, ucfirst(str_replace('_', ' ', $entry['action'])), + [new MenuRowValue(''), new MenuRowValue($entry['description'])], + selected: $index === $content->index, focused: $index === $content->index); + $rows[] = $row; + $heights[] = $layout->heightFor($row, $theme->rows->metrics, false); + } + [$first, $last] = $view->visibleRange('controls-actions', $heights, $bounds, $content->index); + $images = $text = []; + $y = $bounds->y; + for ($i = $first; $i <= $last; $i++) { + $rowBox = new CanvasRectangle($bounds->x, $y, $bounds->width, $heights[$i]); + $view->rows('controls-action', [$rows[$i]], new MenuRowLayout($rowBox, $columns, + $m->rowHeight, $m->cellWidth, $m->cellHeight, true)); + $glyphX = $bounds->x + $theme->rows->metrics->padding + + ($layout->textCells($theme->rows->metrics) - $glyphCells - $descriptionCells + - $theme->rows->metrics->gapCells) * $m->cellWidth; + $glyph = ControlGlyphPresentation::compose($width, $height, 'controls-glyph-' . $i, + $content->rows[$i]['control'], $theme, new CanvasRectangle($glyphX, $y, $glyphCells * $m->cellWidth, $heights[$i])); + array_push($images, ...$glyph->images); + array_push($text, ...$glyph->textLayers); + $y += $heights[$i]; + } + return MenuCanvas::overlay($view->finish(), new PresentationCanvas($width, $height, $images, textLayers: $text), $theme); + } +} diff --git a/src/UI/Presentation/CreditsContent.php b/src/UI/Presentation/CreditsContent.php new file mode 100644 index 00000000..95d4f327 --- /dev/null +++ b/src/UI/Presentation/CreditsContent.php @@ -0,0 +1,54 @@ +}> */ + public array $sections; + + public function __construct(array $sections) + { + $copy = []; + foreach ($sections as $section) { + if (!is_array($section)) { continue; } + $lines = array_values(array_filter((array)($section['lines'] ?? []), 'is_string')); + if ($lines === []) { continue; } + $copy[] = ['title' => is_string($section['title'] ?? null) ? $section['title'] : '', 'lines' => $lines]; + } + $this->sections = $copy; + } + + /** @return list */ + public function getLines(int $columns): array + { + $lines = []; + foreach ($this->sections as $section) { + if ($lines !== []) { $lines[] = ['text' => '', 'color' => 'text']; } + if ($section['title'] !== '') { + foreach (TerminalText::wrapToWidth($section['title'], max(1, $columns)) as $line) { + $lines[] = ['text' => $line, 'color' => 'accent']; + } + } + foreach ($section['lines'] as $text) { + foreach (TerminalText::wrapToWidth($text, max(1, $columns)) as $line) { + $lines[] = ['text' => $line, 'color' => 'text']; + } + } + } + return $lines; + } + + /** @return non-empty-list */ + public function getPages(int $columns, int $rows): array + { + $lines = array_column($this->getLines($columns), 'text'); + return array_map(static fn(array $page): string => implode("\n", $page), + array_chunk($lines, max(1, $rows))) ?: ['']; + } +} diff --git a/src/UI/Presentation/CreditsMenuPresentation.php b/src/UI/Presentation/CreditsMenuPresentation.php new file mode 100644 index 00000000..4b0cf2f1 --- /dev/null +++ b/src/UI/Presentation/CreditsMenuPresentation.php @@ -0,0 +1,75 @@ +metrics; + $padding = max($m->panelPadding, $m->sectionGap); + return new CanvasRectangle($bounds->x + $padding, + $bounds->y + $padding + $m->cellHeight + $m->sectionGap, + $bounds->width - 2 * $padding, + $bounds->height - 2 * $padding - $m->cellHeight - $m->rowHeight - 2 * $m->sectionGap); + } + + public static function createPlayback(CreditsContent $content, MenuPresentationCatalog $theme): CreditsPlayback + { + $viewport = self::getViewport($theme); + $m = $theme->metrics; + $lines = $content->getLines((int)floor($viewport->width / $m->cellWidth)); + return new CreditsPlayback($viewport->height + count($lines) * $m->cellHeight, $m->cellHeight * 0.9); + } + + public static function compose(CreditsContent $content, CreditsPlayback $playback, MenuPresentationCatalog $theme): PresentationCanvas + { + $view = new MenuCanvas($theme); + $m = $theme->metrics; + $bounds = MenuLayout::getBounds(); + $viewport = self::getViewport($theme); + $view->frame('credits-frame', $bounds); + $view->prose('credits-heading', get_message('title.credits', 'Credits'), + new CanvasRectangle($viewport->x, $bounds->y + max($m->panelPadding, $m->sectionGap), $viewport->width, $m->cellHeight), + 'accent', HorizontalAlignment::CENTER); + $label = get_message('menu.back', 'Back'); + $buttonWidth = MenuLayout::getButtonWidth($theme, $label, $viewport->width); + $view->rows('credits-back', [new MenuRow('back', $label, kind: MenuRowKind::BUTTON, + selected: true, focused: true, showCursor: false)], + new MenuRowLayout(new CanvasRectangle($bounds->x + ($bounds->width - $buttonWidth) / 2, + $viewport->y + $viewport->height + $m->sectionGap, $buttonWidth, $m->rowHeight), + rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight)); + $base = $view->finish(); + $columns = (int)floor($viewport->width / $m->cellWidth); + $lines = $content->getLines($columns); + $origin = $viewport->y + $viewport->height - $playback->offset; + $first = max(0, (int)floor(($viewport->y - $origin) / $m->cellHeight)); + $end = min(count($lines), (int)ceil(($viewport->y + $viewport->height - $origin) / $m->cellHeight)); + if ($end <= $first) { return $base; } + $runs = []; + for ($index = $first; $index < $end; $index++) { + $line = $lines[$index]; + if ($line['text'] === '') { continue; } + $runs[] = new PresentationTextRun($index - $first, intdiv($columns - mb_strlen($line['text']), 2), + $line['text'], $theme->colors[$line['color']]); + } + $text = new CanvasTextLayer('credits-roll', 30, + $viewport->x + ($viewport->width - $columns * $m->cellWidth) / 2, $origin + $first * $m->cellHeight, + new RendererGridConfig($columns, $end - $first, $m->cellWidth, $m->cellHeight), $runs, $viewport); + return new PresentationCanvas($base->width, $base->height, $base->images, $base->indicators, [...$base->textLayers, $text]); + } +} diff --git a/src/UI/Presentation/CreditsPlayback.php b/src/UI/Presentation/CreditsPlayback.php new file mode 100644 index 00000000..afb172f0 --- /dev/null +++ b/src/UI/Presentation/CreditsPlayback.php @@ -0,0 +1,35 @@ + $this->offset >= $this->distance; } + + public function __construct(public readonly float $distance, private readonly float $pixelsPerSecond) + { + if (!is_finite($distance) || $distance <= 0 || !is_finite($pixelsPerSecond) || $pixelsPerSecond <= 0) { + throw new InvalidArgumentException('Credits require a finite positive distance and speed.'); + } + } + + public function advance(float $now, bool $paused = false): void + { + if (!is_finite($now) || $now < 0 || ($this->lastTime !== null && $now < $this->lastTime)) { + throw new InvalidArgumentException('Credits time must be finite, nonnegative and monotonic.'); + } + $delta = $this->lastTime === null || $paused || $this->paused ? 0 : min(0.1, $now - $this->lastTime); + $this->lastTime = $now; + $this->paused = $paused; + $this->offset = min($this->distance, $this->offset + $delta * $this->pixelsPerSecond); + } +} diff --git a/src/UI/Presentation/ItemMenuPresentation.php b/src/UI/Presentation/ItemMenuPresentation.php new file mode 100644 index 00000000..78b79467 --- /dev/null +++ b/src/UI/Presentation/ItemMenuPresentation.php @@ -0,0 +1,111 @@ +itemMenu === null || $state->selectionPanel === null) { return null; } + $view = new MenuCanvas($theme, time: $time); + $m = $theme->metrics; + $p = $m->panelPadding; + $mode = $state->mode; + $info = $state->infoPanel?->text ?? ''; + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('back', 'Back')]; + $hintHeight = MenuActionHints::height($hints, $theme, 1100 - 2 * $p); + $infoHeight = MenuInfoPanel::getHeight($theme, $hints); + $bodyBottom = 700 - $infoHeight; + $commandHeight = max(60, $m->rowHeight + 2 * min($p, 10)); + $statusHeight = max(80, 2 * ($m->cellHeight + 2) + 2 * $p); + if ($bodyBottom - $commandHeight - $statusHeight < 160) { + throw new RuntimeException('Items description leaves insufficient space for inventory and targets.'); + } + $view->frame('items-commands', self::box(0, 0, 1100, $commandHeight), 'quiet'); + $commands = $state->itemMenu->getItems()->toArray(); + $width = (1100 - 2 * $p - (count($commands) - 1) * $m->sectionGap) / max(1, count($commands)); + foreach ($commands as $index => $command) { + $selected = $state->itemMenu->activeIndex === $index; + $view->rows('items-commands', [new MenuRow('command-' . $index, $command->getLabel(), kind: MenuRowKind::BUTTON, + selected: $selected, focused: $selected && $mode instanceof SelectIemMenuCommandMode, + disabled: $command->isDisabled())], self::layout($theme, + self::box($p + $index * ($width + $m->sectionGap), ($commandHeight - $m->rowHeight) / 2, $width, $m->rowHeight))); + } + + $inventoryBox = self::box(0, $commandHeight, 700, $bodyBottom - $commandHeight); + $view->frame('items-inventory', $inventoryBox); + $rows = []; + $itemFocus = $mode instanceof UseItemMode || $mode instanceof DiscardItemMode || $mode instanceof ViewKeyItemsMode; + foreach ($state->selectionPanel->items as $index => $item) { + $selected = $state->selectionPanel->activeIndex === $index; + $rows[] = new MenuRow('item-' . $index, $item->name, [new MenuRowValue((string)$item->quantity)], + selected: $selected, focused: $selected && $itemFocus); + } + $quantityCells = max([2, ...array_map(fn(MenuRow $row) => mb_strlen($row->values[0]->text), $rows)]); + $view->rows('items-inventory', $rows, self::layout($theme, self::inset($inventoryBox, $p), + [new MenuRowColumn($quantityCells)]), $state->selectionPanel->activeIndex); + + $targetBox = self::box(700, $commandHeight, 400, $bodyBottom - $commandHeight - $statusHeight); + $view->frame('items-targets', $targetBox); + $rows = []; + foreach ($state->targetSelectionPanel?->targets ?? [] as $index => $target) { + $selected = $state->targetSelectionPanel->activeIndex === $index; + $rows[] = new MenuRow('target-' . $index, $target->name, selected: $selected, + focused: $selected && $mode instanceof SelectItemTargetMode); + } + $view->rows('items-targets', $rows, self::layout($theme, self::inset($targetBox, $p)), + $state->targetSelectionPanel?->activeIndex ?? -1); + + $statusBox = self::box(700, $bodyBottom - $statusHeight, 400, $statusHeight); + $view->frame('items-status', $statusBox, 'quiet'); + $target = $state->targetSelectionPanel?->activeCharacter; + if ($target !== null) { + $hp = $target->stats->currentHp . ' / ' . $target->stats->totalHp; + $mp = $target->stats->currentMp . ' / ' . $target->stats->totalMp; + $columns = [new MenuRowColumn(max(mb_strlen($hp), mb_strlen($mp)))]; + $view->rows('items-status', [new MenuRow('hp', sprintf('Lvl %02d HP', $target->level), [new MenuRowValue($hp)]), + new MenuRow('mp', 'MP', [new MenuRowValue($mp)])], new MenuRowLayout(self::inset($statusBox, $p), $columns, + $m->cellHeight + 2, $m->cellWidth, $m->cellHeight, true)); + } + + $infoBox = self::box(0, $bodyBottom, 1100, $infoHeight); + $view->frame('items-info', $infoBox, 'quiet'); + MenuInfoPanel::renderContent($view, 'items', self::inset($infoBox, $p), $info, + infoModel: $state->menuInfoText, rangeBounds: new CanvasRectangle($infoBox->x + $p, + $infoBox->y + $infoBox->height - $p, $infoBox->width - 2 * $p, $p)); + if ($hintHeight > 0) { + $view->hints('items-hints', $hints, self::box($p, 700 - $p - $hintHeight, 1100 - 2 * $p, $hintHeight)); + } + return $view->finish(); + } + + private static function box(float $x, float $y, float $w, float $h): CanvasRectangle + { + $host = MenuLayout::getBounds(); + return new CanvasRectangle($host->x + $x, $host->y + $y, $w, $h); + } + + private static function inset(CanvasRectangle $box, int $padding): CanvasRectangle + { + return new CanvasRectangle($box->x + $padding, $box->y + $padding, $box->width - 2 * $padding, $box->height - 2 * $padding); + } + + private static function layout(MenuPresentationCatalog $theme, CanvasRectangle $box, array $columns = []): MenuRowLayout + { + return new MenuRowLayout($box, $columns, $theme->metrics->rowHeight, $theme->metrics->cellWidth, $theme->metrics->cellHeight, true); + } +} diff --git a/src/UI/Presentation/JournalDocument.php b/src/UI/Presentation/JournalDocument.php new file mode 100644 index 00000000..61aeac13 --- /dev/null +++ b/src/UI/Presentation/JournalDocument.php @@ -0,0 +1,54 @@ + */ + private array $spans; + + /** @param list $sections */ + public function __construct(public array $sections) + { + $text = ''; + $spans = []; + foreach ($sections as $index => $section) { + if ($index > 0) { $text .= "\n"; } + $entries = [['text' => $section->title, 'icon' => $section->icon, 'color' => 'accent'], ...$section->entries]; + foreach ($entries as $entryIndex => $entry) { + $value = str_replace(["\r\n", "\r", "\t"], ["\n", "\n", ' '], TerminalText::stripAnsi($entry['text'])); + $start = strlen($text); + $text .= $value; + $spans[] = ['start' => $start, 'end' => strlen($text), 'section' => $index, + 'heading' => $entryIndex === 0, 'icon' => $entry['icon'] ?? null, 'color' => $entry['color'] ?? 'text']; + $text .= "\n"; + } + } + $this->text = rtrim($text, "\n"); + $this->spans = $spans; + } + + /** Metadata follows source offsets, never guessed by parsing translated display text. + * @return list + */ + public function lines(int $columns): array + { + $lines = []; + $cursor = 0; + foreach (TextViewport::wrap($this->text, $columns) as [$offset, $text]) { + while (isset($this->spans[$cursor]) && $offset > $this->spans[$cursor]['end']) { $cursor++; } + $span = $this->spans[$cursor] ?? null; + if ($span !== null && $offset < $span['start']) { $span = null; } + $lines[] = ['text' => $text, 'section' => $span['section'] ?? null, 'heading' => $span['heading'] ?? false, + 'icon' => $span !== null && $offset === $span['start'] ? $span['icon'] : null, 'color' => $span['color'] ?? 'text']; + } + return $lines; + } +} diff --git a/src/UI/Presentation/JournalMenuContent.php b/src/UI/Presentation/JournalMenuContent.php new file mode 100644 index 00000000..ab4f8f75 --- /dev/null +++ b/src/UI/Presentation/JournalMenuContent.php @@ -0,0 +1,28 @@ + $tabs + * @param list, icon?: string}> $rows + */ + public function __construct( + public string $title, + public array $tabs, + public int $tabIndex, + public string $summary, + public array $rows, + public int $index, + public string $emptyText, + public string $entryKey, + public string $detailText, + public string $previewText, + public bool $detailsOpen, + public bool $journal, + public ?JournalDocument $document = null, + ) {} +} diff --git a/src/UI/Presentation/JournalMenuPresentation.php b/src/UI/Presentation/JournalMenuPresentation.php new file mode 100644 index 00000000..4fdd2002 --- /dev/null +++ b/src/UI/Presentation/JournalMenuPresentation.php @@ -0,0 +1,136 @@ +journal && $content->document !== null) { return QuestJournalPresentation::pageSize($content, $theme, $width, $height); } + $bounds = self::geometry($content, $theme, $width, $height)['text']; + return [(int)floor($bounds->width / $theme->metrics->cellWidth), (int)floor($bounds->height / $theme->metrics->cellHeight)]; + } + + public static function compose(JournalMenuContent $content, MenuPresentationCatalog $theme, TextPage $page, + float $time = 0, int $width = 1350, int $height = 720): PresentationCanvas + { + if ($content->journal && $content->document !== null) { return QuestJournalPresentation::compose($content, $theme, $page, $time, $width, $height); } + $g = self::geometry($content, $theme, $width, $height); + $m = $theme->metrics; + $p = $m->panelPadding; + $view = new MenuCanvas($theme, $width, $height, $time); + foreach (['summary', 'list', 'info'] as $id) { + $view->frame('journal-' . $id, $g[$id], $id === 'info' ? 'quiet' : 'panel'); + } + $view->prose('journal-title', $content->title . ($content->summary !== '' ? "\n" . $content->summary : ''), $g['title'], 'accent'); + foreach ($content->tabs as $i => $label) { + $view->rows('journal-tab', [new MenuRow((string)$i, $label, kind: MenuRowKind::BUTTON, selected: $i === $content->tabIndex)], + new MenuRowLayout($g['tabs'][$i], rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true)); + } + if (!$content->journal || !$content->detailsOpen) { + $bounds = self::inset($g['list'], $p); + if ($content->rows === []) { $view->prose('journal-empty', $content->emptyText, $bounds, 'disabled'); } + else { + $cells = (int)floor(($bounds->width - 2 * $theme->rows->metrics->padding) / $m->cellWidth); + $valueCount = max(array_map(fn($row) => count($row['values']), $content->rows)); + $valueCells = max(4, (int)floor($cells * 0.50 / max(1, $valueCount)) - $theme->rows->metrics->gapCells); + $labelCells = $cells - $valueCount * ($valueCells + $theme->rows->metrics->gapCells); + $rows = []; + foreach ($content->rows as $i => $row) { + $rows[] = new MenuRow((string)$i, TextViewport::preview($row['label'], $labelCells), + array_map(fn($value) => new MenuRowValue(TextViewport::preview($value, $valueCells)), $row['values']), + selected: $i === $content->index, focused: $i === $content->index && !$content->detailsOpen); + } + $columns = []; + for ($i = 0; $i < $valueCount; $i++) { + $columns[] = new MenuRowColumn($valueCells, $content->journal && $i === 1 ? HorizontalAlignment::RIGHT : HorizontalAlignment::LEFT); + } + $view->rows('journal-entry', $rows, new MenuRowLayout($bounds, $columns, $m->rowHeight, $m->cellWidth, $m->cellHeight), $content->index); + } + } + $hasEntry = $content->rows !== []; + $metadata = !$hasEntry ? '' : ($content->detailsOpen ? 'Details - ' . $page->range() : 'View details'); + $view->prose('journal-reading-state', $metadata, $g['metadata'], $content->detailsOpen ? 'accent' : 'disabled'); + if ($hasEntry && (!$content->journal || $content->detailsOpen)) { + if ($page->columns !== (int)floor($g['text']->width / $m->cellWidth) + || $page->rows !== (int)floor($g['text']->height / $m->cellHeight)) { + throw new RuntimeException('Journal text page must match the measured display viewport.'); + } + $view->prose('journal-text', implode("\n", $page->lines), $g['text']); + } elseif ($hasEntry) { + $view->prose('journal-preview', TextViewport::preview($content->previewText, (int)floor($g['preview']->width / $m->cellWidth)), $g['preview']); + } + if ($g['hints'] !== null) { $view->hints('journal-hints', self::hints($content), $g['hints']); } + return $view->finish(); + } + + private static function hints(JournalMenuContent $content): array + { + $hints = [ActionHints::resolve('cancel', $content->detailsOpen ? 'Back' : 'Cancel')]; + if (!$content->detailsOpen) { array_unshift($hints, ActionHints::resolve('confirm', 'View details')); } + if (!$content->journal || !$content->detailsOpen) { + $hints[] = ActionHints::resolve('character_next', 'Tab', KeyCode::TAB); + $hints[] = ActionHints::resolve('character_previous', 'Previous tab', KeyCode::SHIFT_TAB); + } + return $hints; + } + + private static function geometry(JournalMenuContent $content, MenuPresentationCatalog $theme, int $width, int $height): array + { + $m = $theme->metrics; + $p = $m->panelPadding; + $gap = $m->sectionGap; + $host = new CanvasRectangle(($width - min(1100, $width - 20)) / 2, ($height - min(700, $height - 20)) / 2, + min(1100, $width - 20), min(700, $height - 20)); + $inner = $host->width - 2 * $p; + $titleWidth = floor($inner * 0.22); + $tabWidth = ($inner - $titleWidth - count($content->tabs) * $gap) / count($content->tabs); + $titleText = $content->title . ($content->summary !== '' ? "\n" . $content->summary : ''); + $summaryHeight = max(80, 2 * $p + count(MenuCanvas::wrap($titleText, (int)floor($titleWidth / $m->cellWidth))) * $m->cellHeight); + $tabLayout = new MenuRowLayout(new CanvasRectangle(0, 0, $tabWidth, 1), rowHeight: $m->rowHeight, + cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true); + foreach ($content->tabs as $i => $label) { + $summaryHeight = max($summaryHeight, 2 * $p + $tabLayout->heightFor(new MenuRow((string)$i, $label, kind: MenuRowKind::BUTTON), $theme->rows->metrics, false)); + } + $hintHeight = MenuActionHints::height(self::hints($content), $theme, $inner); + $hintSpace = $hintHeight > 0 ? $hintHeight + $gap : 0; + $infoHeight = max($content->journal ? 80 : 160, 2 * $p + $m->cellHeight * ($content->journal ? 2 : 3) + $gap + $hintSpace); + $listHeight = $host->height - $summaryHeight - $infoHeight; + if ($listHeight < 2 * $p + $m->rowHeight + $m->cellHeight) { + throw new RuntimeException('Journal theme leaves no readable list and text viewport; terminal presentation retained.'); + } + $summary = new CanvasRectangle($host->x, $host->y, $host->width, $summaryHeight); + $list = new CanvasRectangle($host->x, $host->y + $summaryHeight, $host->width, $listHeight); + $info = new CanvasRectangle($host->x, $list->y + $listHeight, $host->width, $infoHeight); + $x = $host->x + $p; + $metadata = new CanvasRectangle($x, $info->y + $p, $inner, $m->cellHeight); + $text = $content->journal ? self::inset($list, $p) + : new CanvasRectangle($x, $metadata->y + $m->cellHeight + $gap, $inner, $infoHeight - 2 * $p - $m->cellHeight - $gap - $hintSpace); + $tabs = []; + foreach ($content->tabs as $i => $_) { + $tabs[] = new CanvasRectangle($x + $titleWidth + $gap + $i * ($tabWidth + $gap), $summary->y + $p, $tabWidth, $summaryHeight - 2 * $p); + } + return ['summary' => $summary, 'list' => $list, 'info' => $info, 'text' => $text, 'metadata' => $metadata, 'tabs' => $tabs, + 'title' => new CanvasRectangle($x, $summary->y + $p, $titleWidth, $summaryHeight - 2 * $p), + 'preview' => new CanvasRectangle($x, $metadata->y + $m->cellHeight, $inner, $m->cellHeight), + 'hints' => $hintHeight > 0 ? new CanvasRectangle($x, $info->y + $infoHeight - $p - $hintHeight, $inner, $hintHeight) : null]; + } + + private static function inset(CanvasRectangle $bounds, int $padding): CanvasRectangle + { + return new CanvasRectangle($bounds->x + $padding, $bounds->y + $padding, $bounds->width - 2 * $padding, $bounds->height - 2 * $padding); + } +} diff --git a/src/UI/Presentation/JournalSection.php b/src/UI/Presentation/JournalSection.php new file mode 100644 index 00000000..fae69622 --- /dev/null +++ b/src/UI/Presentation/JournalSection.php @@ -0,0 +1,12 @@ + $entries */ + public function __construct(public string $title, public array $entries, public ?string $icon = null) {} +} diff --git a/src/UI/Presentation/MainMenuPresentation.php b/src/UI/Presentation/MainMenuPresentation.php new file mode 100644 index 00000000..1ca4122b --- /dev/null +++ b/src/UI/Presentation/MainMenuPresentation.php @@ -0,0 +1,200 @@ +getPresentationMode(); + $commandsFocused = $mode instanceof MainMenuCommandSelectionMode; + if ((!$commandsFocused && !$mode instanceof MainMenuCharacterSelectionMode && !$mode instanceof MainMenuPartyOrderMode) + || $state->mainMenu === null || $state->characterSelectionMenu === null) { return null; } + + $view = new MenuCanvas($theme, time: $time); + $m = $theme->metrics; + $p = $m->panelPadding; + $verticalPadding = (int)min($p, floor($m->sectionGap / 3)); + $info = implode("\n", $state->infoPanel?->getContent() ?? []); + $infoHeight = max(60, 2 * $verticalPadding + $m->cellHeight + self::textHeight($info, self::WIDTH - 2 * $p, $theme)); + self::summary($view, 'main-info', $state->infoPanel?->getTitle() ?? '', $info, + self::box(0, 0, self::WIDTH, $infoHeight), $verticalPadding); + + $summaries = $state->getPresentationSummaries(); + $summaryHeights = []; + foreach ($summaries as $id => $summary) { + $summaryHeights[$id] = max($id === 'location' ? 80 : 60, 2 * $verticalPadding + + self::textHeight($summary['title'], self::COMMAND_WIDTH - 2 * $p, $theme) + + self::textHeight(implode("\n", $summary['lines']), self::COMMAND_WIDTH - 2 * $p, $theme)); + } + $selection = $state->characterSelectionMenu; + $help = $selection->getHelpText(); + if ($help === CharacterSelectionMenu::DEFAULT_HELP_TEXT) { + $help = $commandsFocused ? 'Select a command.' : 'Select a character.'; + } + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')]; + $helpHeight = self::textHeight($help, self::PARTY_WIDTH - 2 * $p, $theme); + $hintHeight = MenuActionHints::height($hints, $theme, self::PARTY_WIDTH - 2 * $p); + // Location and help occupy one shared bottom row, regardless of hint visibility. + $footerHeight = max($summaryHeights['location'] ?? 80, + 2 * $verticalPadding + $helpHeight + ($hintHeight > 0 ? $m->sectionGap + $hintHeight : 0)); + if (isset($summaryHeights['location'])) { $summaryHeights['location'] = $footerHeight; } + + $commandsHeight = self::HEIGHT - $infoHeight - array_sum($summaryHeights); + $commandBox = self::box(0, $infoHeight, self::COMMAND_WIDTH, $commandsHeight); + $view->frame('main-commands', $commandBox); + $rows = []; + foreach ($state->mainMenu->getItems() as $index => $command) { + $selected = $index === $state->mainMenu->activeIndex; + $rows[] = new MenuRow((string)$index, $command->getLabel(), kind: MenuRowKind::COMMAND, + selected: $selected, focused: $commandsFocused && $selected, disabled: $command->isDisabled()); + } + $compactHeight = (int)ceil(max($m->cellHeight + $theme->rows->metrics->separatorWidth, + (int)ceil(min($m->rowHeight, $m->cellHeight + $m->sectionGap / 3)), $theme->rows->metrics->cursorHeight)); + $view->rows('main-command', $rows, new MenuRowLayout(self::inset($commandBox, $p, $p), + rowHeight: $compactHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true), $state->mainMenu->activeIndex); + $y = $infoHeight + $commandsHeight; + foreach ($summaries as $id => $summary) { + self::summary($view, 'main-' . $id, $summary['title'], implode("\n", $summary['lines']), + self::box(0, $y, self::COMMAND_WIDTH, $summaryHeights[$id]), $verticalPadding, HorizontalAlignment::RIGHT); + $y += $summaryHeights[$id]; + } + + $footer = self::box(self::COMMAND_WIDTH, self::HEIGHT - $footerHeight, self::PARTY_WIDTH, $footerHeight); + $view->frame('main-help', $footer, 'quiet'); + $view->prose('main-help-text', $help, new CanvasRectangle($footer->x + $p, + $footer->y + ($hintHeight > 0 ? $verticalPadding : ($footerHeight - $helpHeight) / 2), + $footer->width - 2 * $p, $helpHeight)); + if ($hintHeight > 0) { + $view->hints('main-hints', $hints, new CanvasRectangle($footer->x + $p, + $footer->y + $footer->height - $verticalPadding - $hintHeight, $footer->width - 2 * $p, $hintHeight)); + } + + $members = $state->party->members->toArray(); + $cardInsets = ($theme->frames['panel'] ?? null)?->borderInsets($theme->assetRoot, + new CanvasRectangle(0, 0, self::PARTY_WIDTH, 140)); + $cardPadding = $cardInsets === null ? $verticalPadding : (int)ceil(max($verticalPadding, $cardInsets[1], $cardInsets[3])); + $heights = []; + for ($index = 0; $index < max(4, count($members)); $index++) { + $heights[] = self::recordHeight($members[$index] ?? null, $theme, $cardPadding); + } + $partyBox = self::box(self::COMMAND_WIDTH, $infoHeight, self::PARTY_WIDTH, $footer->y - $infoHeight); + [$first, $last] = $view->visibleRange('main-party', $heights, $partyBox, $commandsFocused ? 0 : $selection->getActivePanelIndex()); + $y = $partyBox->y; + for ($index = $first; $index <= $last; $index++) { + $box = new CanvasRectangle($partyBox->x, $y, $partyBox->width, $heights[$index]); + $id = 'main-party-' . $index; + $view->frame($id . '-frame', $box, borderLayer: 20); + $member = $members[$index] ?? null; + if ($member instanceof Character) { + self::character($view, $id, $member, $box, $cardPadding, + $index === $selection->getMarkedPanelIndex(), !$commandsFocused && $index === $selection->getActivePanelIndex()); + } + $y += $heights[$index]; + } + return $view->finish(); + } + + private static function character(MenuCanvas $view, string $id, Character $member, CanvasRectangle $box, + int $verticalPadding, bool $marked, bool $focused): void + { + $theme = $view->theme; + $m = $theme->metrics; + $portraitSize = $m->portraitSize + 2 * $m->sectionGap; + $view->portrait($member->actorId, new CanvasRectangle($box->x + $m->panelPadding, + $box->y + ($box->height - $portraitSize) / 2, $portraitSize, $portraitSize), $id . '-portrait'); + $identity = new MenuRow('identity', $member->name, selected: $marked || $focused, focused: $focused, showCursor: false); + $identityBox = self::identityBox($box, $theme, $verticalPadding); + $layout = self::recordLayout($theme, $identityBox); + $nameHeight = $layout->heightFor($identity, $theme->rows->metrics, false); + $insets = ($theme->frames['panel'] ?? null)?->borderInsets($theme->assetRoot, $box); + $treatment = $insets === null ? self::inset($box, $m->sectionGap, $verticalPadding) + : new CanvasRectangle($box->x + $insets[0], $box->y + $insets[1], + $box->width - $insets[0] - $insets[2], $box->height - $insets[1] - $insets[3]); + $view->record($id, $identity, $layout, $treatment); + $roleBox = new CanvasRectangle($identityBox->x + $theme->rows->metrics->padding, $identityBox->y + $nameHeight, + $identityBox->width - 2 * $theme->rows->metrics->padding, $box->height - $nameHeight - 2 * $verticalPadding); + $roleHeight = $view->prose($id . '-role', 'Role: ' . $member->role->name, $roleBox); + $stats = $member->effectiveStats; + $rows = [new MenuRow('level', 'Lv', [new MenuRowValue((string)$member->level)]), + new MenuRow('hp', 'HP', [new MenuRowValue($stats->currentHp . ' / ' . $stats->totalHp)]), + new MenuRow('mp', 'MP', [new MenuRowValue($stats->currentMp . ' / ' . $stats->totalMp)])]; + $valueWidth = max(array_map(fn(MenuRow $row) => mb_strlen($row->values[0]->text, 'UTF-8'), $rows)); + $resourceY = $roleBox->y + $roleHeight; + $view->rows($id . '-resources', $rows, self::recordLayout($theme, + new CanvasRectangle($identityBox->x, $resourceY, min(410, $identityBox->width), $box->y + $box->height - $verticalPadding - $resourceY), + [new MenuRowColumn($valueWidth)])); + } + + private static function recordHeight(?Character $member, MenuPresentationCatalog $theme, int $verticalPadding): int + { + if ($member === null) { return 140; } + $m = $theme->metrics; + $box = self::identityBox(new CanvasRectangle(0, 0, self::PARTY_WIDTH, self::HEIGHT), $theme, $verticalPadding); + $layout = self::recordLayout($theme, $box); + $nameHeight = $layout->heightFor(new MenuRow('identity', $member->name), $theme->rows->metrics, false); + $roleHeight = self::textHeight('Role: ' . $member->role->name, $box->width - 2 * $theme->rows->metrics->padding, $theme); + return max(140, $m->portraitSize + 2 * $m->sectionGap, + 2 * $verticalPadding + $nameHeight + $roleHeight + 3 * $layout->rowHeight); + } + + private static function identityBox(CanvasRectangle $box, MenuPresentationCatalog $theme, int $verticalPadding): CanvasRectangle + { + $m = $theme->metrics; + $offset = $m->panelPadding + $m->portraitSize + 3 * $m->sectionGap; + return new CanvasRectangle($box->x + $offset, $box->y + $verticalPadding, + $box->width - $offset - $m->panelPadding, $box->height - 2 * $verticalPadding); + } + + private static function recordLayout(MenuPresentationCatalog $theme, CanvasRectangle $box, array $columns = []): MenuRowLayout + { + $m = $theme->metrics; + return new MenuRowLayout($box, $columns, (int)ceil(max($m->cellHeight + $theme->rows->metrics->separatorWidth, + $theme->rows->metrics->cursorHeight)), $m->cellWidth, $m->cellHeight, true); + } + + private static function summary(MenuCanvas $view, string $id, string $title, string $text, CanvasRectangle $box, + int $verticalPadding, HorizontalAlignment $alignment = HorizontalAlignment::LEFT): void + { + $view->frame($id, $box, 'quiet'); + $body = self::inset($box, $view->theme->metrics->panelPadding, $verticalPadding); + $titleHeight = $view->prose($id . '-title', $title, $body, 'accent'); + $view->prose($id . '-value', $text, new CanvasRectangle($body->x, $body->y + $titleHeight, + $body->width, $body->height - $titleHeight), alignment: $alignment); + } + + private static function textHeight(string $text, float $width, MenuPresentationCatalog $theme): int + { + return count(MenuCanvas::wrap($text, (int)floor($width / $theme->metrics->cellWidth))) * $theme->metrics->cellHeight; + } + + private static function box(float $x, float $y, float $width, float $height): CanvasRectangle + { + return new CanvasRectangle(self::LEFT + $x, $y, $width, $height); + } + + private static function inset(CanvasRectangle $box, int $x, int $y): CanvasRectangle + { + return new CanvasRectangle($box->x + $x, $box->y + $y, $box->width - 2 * $x, $box->height - 2 * $y); + } +} diff --git a/src/UI/Presentation/MenuActionHints.php b/src/UI/Presentation/MenuActionHints.php new file mode 100644 index 00000000..c9b1aaff --- /dev/null +++ b/src/UI/Presentation/MenuActionHints.php @@ -0,0 +1,93 @@ + $hints */ + public static function height(array $hints, MenuPresentationCatalog $theme, float $width): int + { + if (!$theme->showInputHints) { return 0; } + return self::layout($hints, $theme, $width)['rows'] * $theme->metrics->cellHeight; + } + + /** @param list $hints */ + public static function compose(int $width, int $height, string $id, array $hints, + MenuPresentationCatalog $theme, CanvasRectangle $bounds): PresentationCanvas + { + if (!$theme->showInputHints) { return new PresentationCanvas($width, $height); } + $layout = self::layout($hints, $theme, $bounds->width); + $m = $theme->metrics; + if ($layout['rows'] * $m->cellHeight > $bounds->height) { + throw new RuntimeException('Action hints exceed their finite viewport; terminal presentation retained.'); + } + $images = []; + foreach ($layout['glyphs'] as $index => $glyph) { + $box = new CanvasRectangle($bounds->x + $glyph['column'] * $m->cellWidth, + $bounds->y + $glyph['row'] * $m->cellHeight, $glyph['cells'] * $m->cellWidth, $m->cellHeight); + array_push($images, ...MenuIconRegistry::containAsset($theme->assetRoot, $id . '-control-' . $index, + $glyph['asset'], $box, 30, $bounds)); + } + $text = $layout['rows'] === 0 ? [] : [new CanvasTextLayer($id, 30, $bounds->x, $bounds->y, + new RendererGridConfig($layout['cells'], $layout['rows'], $m->cellWidth, $m->cellHeight), $layout['runs'], $bounds)]; + return new PresentationCanvas($width, $height, $images, textLayers: $text); + } + + /** Measurement and painting use the same already-resolved snapshot, even when profiles change. + * @param list $hints + * @return array{cells:int, rows:int, runs:list, glyphs:list} + */ + private static function layout(array $hints, MenuPresentationCatalog $theme, float $width): array + { + if (!array_is_list($hints)) { throw new InvalidArgumentException('Action hints must be an ordered list.'); } + $cells = (int)floor($width / $theme->metrics->cellWidth); + if ($cells < 1) { throw new RuntimeException('Action hints have no available width.'); } + $row = $column = 0; + $runs = $glyphs = []; + $append = static function (string $text, bool $keycap = false) use (&$runs, &$row, &$column, $cells, $theme): void { + while ($text !== '') { + if ($column === $cells) { $column = 0; $row++; } + $part = mb_substr($text, 0, $cells - $column, 'UTF-8'); + $length = mb_strlen($part, 'UTF-8'); + $runs[] = new PresentationTextRun($row, $column, $part, $theme->colors[$keycap ? 'text' : 'disabled'], + $keycap ? $theme->colors['selected'] : null); + $column += $length; + $text = mb_substr($text, $length, null, 'UTF-8'); + } + }; + foreach ($hints as $hint) { + if (!$hint instanceof ActionHint) { throw new InvalidArgumentException('Action hints require typed descriptors.'); } + $control = $hint->control; + // Unknown/missing glyph roles use readable labels, not an unrelated semantic Unknown icon. + $asset = $control === null ? null : ($theme->icons?->icons[$control->iconRole()] ?? null); + $glyphCells = (int)ceil($theme->rows->metrics->iconWidth / $theme->metrics->cellWidth); + if ($glyphCells > $cells) { $asset = null; } + $label = $control === null ? 'Unbound' : ' ' . $control->label . ' '; + $controlCells = $asset === null ? mb_strlen($label, 'UTF-8') : $glyphCells; + $length = $controlCells + 2 + mb_strlen($hint->label, 'UTF-8'); + if ($column > 0) { + if ($column + 2 + $length > $cells) { $column = 0; $row++; } + else { $append(' '); } + } + if ($asset === null) { $append($label, $control !== null); } + else { + $glyphs[] = ['asset' => $asset, 'row' => $row, 'column' => $column, 'cells' => $glyphCells]; + $column += $glyphCells; + } + $append(': ' . $hint->label); + } + return ['cells' => $cells, 'rows' => $hints === [] ? 0 : $row + 1, 'runs' => $runs, 'glyphs' => $glyphs]; + } +} diff --git a/src/UI/Presentation/MenuCanvas.php b/src/UI/Presentation/MenuCanvas.php new file mode 100644 index 00000000..274d725d --- /dev/null +++ b/src/UI/Presentation/MenuCanvas.php @@ -0,0 +1,387 @@ +fill('menu-background', new CanvasRectangle(0, 0, $width, $height), 'background', 0); + } + + /** Frame artwork owns both the interior and silhouette; only unskinned panels use a rectangular fill. */ + public function frame(string $id, CanvasRectangle $bounds, string $role = 'panel', int $layer = 10, ?int $borderLayer = null): void + { + $art = $this->theme->frames[$role] ?? $this->theme->frames['panel'] ?? null; + if ($art !== null) { + array_push($this->images, ...$art->images($this->theme->assetRoot, $id, $bounds, $layer, $borderLayer)); + } + else { $this->fill($id, $bounds, 'panel', $layer); } + } + + /** Theme-owned flat surfaces also serve section headers and scroll indicators. */ + public function surface(string $id, CanvasRectangle $bounds, string $color, int $layer = 20): void + { + $this->fill($id, $bounds, $color, $layer); + } + + /** Decorative semantic roles are optional; a missing role is not an unknown item. */ + public function icon(string $id, string $role, CanvasRectangle $bounds): bool + { + $asset = $this->theme->icons?->icons[$role] ?? null; + if ($asset === null) { return false; } + array_push($this->images, ...MenuIconRegistry::containAsset($this->theme->assetRoot, $id, $asset, $bounds, 31, $bounds)); + return true; + } + + /** Already measured lines retain per-line color in one bounded text layer. + * @param list $lines + */ + public function textLines(string $id, array $lines, CanvasRectangle $bounds): void + { + if ($lines === []) { return; } + $m = $this->theme->metrics; + if (count($lines) * $m->cellHeight > $bounds->height) { throw new RuntimeException('Menu text lines exceed their measured viewport.'); } + $runs = []; + foreach ($lines as $row => $line) { + if ($line['text'] !== '') { $runs[] = new PresentationTextRun($row, 0, $line['text'], $this->theme->colors[$line['color']]); } + } + $this->text[] = new CanvasTextLayer($id, 30, $bounds->x, $bounds->y, + new RendererGridConfig((int)floor($bounds->width / $m->cellWidth), count($lines), $m->cellWidth, $m->cellHeight), $runs, $bounds); + } + + /** Compact reading metadata occupies frame padding, not the prose viewport. */ + public function renderBorderCaption(string $id, string $text, CanvasRectangle $bounds): void + { + $m = $this->theme->metrics; + $height = min($m->cellHeight, (int)floor($bounds->height)); + $width = max(1, (int)floor($m->cellWidth * $height / $m->cellHeight)); + $columns = mb_strlen($text); + if ($height < 1 || $columns < 1 || $columns * $width > $bounds->width) { + throw new RuntimeException('Menu border caption exceeds its finite viewport.'); + } + $this->text[] = new CanvasTextLayer($id, 30, $bounds->x + $bounds->width - $columns * $width, $bounds->y, + new RendererGridConfig($columns, 1, $width, $height), + [new PresentationTextRun(0, 0, $text, $this->theme->colors['disabled'])], $bounds); + } + + /** Reuse the same bounded batching when placing a local menu overlay above an existing canvas. */ + public static function overlay(PresentationCanvas $base, PresentationCanvas $overlay, MenuPresentationCatalog $theme): PresentationCanvas + { + $view = new self($theme, $base->width, $base->height); + $offset = 1 + max([0, ...array_column($base->images, 'layer'), ...array_column($base->textLayers, 'layer'), + ...array_column($base->indicators, 'layer'), ...array_column($base->composites, 'layer')]); + $view->images = $base->images; + foreach ($overlay->images as $image) { + $view->images[] = new CanvasImage($image->id, $image->asset, $image->destination, $offset + $image->layer, + $image->sourceRect, $image->opacity, $image->clipRect); + } + $view->text = $base->textLayers; + foreach ($overlay->textLayers as $text) { + // The local builder's whole-screen background is not part of an overlay. + if ($text->id === 'menu-background' || str_starts_with($text->id, 'menu-background-part-')) { continue; } + $view->text[] = new CanvasTextLayer($text->id, $offset + $text->layer, $text->x, $text->y, + $text->grid, $text->runs, $text->clipRect, $text->opacity, $text->glyphEffects); + } + $view->fills = []; + foreach ($view->text as $text) { + if ($text->opacity !== 1.0 || $text->glyphEffects !== null || count($text->runs) !== $text->grid->rows) { continue; } + $color = $text->runs[0]->background ?? null; + $role = array_search($color, $theme->colors, true); + if ($role === false) { continue; } + foreach ($text->runs as $row => $run) { + if ($run->row !== $row || $run->column !== 0 || $run->foreground !== null || $run->background !== $color + || $run->text !== str_repeat(' ', $text->grid->columns)) { continue 2; } + } + $clip = $text->clipRect ?? $text->bounds; + if ($clip->x < $text->bounds->x || $clip->y < $text->bounds->y + || $clip->x + $clip->width > $text->bounds->x + $text->bounds->width + || $clip->y + $clip->height > $text->bounds->y + $text->bounds->height) { continue; } + $view->fills[$text->id] = [$clip, $role, $text->layer]; + } + $view->compactFills(); + $view->compactOutlines(); + $view->compactRules(); + $view->text = MenuCanvasTextBatch::compact($view->text); + $result = $view->finish(); + $composites = [...$base->composites]; + foreach ($overlay->composites as $composite) { + $composites[] = new CanvasComposite($composite->id, $composite->width, $composite->height, + $composite->destination, $composite->operations, $offset + $composite->layer, $composite->opacity, $composite->clipRect); + } + CanvasImagePreflight::inspect($result->images, $theme->assetRoot, $composites); + return new PresentationCanvas($base->width, $base->height, $result->images, $base->indicators, $result->textLayers, $composites); + } + + public function portrait(string $actorId, CanvasRectangle $bounds, string $id = 'portrait'): void + { + $asset = $this->theme->portraits[$actorId] ?? null; + if ($asset === null) { return; } + if (isset($this->theme->frames['portrait'])) { + $this->frame($id . '-frame', $bounds, 'portrait', 21); + } + $inset = min($this->theme->metrics->sectionGap, $bounds->width / 4, $bounds->height / 4); + $box = new CanvasRectangle($bounds->x + $inset, $bounds->y + $inset, + $bounds->width - 2 * $inset, $bounds->height - 2 * $inset); + array_push($this->images, ...MenuIconRegistry::containAsset($this->theme->assetRoot, + $id, $asset, $box, 22, $bounds)); + } + + /** Prose and hints never become record rows or acquire separators. Returns consumed height. */ + public function prose(string $id, string $text, CanvasRectangle $bounds, string $color = 'text', + HorizontalAlignment $alignment = HorizontalAlignment::LEFT): int + { + if ($text === '') { return 0; } + $m = $this->theme->metrics; + $cells = (int)floor($bounds->width / $m->cellWidth); + $lines = self::wrap($text, $cells); + $height = count($lines) * $m->cellHeight; + if ($height > $bounds->height) { throw new RuntimeException("Menu prose {$id} exceeds its finite viewport; terminal presentation retained."); } + $runs = []; + foreach ($lines as $row => $line) { + $space = $cells - mb_strlen($line, 'UTF-8'); + $column = match ($alignment) { + HorizontalAlignment::LEFT => 0, HorizontalAlignment::CENTER => intdiv($space, 2), HorizontalAlignment::RIGHT => $space, + }; + if ($line !== '') { $runs[] = new PresentationTextRun($row, $column, $line, $this->theme->colors[$color]); } + } + $this->text[] = new CanvasTextLayer($id, 30, $bounds->x, $bounds->y, + new RendererGridConfig((int)floor($bounds->width / $m->cellWidth), count($lines), $m->cellWidth, $m->cellHeight), + $runs, $bounds); + return $height; + } + + /** @return list */ + public static function wrap(string $text, int $cells): array + { + if ($cells < 1) { throw new RuntimeException('Menu text has no available width.'); } + return MenuTextWrap::lines($text, $cells); + } + + /** A finite view follows the owner's existing index, without taking ownership of navigation. + * @param list $rows + */ + public function rows(string $id, array $rows, MenuRowLayout $layout, ?int $activeIndex = null): void + { + if ($rows === []) { return; } + $heights = array_map(fn(MenuRow $row) => $layout->heightFor($row, $this->theme->rows->metrics, + $this->theme->icons?->asset($row->icon) !== null), $rows); + [$first, $last] = $this->visibleRange($id, $heights, $layout->viewport, $activeIndex); + $canvas = MenuRowPainter::compose($this->width, $this->height, $id, + array_slice($rows, $first, $last - $first + 1), $layout, $this->theme->rows, $this->theme->icons, $this->time); + array_push($this->images, ...$canvas->images); + array_push($this->text, ...$canvas->textLayers); + } + + /** Shared whole-record viewport; the caller supplies the existing owner's index. + * @param non-empty-list $heights + * @return array{int, int} Inclusive first/last indexes. + */ + public function visibleRange(string $id, array $heights, CanvasRectangle $viewport, ?int $activeIndex, + ?CanvasRectangle $rangeBounds = null): array + { + $first = 0; + $last = count($heights) - 1; + if (array_sum($heights) > $viewport->height) { + if ($activeIndex === null) { throw new RuntimeException("Menu records {$id} exceed their finite viewport."); } + $activeIndex = max(0, min($last, $activeIndex)); + $available = $viewport->height - $this->theme->metrics->cellHeight; + $height = $heights[$activeIndex]; + if ($height > $available) { throw new RuntimeException("Menu record {$id} at {$activeIndex} exceeds its finite viewport."); } + $first = $last = $activeIndex; + while ($first > 0 && $height + $heights[$first - 1] <= $available) { $height += $heights[--$first]; } + while ($last < count($heights) - 1 && $height + $heights[$last + 1] <= $available) { $height += $heights[++$last]; } + $this->prose($id . '-range', sprintf('%d-%d / %d', $first + 1, $last + 1, count($heights)), + $rangeBounds ?? new CanvasRectangle($viewport->x, $viewport->y + $available, + $viewport->width, $this->theme->metrics->cellHeight), 'disabled', + $rangeBounds === null ? HorizontalAlignment::LEFT : HorizontalAlignment::RIGHT); + } + return [$first, $last]; + } + + /** A compound record shares row treatments while its identity keeps a separate cursor anchor. */ + public function record(string $id, MenuRow $identity, MenuRowLayout $layout, CanvasRectangle $bounds): void + { + $canvas = MenuRowPainter::compose($this->width, $this->height, $id, + [$identity], $layout, $this->theme->rows, $this->theme->icons, $this->time, layer: 15, recordBounds: $bounds, contentLayer: 30); + array_push($this->images, ...$canvas->images); + array_push($this->text, ...$canvas->textLayers); + } + + /** @param list<\Ichiloto\Engine\IO\ActionHint> $hints */ + public function hints(string $id, array $hints, CanvasRectangle $bounds): int + { + $canvas = MenuActionHints::compose($this->width, $this->height, $id, $hints, $this->theme, $bounds); + array_push($this->images, ...$canvas->images); + array_push($this->text, ...$canvas->textLayers); + return MenuActionHints::height($hints, $this->theme, $bounds->width); + } + + public function finish(): PresentationCanvas + { + $this->compactFills(); + $this->compactOutlines(); + $this->compactRules(); + $this->text = MenuCanvasTextBatch::compact($this->text); + if (count($this->text) > 64) { throw new RuntimeException('Menu composition exceeds the existing Canvas text-layer budget: ' . count($this->text)); } + CanvasImagePreflight::inspect($this->images, $this->theme->assetRoot); + return new PresentationCanvas($this->width, $this->height, $this->images, textLayers: $this->text); + } + + private function fill(string $id, CanvasRectangle $bounds, string $color, int $layer): void + { + $bounds->assertWithin($this->width, $this->height); + $columns = (int)ceil($bounds->width / 256); + $rows = (int)ceil($bounds->height / 256); + $cw = (int)ceil($bounds->width / $columns); + $ch = (int)ceil($bounds->height / $rows); + $xs = $this->fillAxis($bounds->width, $this->width, $columns, $cw); + $ys = $this->fillAxis($bounds->height, $this->height, $rows, $ch); + $x = min($bounds->x, $this->width - (count($xs) === 1 ? $columns * $cw : ceil($bounds->width))); + $y = min($bounds->y, $this->height - (count($ys) === 1 ? $rows * $ch : ceil($bounds->height))); + // Split fragments cannot be merge candidates: joining them would recreate the overflow. + if (count($xs) === 1 && count($ys) === 1) { $this->fills[$id] = [$bounds, $color, $layer]; } + foreach ($ys as $yi => [$dy, $nr, $ch]) { + foreach ($xs as $xi => [$dx, $nc, $cw]) { + $left = max($bounds->x, $x + $dx); + $top = max($bounds->y, $y + $dy); + $right = min($bounds->x + $bounds->width, $x + $dx + $nc * $cw); + $bottom = min($bounds->y + $bounds->height, $y + $dy + $nr * $ch); + if ($right <= $left || $bottom <= $top) { continue; } + $runs = []; + for ($row = 0; $row < $nr; $row++) { + $runs[] = new PresentationTextRun($row, 0, str_repeat(' ', $nc), background: $this->theme->colors[$color]); + } + $this->text[] = new CanvasTextLayer($id . ($xi + $yi === 0 ? '' : "-part-{$yi}-{$xi}"), $layer, + $x + $dx, $y + $dy, new RendererGridConfig($nc, $nr, $cw, $ch), $runs, + new CanvasRectangle($left, $top, $right - $left, $bottom - $top)); + } + } + } + + /** Preserve valid grids; only rounded overflow needs one exact remainder strip per axis. + * @return list Offset, cells, cell size. + */ + private function fillAxis(float $extent, int $limit, int $cells, int $size): array + { + if ($cells * $size <= $limit) { return [[0, $cells, $size]]; } + $main = ($cells - 1) * $size; + return [[0, $cells - 1, $size], [$main, 1, (int)ceil($extent) - $main]]; + } + + /** Join only identical opaque rectangular backings, never text, selection or artwork. + * This keeps dense default menus within the existing Canvas layer budget without a protocol change. + */ + private function compactFills(): void + { + do { + $merged = false; + if (count($this->text) <= 64) { return; } + foreach ($this->fills as $id => [$a, $color, $layer]) { + foreach ($this->fills as $other => [$b, $otherColor, $otherLayer]) { + if ($id === $other || $color !== $otherColor || $layer !== $otherLayer) { continue; } + $x = min($a->x, $b->x); + $y = min($a->y, $b->y); + $w = max($a->x + $a->width, $b->x + $b->width) - $x; + $h = max($a->y + $a->height, $b->y + $b->height) - $y; + $overlap = max(0, min($a->x + $a->width, $b->x + $b->width) - max($a->x, $b->x)) + * max(0, min($a->y + $a->height, $b->y + $b->height) - max($a->y, $b->y)); + if (abs($w * $h - ($a->width * $a->height + $b->width * $b->height - $overlap)) > 0.00001) { continue; } + unset($this->fills[$id], $this->fills[$other]); + $this->text = array_values(array_filter($this->text, fn(CanvasTextLayer $text) => $text->id !== $id && $text->id !== $other)); + $this->fill($id, new CanvasRectangle($x, $y, $w, $h), $color, $layer); + $merged = true; + break 2; + } + } + } while ($merged); + } + + /** Default focus edges are four disjoint rectangles, representable by one sparse pixel-fill grid. */ + private function compactOutlines(): void + { + foreach ($this->text as $index => $first) { + if (count($this->text) <= 64) { break; } + if (!str_ends_with($first->id, '-focus-0')) { continue; } + $id = substr($first->id, 0, -2); + $edges = array_filter($this->text, fn(CanvasTextLayer $text) => in_array($text->id, + [$id . '-0', $id . '-1', $id . '-2', $id . '-3'], true)); + if (count($edges) !== 4) { continue; } + $x = min(array_column($edges, 'x')); + $y = min(array_column($edges, 'y')); + $right = $bottom = 0; + foreach ($edges as $edge) { + if ($edge->layer !== $first->layer || $edge->opacity !== $first->opacity || $edge->glyphEffects !== null + || $edge->bounds != $edge->clipRect || count($edge->runs) !== 1 || $edge->runs[0]->foreground !== null + || $edge->runs[0]->background === null || $edge->runs[0]->row !== 0 || $edge->runs[0]->column !== 0 + || $edge->runs[0]->text !== str_repeat(' ', $edge->grid->columns)) { continue 2; } + foreach ([$edge->x - $x, $edge->y - $y, $edge->bounds->width, $edge->bounds->height] as $value) { + if ($value !== floor($value)) { continue 3; } + } + $right = max($right, $edge->x + $edge->bounds->width); + $bottom = max($bottom, $edge->y + $edge->bounds->height); + } + if ($right - $x > 512 || $bottom - $y > 256) { continue; } + $runs = []; + foreach ($edges as $edgeIndex => $edge) { + for ($row = 0; $row < $edge->bounds->height; $row++) { + $runs[] = new PresentationTextRun((int)($edge->y - $y) + $row, (int)($edge->x - $x), + str_repeat(' ', (int)$edge->bounds->width), background: $edge->runs[0]->background); + } + unset($this->text[$edgeIndex], $this->fills[$edge->id]); + } + $this->text[$index] = new CanvasTextLayer($id, $first->layer, $x, $y, + new RendererGridConfig((int)($right - $x), (int)($bottom - $y), 1, 1), $runs, + new CanvasRectangle($x, $y, $right - $x, $bottom - $y), $first->opacity); + } + $this->text = array_values($this->text); + } + + /** Batch compatible horizontal rules as sparse background runs, preserving every painted pixel. */ + private function compactRules(): void + { + foreach ($this->text as $index => $a) { + if (count($this->text) <= 64) { break; } + if (!isset($this->text[$index]) || !str_ends_with($a->id, '-separator') || $a->clipRect === null) { continue; } + foreach ($this->text as $other => $b) { + if ($other <= $index || !str_ends_with($b->id, '-separator') || $b->clipRect === null + || $a->x !== $b->x || $a->layer !== $b->layer || $a->opacity !== $b->opacity + || $a->grid->columns !== $b->grid->columns || $a->grid->cellWidth !== $b->grid->cellWidth + || $a->grid->cellHeight !== $b->grid->cellHeight || $a->y > $b->y + || $a->bounds != $a->clipRect || $b->bounds != $b->clipRect) { continue; } + $offset = ($b->y - $a->y) / $a->grid->cellHeight; + $rows = (int)$offset + $b->grid->rows; + if ($offset !== floor($offset) || $rows > 256) { continue; } + $runs = $a->runs; + foreach ($b->runs as $run) { + $runs[] = new PresentationTextRun($run->row + (int)$offset, $run->column, $run->text, $run->foreground, $run->background); + } + $a = new CanvasTextLayer($a->id, $a->layer, $a->x, $a->y, + new RendererGridConfig($a->grid->columns, $rows, $a->grid->cellWidth, $a->grid->cellHeight), $runs, + new CanvasRectangle($a->x, $a->y, $a->bounds->width, $rows * $a->grid->cellHeight), $a->opacity); + $this->text[$index] = $a; + unset($this->text[$other]); + } + } + $this->text = array_values($this->text); + } +} diff --git a/src/UI/Presentation/MenuCanvasState.php b/src/UI/Presentation/MenuCanvasState.php new file mode 100644 index 00000000..95408dcf --- /dev/null +++ b/src/UI/Presentation/MenuCanvasState.php @@ -0,0 +1,84 @@ + $this->infoText ??= new MenuInfoText(); + } + + protected function handleMenuInfoInput(string $description, ?string $status = null, int $columns = 1): bool + { + if (!Input::isButtonDown('info')) { return false; } + $info = $this->menuInfoText; + $info->getPage($description, $status, $info->lastPage?->columns ?? $columns); + $info->advance(); + return true; + } + + private function resetMenuPresentation(): void + { + $this->menuTheme = null; + $this->menuThemeLoaded = false; + $this->menuPresentationStart = hrtime(true) / 1e9; + $this->menuPresentationError = null; + $this->infoText?->reset(); + } + + public function getPresentationCanvas(): ?PresentationCanvas + { + $game = $this->getGameScene()->getGame(); + $runtime = $game->getRendererRuntime(); + if ($runtime === null) { return null; } + try { + if (!$this->menuThemeLoaded) { + $this->menuThemeLoaded = true; + $theme = MenuPresentationCatalog::load($runtime->getAssetRoot()); + if ($theme !== null) { + foreach (MenuPresentationCatalog::CAPABILITIES as $capability) { + if (!$runtime->supports($capability)) { throw new RuntimeException("Menu renderer lacks {$capability}."); } + } + } + $this->menuTheme = $theme; + } + if ($this->menuTheme === null) { return null; } + $time = max(0, hrtime(true) / 1e9 - $this->menuPresentationStart); + $canvas = $this->composeMenuCanvas($this->menuTheme, $time); + $modal = isset($game->modalManager) ? $game->modalManager->currentModal : null; + if ($canvas === null || $modal === null) { return $canvas; } + if (!$modal->isShowing()) { return $canvas; } + $snapshot = $modal instanceof ModalPresentationProviderInterface ? $modal->getModalPresentation() : null; + if ($snapshot === null) { + throw new RuntimeException('Active modal ' . $modal::class . ' has no supported menu canvas presentation.'); + } + return MenuModalPresentation::compose($canvas, $snapshot, $this->menuTheme, $time); + } catch (Throwable $error) { + if ($this->menuPresentationError !== $error->getMessage()) { + Debug::error('Menu presentation degraded to terminal: ' . $error->getMessage()); + $this->menuPresentationError = $error->getMessage(); + } + return null; + } + } + + abstract protected function composeMenuCanvas(MenuPresentationCatalog $theme, float $time): ?PresentationCanvas; +} diff --git a/src/UI/Presentation/MenuCanvasTextBatch.php b/src/UI/Presentation/MenuCanvasTextBatch.php new file mode 100644 index 00000000..07816203 --- /dev/null +++ b/src/UI/Presentation/MenuCanvasTextBatch.php @@ -0,0 +1,70 @@ + $layers @return list */ + public static function compact(array $layers): array + { + foreach ($layers as $index => $a) { + if (count($layers) <= 64) { break; } + if (!isset($layers[$index]) || !self::eligible($a)) { continue; } + foreach ($layers as $other => $b) { + if (count($layers) <= 64) { break 2; } + if ($other <= $index || !self::eligible($b) || $a->layer !== $b->layer || $a->opacity !== $b->opacity + || $a->grid->cellWidth !== $b->grid->cellWidth || $a->grid->cellHeight !== $b->grid->cellHeight) { continue; } + // Moving this run group must not change the order of any overlapping same-layer text. + foreach ($layers as $candidate => $text) { + if ($candidate !== $other && $text->layer === $b->layer && self::overlap($text->bounds, $b->bounds)) { continue 2; } + } + $x = min($a->x, $b->x); + $y = min($a->y, $b->y); + $cw = $a->grid->cellWidth; + $ch = $a->grid->cellHeight; + foreach ([($a->x - $x) / $cw, ($b->x - $x) / $cw, ($a->y - $y) / $ch, ($b->y - $y) / $ch] as $offset) { + if ($offset !== floor($offset)) { continue 2; } + } + $columns = (int)((max($a->bounds->x + $a->bounds->width, $b->bounds->x + $b->bounds->width) - $x) / $cw); + $rows = (int)((max($a->bounds->y + $a->bounds->height, $b->bounds->y + $b->bounds->height) - $y) / $ch); + if ($columns > 512 || $rows > 256) { continue; } + $runs = []; + foreach ([$a, $b] as $text) { + foreach ($text->runs as $run) { + $runs[] = new PresentationTextRun($run->row + (int)(($text->y - $y) / $ch), + $run->column + (int)(($text->x - $x) / $cw), $run->text, $run->foreground, $run->background); + } + } + $a = new CanvasTextLayer($a->id, $a->layer, $x, $y, new RendererGridConfig($columns, $rows, $cw, $ch), + $runs, new CanvasRectangle($x, $y, $columns * $cw, $rows * $ch), $a->opacity); + $layers[$index] = $a; + unset($layers[$other]); + } + } + return array_values($layers); + } + + private static function eligible(CanvasTextLayer $text): bool + { + if ($text->glyphEffects !== null || $text->runs === [] + || array_any($text->runs, fn($run) => $run->foreground === null || $run->background !== null)) { return false; } + $clip = $text->clipRect; + return $clip === null || ($clip->x <= $text->bounds->x && $clip->y <= $text->bounds->y + && $clip->x + $clip->width >= $text->bounds->x + $text->bounds->width + && $clip->y + $clip->height >= $text->bounds->y + $text->bounds->height); + } + + private static function overlap(CanvasRectangle $a, CanvasRectangle $b): bool + { + return $a->x < $b->x + $b->width && $a->x + $a->width > $b->x + && $a->y < $b->y + $b->height && $a->y + $a->height > $b->y; + } +} diff --git a/src/UI/Presentation/MenuControls.php b/src/UI/Presentation/MenuControls.php new file mode 100644 index 00000000..5c70dc83 --- /dev/null +++ b/src/UI/Presentation/MenuControls.php @@ -0,0 +1,131 @@ +getImages($this->theme->icons, $id, $box, 40, chevron: true); + $opacity = $enabled ? 1.0 : 0.35; + if ($images !== []) { + foreach ($images as $image) { + $this->images[] = new CanvasImage($image->id, $image->asset, $image->destination, $image->layer, + $image->sourceRect, $opacity, $image->clipRect); + } + return; + } + $glyph = $symbol->getGlyph(chevron: true); + $m = $this->theme->metrics; + $this->text[] = new CanvasTextLayer($id, 40, $box->x + ($box->width - $m->cellWidth) / 2, + $box->y + ($box->height - $m->cellHeight) / 2, new RendererGridConfig(1, 1, $m->cellWidth, $m->cellHeight), + [new PresentationTextRun(0, 0, $glyph, $this->theme->colors['accent'])], $box, $opacity); + } + + public function renderSurface(string $id, string $role, CanvasRectangle $box, PresentationColor $fallback, + bool $contain = false, int $layer = 40): void + { + $art = $this->theme->frames[$role] ?? null; + if ($art === null) { $this->fill($id, $box, $fallback, $layer); return; } + array_push($this->images, ...($contain && $art->left + $art->top + $art->right + $art->bottom === 0 + ? MenuIconRegistry::containAsset($this->theme->assetRoot, $id, $art->asset, $box, $layer, $box) + : $art->images($this->theme->assetRoot, $id, $box, $layer))); + } + + public function renderLevel(string $id, CanvasRectangle $box, float $ratio, PresentationColor $color): void + { + $m = $this->theme->metrics; + $thumb = min($m->sliderThumbSize, $box->height); + if (!is_finite($ratio) || $ratio < 0 || $ratio > 1 || $box->width <= $thumb) { + throw new InvalidArgumentException('A slider needs a finite unit ratio and room for its track and thumb.'); + } + $trackHeight = min($m->sliderTrackHeight, $thumb); + $fillHeight = min($m->sliderFillHeight, $trackHeight); + $track = new CanvasRectangle($box->x + $thumb / 2, $box->y + ($box->height - $trackHeight) / 2, + $box->width - $thumb, $trackHeight); + $this->renderSurface($id . '-track', 'slider.track', $track, $this->theme->colors['edge']); + if ($ratio > 0) { + $this->fill($id . '-fill', new CanvasRectangle($track->x, $track->y + ($trackHeight - $fillHeight) / 2, + $track->width * $ratio, $fillHeight), $color); + } + $this->renderSurface($id . '-thumb', 'slider.thumb', + new CanvasRectangle($box->x + ($box->width - $thumb) * $ratio, $box->y + ($box->height - $thumb) / 2, $thumb, $thumb), + $color, true, 41); + } + + /** Offset and extents share the owner's units: pixels for rows, line counts for prose. */ + public function renderScrollbar(string $id, CanvasRectangle $box, float $offset, float $visible, float $total): void + { + if (!is_finite($offset) || !is_finite($visible) || !is_finite($total) + || $visible <= 0 || $total < 0 || $offset < 0 || $offset > max(0, $total - $visible)) { + throw new InvalidArgumentException('A scrollbar needs finite extents and an offset inside the readable range.'); + } + if ($total <= $visible) { return; } + $m = $this->theme->metrics; + $arrow = min($m->scrollbarWidth, $box->width); + $inset = $arrow + $m->scrollbarArrowGap; + if ($box->height <= 2 * $inset) { + throw new InvalidArgumentException('A scrollbar needs room for its chevrons, gaps and track.'); + } + $arrowX = $box->x + ($box->width - $arrow) / 2; + $this->renderChevron($id . '-up', 'up', new CanvasRectangle($arrowX, $box->y, $arrow, $arrow), $offset > 0); + $this->renderChevron($id . '-down', 'down', + new CanvasRectangle($arrowX, $box->y + $box->height - $arrow, $arrow, $arrow), $offset < $total - $visible); + $trackWidth = min($m->scrollbarTrackWidth, $box->width); + $track = new CanvasRectangle($box->x + ($box->width - $trackWidth) / 2, + $box->y + $inset, $trackWidth, $box->height - 2 * $inset); + $this->renderSurface($id . '-track', 'scroll.track', $track, $this->theme->colors['edge']); + $thumbHeight = min($track->height, max($m->scrollbarMinThumbHeight, $track->height * $visible / $total)); + $thumbY = $track->y + ($track->height - $thumbHeight) * $offset / ($total - $visible); + $this->renderSurface($id . '-thumb', 'scroll.thumb', + new CanvasRectangle($track->x, $thumbY, $trackWidth, $thumbHeight), $this->theme->colors['accent'], layer: 41); + } + + public function renderDivider(string $id, CanvasRectangle $box): void + { + $this->fill($id . '-line', new CanvasRectangle($box->x, $box->y + $box->height / 2, $box->width, 1), $this->theme->colors['edge']); + $asset = $this->theme->icons?->icons['decoration.divider'] ?? null; + if ($asset !== null) { + $width = min($box->width, 4 * $this->theme->metrics->cellHeight); + array_push($this->images, ...MenuIconRegistry::containAsset($this->theme->assetRoot, $id, $asset, + new CanvasRectangle($box->x + ($box->width - $width) / 2, $box->y, $width, $box->height), 40, $box)); + } + } + + public function fill(string $id, CanvasRectangle $box, PresentationColor $color, int $layer = 40): void + { + $columns = (int)ceil($box->width / 256); + $rows = (int)ceil($box->height / 256); + $cw = (int)ceil($box->width / $columns); + $ch = (int)ceil($box->height / $rows); + $runs = []; + for ($row = 0; $row < $rows; $row++) { + $runs[] = new PresentationTextRun($row, 0, str_repeat(' ', $columns), background: $color); + } + $this->text[] = new CanvasTextLayer($id, $layer, $box->x, $box->y, + new RendererGridConfig($columns, $rows, $cw, $ch), $runs, $box); + } + + public function finish(int $width, int $height): PresentationCanvas + { + return new PresentationCanvas($width, $height, $this->images, textLayers: $this->text); + } +} diff --git a/src/UI/Presentation/MenuDirection.php b/src/UI/Presentation/MenuDirection.php new file mode 100644 index 00000000..c8895c50 --- /dev/null +++ b/src/UI/Presentation/MenuDirection.php @@ -0,0 +1,38 @@ + "\u{2039}", self::RIGHT => "\u{203A}", + self::UP => "\u{2227}", self::DOWN => "\u{2228}", + }; + } + return match ($this) { + self::LEFT => "\u{2190}", self::RIGHT => "\u{2192}", + self::UP => "\u{2191}", self::DOWN => "\u{2193}", + }; + } + + /** Comparison arrows and navigation chevrons never borrow each other's artwork. */ + public function getImages(?MenuIconRegistry $icons, string $id, CanvasRectangle $bounds, int $layer, + bool $chevron = false): array + { + $asset = $icons?->icons[($chevron ? 'navigation.' : 'comparison.') . $this->value] ?? null; + return $asset === null ? [] : $icons->contain($id, $asset, $bounds, $layer, $bounds); + } +} diff --git a/src/UI/Presentation/MenuIconRegistry.php b/src/UI/Presentation/MenuIconRegistry.php new file mode 100644 index 00000000..f16d2807 --- /dev/null +++ b/src/UI/Presentation/MenuIconRegistry.php @@ -0,0 +1,67 @@ + */ + public array $icons; + + /** @param array $icons Semantic keys such as weapon.staff, slot.head, with optional unknown fallback. */ + public function __construct(public string $assetRoot, array $icons, public ?string $cursor = null) + { + $copy = []; + foreach ($icons as $semantic => $asset) { + if (!is_string($semantic) || !is_string($asset)) { + throw new InvalidArgumentException('Menu icon bindings require semantic keys and asset paths.'); + } + CanvasValidation::id($semantic); + SpriteValidation::validateAssetPath($asset); + $copy[$semantic] = $asset; + } + if ($cursor !== null) { SpriteValidation::validateAssetPath($cursor); } + $this->icons = $copy; + } + + public function asset(WeaponType|EquipmentSlotType|string|null $metadata): ?string + { + if ($metadata === null) { return null; } + $semantic = match (true) { + $metadata instanceof WeaponType => 'weapon.' . strtolower($metadata->value), + $metadata instanceof EquipmentSlotType => 'slot.' . $metadata->value, + default => $metadata, + }; + return $this->icons[$semantic] ?? $this->icons['unknown'] ?? null; + } + + /** Full-source contain uses a zero-cut CanvasNineSlice, never stretches or slices an icon. + * @return list + */ + public function contain(string $id, string $asset, CanvasRectangle $box, int $layer, CanvasRectangle $clip): array + { + return self::containAsset($this->assetRoot, $id, $asset, $box, $layer, $clip); + } + + /** @return list */ + public static function containAsset(string $root, string $id, string $asset, CanvasRectangle $box, int $layer, CanvasRectangle $clip): array + { + $art = CanvasNineSlice::fromPng($root, $asset); + $scale = min($box->width / $art->source->width, $box->height / $art->source->height); + $width = $art->source->width * $scale; + $height = $art->source->height * $scale; + return $art->images($id, new CanvasRectangle($box->x + ($box->width - $width) / 2, + $box->y + ($box->height - $height) / 2, $width, $height), $layer, $clip); + } +} diff --git a/src/UI/Presentation/MenuInfoPanel.php b/src/UI/Presentation/MenuInfoPanel.php new file mode 100644 index 00000000..2f090133 --- /dev/null +++ b/src/UI/Presentation/MenuInfoPanel.php @@ -0,0 +1,44 @@ +metrics; + $hintHeight = MenuActionHints::height($hints, $theme, $width - 2 * $m->panelPadding); + return 2 * $m->panelPadding + 2 * $m->cellHeight + $hintHeight + ($hintHeight > 0 ? $m->sectionGap : 0); + } + + public static function renderContent(MenuCanvas $view, string $id, CanvasRectangle $bounds, string $description, + ?string $status = null, string $statusColor = 'accent', ?MenuInfoText $infoModel = null, + ?CanvasRectangle $rangeBounds = null): void + { + $m = $view->theme->metrics; + $cells = (int)floor($bounds->width / $m->cellWidth); + $page = ($infoModel ?? new MenuInfoText())->getPage($description, $status, $cells); + $descriptionRows = $description === '' ? 0 : (new MenuInfoText())->getPage($description, null, $cells)->total; + $split = max(0, min(count($page->lines), $descriptionRows - $page->first)); + if ($split > 0) { + $view->textLines($id . '-description', array_map(static fn(string $line) => ['text' => $line, 'color' => 'text'], + array_slice($page->lines, 0, $split)), + new CanvasRectangle($bounds->x, $bounds->y, $bounds->width, $split * $m->cellHeight)); + } + if ($split < count($page->lines)) { + $view->textLines($id . '-status', array_map(static fn(string $line) => ['text' => $line, 'color' => $statusColor], + array_slice($page->lines, $split)), + new CanvasRectangle($bounds->x, $bounds->y + $split * $m->cellHeight, + $bounds->width, (2 - $split) * $m->cellHeight)); + } + if ($page->total > 2 && $rangeBounds !== null) { + $view->renderBorderCaption($id . '-info-range', $page->range(), $rangeBounds); + } + } +} diff --git a/src/UI/Presentation/MenuLayout.php b/src/UI/Presentation/MenuLayout.php new file mode 100644 index 00000000..3e276e1a --- /dev/null +++ b/src/UI/Presentation/MenuLayout.php @@ -0,0 +1,24 @@ +metrics->cellWidth, + mb_strlen($label) * $theme->metrics->cellWidth + 2 * $theme->rows->metrics->padding)); + } +} diff --git a/src/UI/Presentation/MenuModalPresentation.php b/src/UI/Presentation/MenuModalPresentation.php new file mode 100644 index 00000000..5d887642 --- /dev/null +++ b/src/UI/Presentation/MenuModalPresentation.php @@ -0,0 +1,114 @@ +metrics; + $p = $m->panelPadding; + $gap = $m->sectionGap; + $longest = static fn(string $text): int => max(array_map(mb_strlen(...), explode("\n", str_replace(["\r\n", "\r", "\t"], ["\n", "\n", ' '], $text)))); + $buttonWidths = array_map(fn(string $label) => $longest($label) * $m->cellWidth + 2 * $theme->rows->metrics->padding, $modal->choices); + $choicesWidth = $modal->singleConfirmation ? 2 * $buttonWidths[0] + : ($modal->vertical ? max($buttonWidths) : array_sum($buttonWidths) + (count($buttonWidths) - 1) * $gap); + $naturalWidth = max(36 * $m->cellWidth, $longest($modal->title) * $m->cellWidth, + $longest($modal->message) * $m->cellWidth, $choicesWidth); + if ($modal->quantity !== null) { + $naturalWidth = max($naturalWidth, (2 * strlen((string)$modal->quantity->maximum) + 3) * $m->cellWidth + + 2 * ($m->cellHeight + $gap)); + } + $width = (int)floor(min($base->width * 2 / 3 - 2 * $p, $naturalWidth) / $m->cellWidth) * $m->cellWidth; + $x = ($base->width - $width) / 2; + $cells = (int)floor($width / $m->cellWidth); + $titleHeight = $modal->title === '' ? 0 : count(MenuCanvas::wrap($modal->title, $cells)) * $m->cellHeight; + $messageHeight = $modal->message === '' ? 0 : count(MenuCanvas::wrap($modal->message, $cells)) * $m->cellHeight; + $bodyHeight = $modal->singleConfirmation ? max($messageHeight, 3 * $m->cellHeight) : $messageHeight; + $quantityHeight = $modal->quantity === null ? 0 : 2 * $m->cellHeight + $gap; + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')]; + $hintHeight = MenuActionHints::height($hints, $theme, $width); + $hintGap = $hintHeight > 0 ? $gap : 0; + $available = $base->height - 4 * $p - $titleHeight - $bodyHeight - $quantityHeight - $hintHeight - 2 * $gap - $hintGap; + if ($available < $m->rowHeight + $m->cellHeight) { + throw new RuntimeException('Modal content exceeds its finite menu viewport; terminal presentation retained.'); + } + $view = new MenuCanvas($theme, $base->width, $base->height, $time); + $rows = []; + foreach ($modal->choices as $index => $choice) { + $rows[] = new MenuRow((string)$index, $choice, + kind: $modal->vertical ? MenuRowKind::COMMAND : MenuRowKind::BUTTON, + selected: $index === $modal->activeIndex, focused: $index === $modal->activeIndex); + } + $singleButton = !$modal->vertical && count($rows) === 1; + $buttonWidth = $singleButton ? MenuLayout::getButtonWidth($theme, $modal->choices[0], $width) + : ($modal->vertical ? $width : ($width - (count($rows) - 1) * $gap) / count($rows)); + if ($buttonWidth <= 2 * $theme->rows->metrics->padding) { + throw new RuntimeException('Modal buttons require a wider menu viewport.'); + } + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, $buttonWidth, $available), rowHeight: $m->rowHeight, + cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true); + $heights = array_map(fn(MenuRow $row) => $layout->heightFor($row, $theme->rows->metrics, false), $rows); + $choiceHeight = $modal->vertical ? min($available, array_sum($heights)) : max($heights); + if ($choiceHeight > $available) { throw new RuntimeException('Modal button text exceeds its finite menu viewport.'); } + $height = 2 * $p + $titleHeight + $bodyHeight + $quantityHeight + $choiceHeight + $hintHeight + 2 * $gap + $hintGap; + $top = ($base->height - $height) / 2; + $box = new CanvasRectangle($x - $p, $top, $width + 2 * $p, $height); + $view->frame('menu-modal-frame', $box); + $y = $top + $p; + if ($titleHeight > 0) { + $view->prose('menu-modal-title', $modal->title, new CanvasRectangle($x, $y, $width, $titleHeight), + alignment: HorizontalAlignment::CENTER); + } + $y += $titleHeight + $gap; + if ($messageHeight > 0) { + $view->prose('menu-modal-message', $modal->message, + new CanvasRectangle($x, $y + ($bodyHeight - $messageHeight) / 2, $width, $messageHeight), + alignment: $modal->singleConfirmation || $messageHeight === $m->cellHeight + ? HorizontalAlignment::CENTER : HorizontalAlignment::LEFT); + } + $y += $bodyHeight + $gap; + $controls = new MenuControls($theme); + if ($modal->quantity !== null) { + $quantity = $modal->quantity; + $center = $base->width / 2; + $valueWidth = (2 * strlen((string)$quantity->maximum) + 3) * $m->cellWidth; + $left = $center - $valueWidth / 2; + $view->prose('menu-modal-quantity', $quantity->value . ' / ' . $quantity->maximum, + new CanvasRectangle($left, $y + $m->cellHeight / 2, $valueWidth, $m->cellHeight), alignment: HorizontalAlignment::CENTER); + $arrowX = $left + $valueWidth + $gap; + $controls->renderChevron('menu-modal-quantity-up', MenuDirection::UP->value, + new CanvasRectangle($arrowX, $y, $m->cellHeight, $m->cellHeight), $quantity->value < $quantity->maximum); + $controls->renderChevron('menu-modal-quantity-down', MenuDirection::DOWN->value, + new CanvasRectangle($arrowX, $y + $m->cellHeight, $m->cellHeight, $m->cellHeight), $quantity->value > $quantity->minimum); + $y += $quantityHeight; + } + if ($modal->vertical) { + $view->rows('menu-modal-choice', $rows, new MenuRowLayout(new CanvasRectangle($x, $y, $width, $choiceHeight), + rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true), $modal->activeIndex); + } else { + foreach ($rows as $index => $row) { + $view->rows('menu-modal-choice', [$row], new MenuRowLayout( + new CanvasRectangle($x + ($singleButton ? ($width - $buttonWidth) / 2 : $index * ($buttonWidth + $gap)), + $y, $buttonWidth, $choiceHeight), + rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true)); + } + } + if ($hintHeight > 0) { + $view->hints('menu-modal-hints', $hints, new CanvasRectangle($x, $y + $choiceHeight + $hintGap, $width, $hintHeight)); + } + $dialog = MenuCanvas::overlay($view->finish(), $controls->finish($base->width, $base->height), $theme); + return MenuCanvas::overlay($base, $dialog, $theme); + } +} diff --git a/src/UI/Presentation/MenuPresentationCatalog.php b/src/UI/Presentation/MenuPresentationCatalog.php new file mode 100644 index 00000000..47a7c5d3 --- /dev/null +++ b/src/UI/Presentation/MenuPresentationCatalog.php @@ -0,0 +1,136 @@ + [232, 235, 239], 'selected' => [46, 59, 72], 'accent' => [153, 193, 213], + 'focus' => [220, 232, 240], 'disabled' => [126, 137, 147], 'edge' => [162, 172, 182], + 'background' => [13, 18, 24], 'panel' => [24, 31, 40], + 'increase' => [118, 213, 159], 'decrease' => [237, 142, 142], + ]; + + public MenuRowSkin $rows; + public MenuThemeMetrics $metrics; + public ?MenuIconRegistry $icons; + /** @var array */ + public array $colors; + /** @var array */ + public array $frames; + /** @var array Stable actor IDs to relative PNG paths. */ + public array $portraits; + public bool $showInputHints; + + public function __construct(public string $assetRoot, array $data) + { + self::keys($data, ['schema', 'colors', 'metrics', 'rowMetrics', 'rowArtwork', 'frames', 'icons', 'cursor', 'portraits', 'showInputHints']); + if (($data['schema'] ?? null) !== 'ichiloto.menu/1') { + throw new InvalidArgumentException('Menu catalog requires schema ichiloto.menu/1.'); + } + $showInputHints = $data['showInputHints'] ?? true; + if (!is_bool($showInputHints)) { throw new InvalidArgumentException('Menu showInputHints must be a boolean.'); } + $this->showInputHints = $showInputHints; + $colors = self::map($data, 'colors'); + self::keys($colors, array_keys(self::DEFAULT_COLORS)); + $palette = []; + foreach (array_replace(self::DEFAULT_COLORS, $colors) as $role => $rgb) { + if (!is_array($rgb) || !array_is_list($rgb) || count($rgb) !== 3 || !array_all($rgb, static fn($value) => is_int($value))) { + throw new InvalidArgumentException('Menu colors must be RGB integer triples.'); + } + $palette[$role] = PresentationColor::rgb(...$rgb); + } + $this->colors = $palette; + $this->metrics = new MenuThemeMetrics(...self::map($data, 'metrics')); + $art = self::artwork(self::map($data, 'rowArtwork'), MenuRowSkin::ARTWORK); + $this->rows = new MenuRowSkin(array_intersect_key($palette, array_flip(MenuRowSkin::COLORS)), + new MenuRowMetrics(...self::map($data, 'rowMetrics')), $art, $assetRoot); + $this->frames = self::artwork(self::map($data, 'frames'), ['panel', 'quiet', 'portrait', + 'slider.track', 'slider.thumb', 'scroll.track', 'scroll.thumb']); + $bindings = self::map($data, 'icons'); + $cursor = $data['cursor'] ?? null; + if ($cursor !== null && !is_string($cursor)) { throw new InvalidArgumentException('Menu cursor must be a PNG path.'); } + $this->icons = $bindings !== [] || $cursor !== null ? new MenuIconRegistry($assetRoot, $bindings, $cursor) : null; + $portraits = self::map($data, 'portraits'); + foreach ($portraits as $actorId => $asset) { + if (!is_string($actorId) || $actorId === '' || !is_string($asset)) { + throw new InvalidArgumentException('Menu portraits require stable actor IDs and PNG paths.'); + } + SpriteValidation::validateAssetPath($asset); + } + $this->portraits = $portraits; + // Diagnose every configured asset, including artwork for an actor not currently on screen. + $assets = [...array_values($bindings), ...array_values($portraits)]; + if ($cursor !== null) { $assets[] = $cursor; } + foreach ([...array_values($art), ...array_values($this->frames)] as $image) { $assets[] = $image->asset; } + foreach (array_unique($assets) as $asset) { PngAssetPreflight::inspect($assetRoot, $asset); } + } + + public static function exists(string $assetRoot): bool + { + return file_exists($assetRoot . '/' . self::FILE) || is_link($assetRoot . '/' . self::FILE); + } + + /** Discover existing capabilities without executing or validating optional artwork at renderer startup. */ + public static function requestedCapabilities(string $assetRoot): array + { + return self::exists($assetRoot) ? self::CAPABILITIES : []; + } + + public static function load(string $assetRoot): ?self + { + if (!self::exists($assetRoot)) { return null; } + $root = realpath($assetRoot); + $path = realpath($assetRoot . '/' . self::FILE); + if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) + || !is_file($path) || !is_readable($path)) { + throw new RuntimeException('Menu presentation catalog must be a readable file inside assets.'); + } + $data = (static fn(string $file): mixed => require $file)($path); + if (!is_array($data)) { throw new RuntimeException(self::FILE . ' must return a plain theme array.'); } + return new self($root, $data); + } + + private static function map(array $data, string $key): array + { + $value = $data[$key] ?? []; + if (!is_array($value)) { throw new InvalidArgumentException("Menu {$key} must be an array."); } + return $value; + } + + private static function keys(array $data, array $allowed): void + { + if (array_diff(array_keys($data), $allowed) !== []) { throw new InvalidArgumentException('Unsupported menu theme field.'); } + } + + /** @return array */ + private static function artwork(array $data, array $roles): array + { + self::keys($data, $roles); + $result = []; + foreach ($data as $role => $definition) { + if (!is_array($definition)) { throw new InvalidArgumentException('Menu artwork must be a plain descriptor.'); } + self::keys($definition, ['asset', 'cuts', 'density', 'borderWidths']); + $cuts = $definition['cuts'] ?? [0, 0, 0, 0]; + if (!is_array($cuts) || !array_is_list($cuts) || count($cuts) !== 4 || !array_all($cuts, static fn($value) => is_int($value))) { + throw new InvalidArgumentException('Menu artwork cuts must be four integers: left, top, right, bottom.'); + } + $result[$role] = new MenuRowArtwork($definition['asset'] ?? '', + ...[...$cuts, $definition['density'] ?? 1, $definition['borderWidths'] ?? null]); + } + return $result; + } +} diff --git a/src/UI/Presentation/MenuRow.php b/src/UI/Presentation/MenuRow.php new file mode 100644 index 00000000..dda635dc --- /dev/null +++ b/src/UI/Presentation/MenuRow.php @@ -0,0 +1,48 @@ + */ + public array $values; + + /** @param list $values */ + public function __construct( + public string $id, + public string $label, + array $values = [], + public MenuRowKind $kind = MenuRowKind::RECORD, + public WeaponType|EquipmentSlotType|string|null $icon = null, + public bool $selected = false, + public bool $focused = false, + public bool $disabled = false, + public bool $showCursor = true, + ) { + CanvasValidation::id($id); + new PresentationTextRun(0, 0, $label); + if (!array_is_list($values)) { throw new InvalidArgumentException('Menu values must be a list.'); } + $copy = []; + foreach ($values as $value) { + if (!$value instanceof MenuRowValue) { throw new InvalidArgumentException('Menu values must be typed.'); } + $copy[] = $value; + } + if ($kind->isAction() && $values !== []) { + throw new InvalidArgumentException('Commands have one centered label, not data columns.'); + } + if (trim($label) === '' && !array_any($copy, static fn(MenuRowValue $value) => trim($value->text) !== '')) { + throw new InvalidArgumentException('Blank space is not a menu record. Supply only real rows.'); + } + if (is_string($icon)) { CanvasValidation::id($icon); } + $this->values = $copy; + } +} diff --git a/src/UI/Presentation/MenuRowArtwork.php b/src/UI/Presentation/MenuRowArtwork.php new file mode 100644 index 00000000..a9d31095 --- /dev/null +++ b/src/UI/Presentation/MenuRowArtwork.php @@ -0,0 +1,95 @@ + 4096 || min($left, $top, $right, $bottom) < 0 || !in_array($density, [1, 2], true)) { + throw new InvalidArgumentException('Menu artwork requires nonnegative cuts and a supported PNG density.'); + } + if ($borderWidths !== null && (!array_is_list($borderWidths) || count($borderWidths) !== 4 + || !array_all($borderWidths, static fn($value) => is_int($value) && $value >= 0))) { + throw new InvalidArgumentException('Menu border widths must be four nonnegative source-pixel integers.'); + } + } + + /** @return list */ + public function images(string $root, string $id, CanvasRectangle $bounds, int $layer, ?int $borderLayer = null): array + { + $images = $this->slice($root, $bounds)->images($id, $bounds, $layer, $bounds); + if ($borderLayer === null) { return $images; } + $result = []; + foreach ($images as $image) { + $cell = substr($image->id, strlen($id) + 1); + if ($cell === '1-1') { $result[] = $image; continue; } + $side = array_search($cell, ['1-0', '0-1', '1-2', '2-1'], true); + if ($this->borderWidths === null || $side === false) { + $result[] = new CanvasImage($image->id, $image->asset, $image->destination, $borderLayer, + $image->sourceRect, $image->opacity, $image->clipRect); + continue; + } + // Partition each straight edge once: inner backing below selection, decoration above it. + // Disjoint clips avoid double-compositing translucent replacement artwork. + $d = $image->destination; + $horizontal = $side === 1 || $side === 3; + $extent = $horizontal ? $d->height : $d->width; + $border = min($extent, $this->borderWidths[$side] / $this->density); + $start = $side < 2; + foreach ([true, false] as $outer) { + $size = $outer ? $border : $extent - $border; + if ($size <= 0) { continue; } + $offset = $outer ? ($start ? 0 : $extent - $border) : ($start ? $border : 0); + $clip = new CanvasRectangle($d->x + ($horizontal ? 0 : $offset), $d->y + ($horizontal ? $offset : 0), + $horizontal ? $d->width : $size, $horizontal ? $size : $d->height); + $result[] = new CanvasImage($image->id . ($outer ? '-border' : '-backing'), $image->asset, + $d, $outer ? $borderLayer : $layer, $image->sourceRect, $image->opacity, $clip); + } + } + return $result; + } + + /** Effective straight-edge insets, after current-image reconciliation and density scaling. + * @return null|array{float, float, float, float} + */ + public function borderInsets(string $root, CanvasRectangle $bounds): ?array + { + if ($this->borderWidths === null) { return null; } + $slice = $this->slice($root, $bounds); + return array_map(fn(int $width, int $cut): float => min($width, $cut) / $this->density, + $this->borderWidths, [$slice->left, $slice->top, $slice->right, $slice->bottom]); + } + + private function slice(string $root, CanvasRectangle $bounds): CanvasNineSlice + { + // Row geometry constrains authored cuts; the shared loader reconciles current source facts. + [$left, $right] = $this->fitCuts($this->left, $this->right, (int)floor($bounds->width * $this->density)); + [$top, $bottom] = $this->fitCuts($this->top, $this->bottom, (int)floor($bounds->height * $this->density)); + if ([$left, $top, $right, $bottom] !== [$this->left, $this->top, $this->right, $this->bottom]) { + Debug::warn("Menu artwork cuts reconciled to current image and row bounds: {$this->asset}"); + } + return CanvasNineSlice::fromPng($root, $this->asset, $left, $top, $right, $bottom, $this->density); + } + + /** @return array{int, int} */ + private function fitCuts(int $first, int $last, int $available): array + { + if ($first + $last <= $available) { return [$first, $last]; } + $start = (int)floor($available * ($first / ((float)$first + $last))); + return [$start, $available - $start]; + } +} diff --git a/src/UI/Presentation/MenuRowColumn.php b/src/UI/Presentation/MenuRowColumn.php new file mode 100644 index 00000000..bfae13d6 --- /dev/null +++ b/src/UI/Presentation/MenuRowColumn.php @@ -0,0 +1,19 @@ + 512) { + throw new InvalidArgumentException('Menu value columns require 1..512 cells.'); + } + } +} diff --git a/src/UI/Presentation/MenuRowKind.php b/src/UI/Presentation/MenuRowKind.php new file mode 100644 index 00000000..9e455769 --- /dev/null +++ b/src/UI/Presentation/MenuRowKind.php @@ -0,0 +1,18 @@ + */ + public array $columns; + + /** @param list $columns */ + public function __construct( + public CanvasRectangle $viewport, + array $columns = [], + public int $rowHeight = 40, + public int $cellWidth = 10, + public int $cellHeight = 24, + public bool $wrapText = false, + ) { + new RendererGridConfig(1, 1, $cellWidth, $cellHeight); + if ($rowHeight < $cellHeight || $rowHeight > 256 || !array_is_list($columns)) { + throw new InvalidArgumentException('Menu row geometry must fit its Canvas text height.'); + } + $copy = []; + foreach ($columns as $column) { + if (!$column instanceof MenuRowColumn) { throw new InvalidArgumentException('Menu columns must be typed.'); } + $copy[] = $column; + } + $this->columns = $copy; + } + + public function capacity(): int + { + return (int)floor($this->viewport->height / $this->rowHeight); + } + + /** Complete scalar text is wrapped, never elided. Values retain their own aligned columns. */ + public function lines(string $text, int $cells): array + { + if ($cells < 1 || (!$this->wrapText && mb_strlen($text, 'UTF-8') > $cells)) { + throw new InvalidArgumentException('Menu text requires wider columns or wrapping; complete text must not be silently truncated.'); + } + return MenuTextWrap::lines($text, $cells); + } + + public function heightFor(MenuRow $row, MenuRowMetrics $metrics, bool $hasIcon): int + { + $iconCells = $hasIcon ? (int)ceil($metrics->iconWidth / $this->cellWidth) + $metrics->gapCells : 0; + $cells = $row->kind->isAction() + ? (int)floor(($this->viewport->width - 2 * ($metrics->padding + $iconCells * $this->cellWidth)) / $this->cellWidth) + : $this->textCells($metrics) - $this->valueCells($metrics) - $iconCells; + $lines = count($this->lines($row->label, $cells)); + foreach ($row->values as $index => $value) { + $column = $this->columns[$index] ?? throw new InvalidArgumentException('Menu value column missing.'); + $lines = max($lines, count($this->lines($value->text, $column->cells))); + } + return $this->rowHeight + ($lines - 1) * $this->cellHeight; + } + + public function textCells(MenuRowMetrics $metrics): int + { + return (int)floor(($this->viewport->width - 2 * $metrics->padding) / $this->cellWidth); + } + + public function valueCells(MenuRowMetrics $metrics): int + { + return array_sum(array_map(fn(MenuRowColumn $column) => $column->cells + $metrics->gapCells, $this->columns)); + } + + public function assertFits(MenuRowMetrics $metrics): void + { + $cells = $this->textCells($metrics); + if ($cells < 1 || $cells > 512 || $this->valueCells($metrics) >= $cells + || $metrics->separatorWidth + $this->cellHeight > $this->rowHeight + || 2 * $metrics->focusWidth >= min($this->rowHeight, $this->viewport->width) + || $metrics->accentWidth > $this->viewport->width) { + throw new InvalidArgumentException('Menu columns and theme metrics require a larger row or viewport within Canvas limits.'); + } + } +} diff --git a/src/UI/Presentation/MenuRowMetrics.php b/src/UI/Presentation/MenuRowMetrics.php new file mode 100644 index 00000000..64303c7a --- /dev/null +++ b/src/UI/Presentation/MenuRowMetrics.php @@ -0,0 +1,41 @@ + 1) { + throw new InvalidArgumentException('Menu image boxes and cursor period must be positive; opacity must be in 0..1.'); + } + } + + public function cursorOffset(float $time, bool $reducedMotion): float + { + return $reducedMotion ? 0.0 : $this->cursorTravel / 2 * (1 - cos(2 * M_PI * fmod($time, $this->cursorPeriod) / $this->cursorPeriod)); + } +} diff --git a/src/UI/Presentation/MenuRowPainter.php b/src/UI/Presentation/MenuRowPainter.php new file mode 100644 index 00000000..d4572547 --- /dev/null +++ b/src/UI/Presentation/MenuRowPainter.php @@ -0,0 +1,239 @@ + */ + private array $images = []; + /** @var list */ + private array $text = []; + + private function __construct(private int $width, private int $height, private int $layer, private int $contentLayer) {} + + /** Supply only real visible records, not prose or padding rows. Merge returned layers into the owner's canvas. + * @param list $rows + */ + public static function compose(int $width, int $height, string $id, array $rows, MenuRowLayout $layout, + MenuRowSkin $skin, ?MenuIconRegistry $icons = null, float $time = 0, ?bool $reducedMotion = null, + int $layer = 1000, ?CanvasRectangle $recordBounds = null, ?int $contentLayer = null): PresentationCanvas + { + new PresentationCanvas($width, $height); + CanvasValidation::id($id); + $layout->viewport->assertWithin($width, $height); + $layout->assertFits($skin->metrics); + if ($recordBounds !== null) { + $recordBounds->assertWithin($width, $height); + if (count($rows) !== 1 || $rows[0]->kind !== MenuRowKind::RECORD) { + throw new InvalidArgumentException('Separate treatment bounds require one real record.'); + } + } + if (!is_finite($time) || $time < 0 || !array_is_list($rows) || count($rows) > $layout->capacity()) { + throw new InvalidArgumentException('Menu rows require finite nonnegative time and an owner-resolved whole-row visible subset.'); + } + $view = new self($width, $height, $layer, $contentLayer ?? $layer + 5); + $reducedMotion ??= Accessibility::prefersReducedMotion(); + $ids = []; + $y = $layout->viewport->y; + foreach ($rows as $index => $row) { + if (!$row instanceof MenuRow || isset($ids[$row->id])) { + throw new InvalidArgumentException('Menu rows require typed records with unique stable IDs.'); + } + $ids[$row->id] = true; + $rowHeight = $layout->heightFor($row, $skin->metrics, $icons?->asset($row->icon) !== null); + if ($y + $rowHeight > $layout->viewport->y + $layout->viewport->height) { $view->overflow($row->id); } + $bounds = new CanvasRectangle($layout->viewport->x, $y, $layout->viewport->width, $rowHeight); + if ($recordBounds !== null && ($bounds->x < $recordBounds->x || $bounds->y < $recordBounds->y + || $bounds->x + $bounds->width > $recordBounds->x + $recordBounds->width + || $bounds->y + $bounds->height > $recordBounds->y + $recordBounds->height)) { + throw new InvalidArgumentException('Record identity must fit inside its treatment bounds.'); + } + $view->row($id . '-' . $row->id, $row, $bounds, $layout, $skin, $icons, $time, $reducedMotion, $recordBounds); + $y += $rowHeight; + } + return new PresentationCanvas($width, $height, $view->images, textLayers: $view->text); + } + + private function row(string $id, MenuRow $row, CanvasRectangle $bounds, MenuRowLayout $layout, + MenuRowSkin $skin, ?MenuIconRegistry $icons, float $time, bool $reducedMotion, ?CanvasRectangle $recordBounds): void + { + $identityBounds = $bounds; + $bounds = $recordBounds ?? $bounds; + $colors = $skin->colors; + $metrics = $skin->metrics; + $this->treatment($id . '-normal', $row, $skin, 'normal', $bounds, $this->layer); + if ($row->selected && !$this->treatment($id . '-selected', $row, $skin, 'selected', $bounds, $this->layer + 1)) { + $this->fill($id . '-selected', $bounds, $colors['selected'], $this->layer + 1); + if ($metrics->accentWidth > 0) { + $this->fill($id . '-accent', new CanvasRectangle($bounds->x, $bounds->y, $metrics->accentWidth, $bounds->height), + $colors['accent'], $this->layer + 1); + } + } + if ($row->disabled) { $this->treatment($id . '-disabled', $row, $skin, 'disabled', $bounds, $this->layer + 2); } + if (!$row->kind->isAction() && $metrics->separatorWidth > 0) { + $this->fill($id . '-separator', new CanvasRectangle($bounds->x, $bounds->y + $bounds->height - $metrics->separatorWidth, + $bounds->width, $metrics->separatorWidth), $colors['edge'], $this->layer + 3, + $row->kind === MenuRowKind::HEADING ? $metrics->headingSeparatorOpacity : $metrics->recordSeparatorOpacity); + } + if ($row->focused && !$this->treatment($id . '-focus', $row, $skin, 'focus', $bounds, $this->layer + 4) + && $metrics->focusWidth > 0) { + $focus = $colors[$row->disabled ? 'edge' : 'focus']; + $edgeWidth = $metrics->focusWidth; + foreach ([ + new CanvasRectangle($bounds->x, $bounds->y, $bounds->width, $edgeWidth), + new CanvasRectangle($bounds->x, $bounds->y + $bounds->height - $edgeWidth, $bounds->width, $edgeWidth), + new CanvasRectangle($bounds->x, $bounds->y + $edgeWidth, $edgeWidth, $bounds->height - 2 * $edgeWidth), + new CanvasRectangle($bounds->x + $bounds->width - $edgeWidth, $bounds->y + $edgeWidth, $edgeWidth, $bounds->height - 2 * $edgeWidth), + ] as $edge => $rect) { + $this->fill($id . '-focus-' . $edge, $rect, $focus, $this->layer + 4); + } + } + + $bounds = $identityBounds; + $asset = $icons?->asset($row->icon); + if ($asset !== null) { + if ($metrics->iconHeight > $bounds->height || $metrics->iconWidth > $bounds->width - 2 * $metrics->padding) { + throw new InvalidArgumentException('Menu icon box requires a larger row or viewport.'); + } + array_push($this->images, ...$icons->contain($id . '-icon', $asset, + new CanvasRectangle($bounds->x + $metrics->padding, $bounds->y + ($bounds->height - $metrics->iconHeight) / 2, + $metrics->iconWidth, $metrics->iconHeight), $this->contentLayer, $bounds)); + } + $this->renderLabel($id, $row, $bounds, $layout, $skin, $asset !== null, $icons); + if ($row->focused && $row->showCursor && $row->kind !== MenuRowKind::BUTTON + && !$row->disabled && $icons?->cursor !== null) { + if ($metrics->cursorHeight > $bounds->height + || $metrics->cursorInset + $metrics->cursorWidth + $metrics->cursorTravel > $metrics->padding) { + throw new InvalidArgumentException('Menu cursor box and motion must fit the separate leading gutter.'); + } + array_push($this->images, ...$icons->contain($id . '-cursor', $icons->cursor, + new CanvasRectangle($bounds->x + $metrics->cursorInset + $metrics->cursorOffset($time, $reducedMotion), + $bounds->y + ($bounds->height - $metrics->cursorHeight) / 2, $metrics->cursorWidth, $metrics->cursorHeight), + $this->contentLayer + 1, $bounds)); + } + } + + private function treatment(string $id, MenuRow $row, MenuRowSkin $skin, string $role, CanvasRectangle $bounds, int $layer): bool + { + $art = $skin->treatment($row->kind, $role); + if ($art === null) { return false; } + array_push($this->images, ...$art->images($skin->assetRoot ?? throw new InvalidArgumentException('Menu artwork needs its asset root.'), + $id, $bounds, $layer)); + return true; + } + + private function renderLabel(string $id, MenuRow $row, CanvasRectangle $bounds, MenuRowLayout $layout, + MenuRowSkin $skin, bool $hasIcon, ?MenuIconRegistry $icons): void + { + $color = $skin->colors[$row->disabled ? 'disabled' : 'text']; + $metrics = $skin->metrics; + $separator = $row->kind->isAction() ? 0 : $metrics->separatorWidth; + $y = $bounds->y + ($layout->rowHeight - $separator - $layout->cellHeight) / 2; + $iconCells = $hasIcon ? (int)ceil($metrics->iconWidth / $layout->cellWidth) + $metrics->gapCells : 0; + $length = mb_strlen($row->label, 'UTF-8'); + if ($row->kind->isAction()) { + $available = $bounds->width - 2 * ($metrics->padding + $iconCells * $layout->cellWidth); + if ($length === 0) { return; } + $lines = $layout->lines($row->label, (int)floor($available / $layout->cellWidth)); + $length = max(array_map(mb_strlen(...), $lines)); + $runs = []; + foreach ($lines as $line => $text) { $runs[] = new PresentationTextRun($line, intdiv($length - mb_strlen($text), 2), $text, $color); } + $this->text[] = new CanvasTextLayer($id . '-text', $this->contentLayer, + $bounds->x + ($bounds->width - $length * $layout->cellWidth) / 2, $y, + new RendererGridConfig($length, count($lines), $layout->cellWidth, $layout->cellHeight), $runs, $bounds); + return; + } + if (count($row->values) !== count($layout->columns)) { + throw new InvalidArgumentException("Menu row {$row->id} must supply every configured value column, including empty fields."); + } + $cells = $layout->textCells($metrics); + $labelCells = $cells - $layout->valueCells($metrics); + $runs = []; + $lines = $layout->lines($row->label, $labelCells - $iconCells); + $lineCount = count($lines); + foreach ($lines as $line => $text) { + if ($text !== '') { $runs[] = new PresentationTextRun($line, $iconCells, $text, $color); } + } + $column = $labelCells; + foreach ($layout->columns as $index => $definition) { + $column += $metrics->gapCells; + $value = $row->values[$index]; + if ($value->direction !== null) { + $left = $bounds->x + $bounds->width - $metrics->padding - $cells * $layout->cellWidth; + $arrow = $value->direction->getImages($icons, $id . '-value-' . $index, + new CanvasRectangle($left + $column * $layout->cellWidth, $y, + $definition->cells * $layout->cellWidth, $layout->cellHeight), $this->contentLayer); + if ($arrow !== []) { + array_push($this->images, ...$arrow); + $column += $definition->cells; + continue; + } + } + $lines = $layout->lines($value->text, $definition->cells); + $lineCount = max($lineCount, count($lines)); + foreach ($lines as $line => $text) { + $length = mb_strlen($text, 'UTF-8'); + $space = $definition->cells - $length; + $offset = match ($definition->alignment) { + HorizontalAlignment::LEFT => 0, + HorizontalAlignment::CENTER => intdiv($space, 2), + HorizontalAlignment::RIGHT => $space, + }; + if ($length > 0) { + $runs[] = new PresentationTextRun($line, $column + $offset, $text, + $row->disabled ? $color : ($value->color ?? $color)); + } + } + $column += $definition->cells; + } + if ($runs === []) { return; } + // All fields share a baseline and one text layer, rather than one layer per value. + $this->text[] = new CanvasTextLayer($id . '-text', $this->contentLayer, + $bounds->x + $bounds->width - $metrics->padding - $cells * $layout->cellWidth, $y, + new RendererGridConfig($cells, $lineCount, $layout->cellWidth, $layout->cellHeight), $runs, $bounds); + } + + private function overflow(string $id): never + { + throw new InvalidArgumentException("Menu row {$id} needs wider columns or viewport; complete text must not be silently truncated."); + } + + /** Canvas background runs are the existing flat-fill primitive. Clip rounding inside the original row. */ + private function fill(string $id, CanvasRectangle $rect, PresentationColor $color, int $layer, float $opacity = 1): void + { + if ($rect->height > 256) { + $this->fill($id . '-top', new CanvasRectangle($rect->x, $rect->y, $rect->width, 256), $color, $layer, $opacity); + $this->fill($id . '-bottom', new CanvasRectangle($rect->x, $rect->y + 256, $rect->width, $rect->height - 256), $color, $layer, $opacity); + return; + } + $columns = (int)ceil($rect->width / 256); + $cellWidth = (int)ceil($rect->width / $columns); + if ($columns * $cellWidth > $this->width) { + $firstWidth = (int)floor($this->width / $columns) * $columns; + $this->fill($id . '-left', new CanvasRectangle($rect->x, $rect->y, $firstWidth, $rect->height), $color, $layer, $opacity); + $this->fill($id . '-right', new CanvasRectangle($rect->x + $firstWidth, $rect->y, $rect->width - $firstWidth, $rect->height), + $color, $layer, $opacity); + return; + } + $cellHeight = (int)ceil($rect->height); + $this->text[] = new CanvasTextLayer($id, $layer, + min($rect->x, $this->width - $columns * $cellWidth), min($rect->y, $this->height - $cellHeight), + new RendererGridConfig($columns, 1, $cellWidth, $cellHeight), + [new PresentationTextRun(0, 0, str_repeat(' ', $columns), background: $color)], $rect, $opacity); + } +} diff --git a/src/UI/Presentation/MenuRowSkin.php b/src/UI/Presentation/MenuRowSkin.php new file mode 100644 index 00000000..7d54a1c7 --- /dev/null +++ b/src/UI/Presentation/MenuRowSkin.php @@ -0,0 +1,55 @@ + */ + public array $colors; + /** @var array */ + public array $artwork; + + /** Command roles override the shared role; omitted artwork uses the neutral primitive treatment. + * @param array $colors + * @param array $artwork + */ + public function __construct(array $colors, public MenuRowMetrics $metrics = new MenuRowMetrics(), + array $artwork = [], public ?string $assetRoot = null) + { + if (count($colors) !== count(self::COLORS)) { + throw new InvalidArgumentException('Menu row skin requires exactly its supported color roles.'); + } + $copy = []; + foreach (self::COLORS as $role) { + if (!($colors[$role] ?? null) instanceof PresentationColor) { + throw new InvalidArgumentException("Menu row skin requires a typed {$role} color."); + } + $copy[$role] = $colors[$role]; + } + $this->colors = $copy; + $copy = []; + foreach ($artwork as $role => $art) { + if (!in_array($role, self::ARTWORK, true) || !$art instanceof MenuRowArtwork || $assetRoot === null) { + throw new InvalidArgumentException('Menu state artwork requires a supported role, typed artwork and asset root.'); + } + $copy[$role] = $art; + } + $this->artwork = $copy; + } + + public function treatment(MenuRowKind $kind, string $role): ?MenuRowArtwork + { + return ($kind->isAction() ? ($this->artwork['command.' . $role] ?? null) : null) + ?? $this->artwork[$role] ?? null; + } +} diff --git a/src/UI/Presentation/MenuRowValue.php b/src/UI/Presentation/MenuRowValue.php new file mode 100644 index 00000000..e7ad0cec --- /dev/null +++ b/src/UI/Presentation/MenuRowValue.php @@ -0,0 +1,23 @@ +getGlyph(), $color, $direction); + } +} diff --git a/src/UI/Presentation/MenuTextWrap.php b/src/UI/Presentation/MenuTextWrap.php new file mode 100644 index 00000000..ae30ccb8 --- /dev/null +++ b/src/UI/Presentation/MenuTextWrap.php @@ -0,0 +1,50 @@ + Complete text, with whitespace retained at wrap boundaries. */ + public static function lines(string $text, int $cells): array + { + if ($cells < 1 || !mb_check_encoding($text, 'UTF-8')) { + throw new InvalidArgumentException('Menu text requires UTF-8 and a positive cell width.'); + } + $lines = []; + $text = str_replace(["\r\n", "\r", "\t"], ["\n", "\n", ' '], $text); + foreach (explode("\n", $text) as $paragraph) { + // Nonbreaking spaces remain inside their word. Hyphens do not introduce a break. + preg_match_all('/[^\S\x{00A0}\x{202F}]+|[\S\x{00A0}\x{202F}]+/u', $paragraph, $matches); + $line = ''; + $width = 0; + foreach ($matches[0] as $token) { + $length = mb_strlen($token, 'UTF-8'); + $whitespace = preg_match('/\A[^\S\x{00A0}\x{202F}]+\z/u', $token) === 1; + if (!$whitespace && $width > 0 && $width + $length > $cells + && ($length <= $cells || preg_match('/\S/u', $line) === 1)) { + $lines[] = $line; + $line = ''; + $width = 0; + } + for ($offset = 0; $offset < $length;) { + if ($width === $cells) { + $lines[] = $line; + $line = ''; + $width = 0; + } + $take = min($cells - $width, $length - $offset); + $line .= mb_substr($token, $offset, $take, 'UTF-8'); + $width += $take; + $offset += $take; + } + } + $lines[] = $line; + } + return $lines; + } +} diff --git a/src/UI/Presentation/MenuThemeMetrics.php b/src/UI/Presentation/MenuThemeMetrics.php new file mode 100644 index 00000000..de0dabf4 --- /dev/null +++ b/src/UI/Presentation/MenuThemeMetrics.php @@ -0,0 +1,42 @@ + 32 || $cellHeight < 8 || $cellHeight > 64 + || $rowHeight < $cellHeight + 1 || $rowHeight > 128 || $panelPadding < 0 || $panelPadding > 64 + || $sectionGap < 0 || $sectionGap > 64 || $portraitSize < 16 || $portraitSize > 192) { + throw new InvalidArgumentException('Menu typography and spacing must fit the supported presentation bounds.'); + } + if ($sliderFillHeight < 1 || $sliderFillHeight > $sliderTrackHeight + || $sliderTrackHeight > $sliderThumbSize || $sliderThumbSize > 64) { + throw new InvalidArgumentException('Slider metrics require 1 <= fill height <= track height <= thumb size <= 64.'); + } + if ($scrollbarTrackWidth < 1 || $scrollbarTrackWidth > $scrollbarWidth || $scrollbarWidth > 64 + || $scrollbarMinThumbHeight < 1 || $scrollbarMinThumbHeight > 128 + || $scrollbarArrowGap < 0 || $scrollbarArrowGap > 32) { + throw new InvalidArgumentException('Scrollbar metrics require a bounded rail, track, minimum thumb and arrow gap.'); + } + } +} diff --git a/src/UI/Presentation/QuestJournalPresentation.php b/src/UI/Presentation/QuestJournalPresentation.php new file mode 100644 index 00000000..047e831d --- /dev/null +++ b/src/UI/Presentation/QuestJournalPresentation.php @@ -0,0 +1,169 @@ +width / $theme->metrics->cellWidth), (int)floor($text->height / $theme->metrics->cellHeight)]; + } + + public static function compose(JournalMenuContent $content, MenuPresentationCatalog $theme, TextPage $page, + float $time = 0, int $width = 1350, int $height = 720): PresentationCanvas + { + $g = self::getGeometry($content, $theme, $width, $height); + [$columns, $rows] = self::pageSize($content, $theme, $width, $height); + if ($page->columns !== $columns || $page->rows !== $rows || $page->source !== $content->document?->text) { + throw new RuntimeException('Journal text page must match the measured document viewport.'); + } + $view = new MenuCanvas($theme, $width, $height, $time); + $controls = new MenuControls($theme); + $m = $theme->metrics; + $p = $m->panelPadding; + foreach (['header', 'list', 'detail'] as $role) { + $view->frame('journal-' . $role, $g[$role], $role === 'header' ? 'quiet' : 'panel'); + } + if ($content->detailsOpen) { + $view->surface('journal-reading-focus', new CanvasRectangle($g['detail']->x + 8, $g['detail']->y + 12, + 2, $g['detail']->height - 24), 'focus', 22); + } + $iconWidth = $g['gutter']; + $view->icon('journal-heading-icon', 'journal.quests', new CanvasRectangle($g['header']->x + $p, + $g['header']->y + $p, $iconWidth - 4, $m->rowHeight)); + $titleWidth = $g['list']->width - 2 * $p - $iconWidth; + $view->prose('journal-title', $content->title, new CanvasRectangle($g['header']->x + $p + $iconWidth, + $g['header']->y + ($g['header']->height - $m->cellHeight) / 2, $titleWidth, $m->cellHeight), 'accent'); + $tabWidth = ($g['detail']->width - 2 * $p) / max(1, count($content->tabs)); + foreach ($content->tabs as $i => $tab) { + $view->rows('journal-tab', [new MenuRow((string)$i, $tab, kind: MenuRowKind::BUTTON, + selected: $i === $content->tabIndex)], new MenuRowLayout(new CanvasRectangle($g['detail']->x + $p + $tabWidth * $i, + $g['header']->y + $p, $tabWidth, $g['header']->height - 2 * $p), rowHeight: $m->rowHeight, + cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true)); + } + $list = self::inset($g['list'], $p); + if ($content->rows === []) { + $view->prose('journal-empty', $content->emptyText, $list, 'disabled'); + return $view->finish(); + } + $rowHeight = $m->rowHeight + $m->cellHeight; + [$first, $last] = $view->visibleRange('journal-entry', array_fill(0, count($content->rows), $rowHeight), $list, $content->index); + foreach (array_slice($content->rows, $first, $last - $first + 1, true) as $index => $row) { + $record = new CanvasRectangle($list->x, $list->y + ($index - $first) * $rowHeight, $list->width, $rowHeight); + $layout = new MenuRowLayout(new CanvasRectangle($record->x, $record->y, $record->width, $m->rowHeight), + rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight); + $icon = isset($theme->icons?->icons[$row['icon'] ?? '']) ? $row['icon'] : null; + $iconCells = $icon !== null ? (int)ceil($theme->rows->metrics->iconWidth / $m->cellWidth) + $theme->rows->metrics->gapCells : 0; + $cells = $layout->textCells($theme->rows->metrics) - $iconCells; + $view->record('journal-entry-' . $index, new MenuRow((string)$index, TextViewport::preview($row['label'], $cells), icon: $icon, + selected: $index === $content->index, focused: $index === $content->index && !$content->detailsOpen), $layout, $record); + $view->prose('journal-entry-' . $index . '-progress', TextViewport::preview($row['values'][1] ?? '', $cells), + new CanvasRectangle($record->x + $theme->rows->metrics->padding + $iconCells * $m->cellWidth, + $record->y + $m->rowHeight - 2, $cells * $m->cellWidth, $m->cellHeight), 'disabled'); + } + self::document($view, $content->document, $page, $g); + if ($page->total > $page->rows) { + $controls->renderScrollbar('journal-scroll', $g['scroll'], $page->first, $page->rows, $page->total); + } + if ($theme->showInputHints) { + $view->hints('journal-hints', [ActionHints::resolve('confirm', 'Read'), ActionHints::resolve('cancel', 'Back')], $g['hints']); + } + return MenuCanvas::overlay($view->finish(), $controls->finish($width, $height), $theme); + } + + private static function document(MenuCanvas $view, JournalDocument $document, TextPage $page, array $g): void + { + $m = $view->theme->metrics; + $styles = array_slice($document->lines($page->columns), $page->first, $page->rows); + $sections = []; + foreach ($styles as $index => $line) { + if ($line['section'] !== null) { $sections[$line['section']][] = $index; } + } + // Each visible section has its own bounded surface. Scrolling never puts text under a frame edge. + foreach ($sections as $section => $indexes) { + $top = $g['text']->y + min($indexes) * $m->cellHeight; + $bottom = $g['text']->y + (max($indexes) + 1) * $m->cellHeight; + $view->surface('journal-section-' . $section, new CanvasRectangle($g['sectionX'], $top, + $g['sectionWidth'], $bottom - $top), 'background', 18); + } + $markers = []; + foreach ($styles as $index => $line) { + $y = $g['text']->y + $index * $m->cellHeight; + $fallback = ''; + if ($line['heading']) { + $view->surface('journal-section-heading-' . $index, new CanvasRectangle($g['sectionX'], $y, + $g['sectionWidth'], $m->cellHeight), 'selected', 19); + } + if ($line['icon'] !== null) { + $iconBox = new CanvasRectangle($g['sectionX'] + 2, $y + 1, $g['gutter'] - 6, $m->cellHeight - 2); + if (!$view->icon('journal-icon-' . $index, $line['icon'], $iconBox)) { + $fallback = match ($line['icon']) { 'objective.complete' => "\u{2713}", 'objective.open' => "\u{25CB}", default => '' }; + } + } + $markers[] = ['text' => $fallback, 'color' => $line['color']]; + } + $view->textLines('journal-text', array_map(fn($line) => ['text' => $line['text'], 'color' => $line['color']], $styles), $g['text']); + $view->textLines('journal-markers', $markers, new CanvasRectangle($g['sectionX'] + 2, $g['text']->y, + $g['gutter'] - 6, $g['text']->height)); + } + + private static function getGeometry(JournalMenuContent $content, MenuPresentationCatalog $theme, int $width, int $height): array + { + $m = $theme->metrics; + $p = $m->panelPadding; + $gap = $m->sectionGap; + $w = min(1100, $width - 20); + $h = min(700, $height - 20); + $x = ($width - $w) / 2; + $y = ($height - $h) / 2; + $leftWidth = floor($w * 0.38); + $rightX = $x + $leftWidth + $gap; + $rightWidth = $w - $leftWidth - $gap; + $gutter = max(28, (int)ceil($theme->rows->metrics->iconWidth + 8)); + $headerHeight = max(80, 2 * $p + $m->rowHeight); + $tabWidth = ($rightWidth - 2 * $p) / max(1, count($content->tabs)); + if ($tabWidth > 2 * $theme->rows->metrics->padding + $m->cellWidth) { + $tabLayout = new MenuRowLayout(new CanvasRectangle(0, 0, $tabWidth, 1), rowHeight: $m->rowHeight, + cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true); + foreach ($content->tabs as $index => $label) { + $headerHeight = max($headerHeight, 2 * $p + $tabLayout->heightFor(new MenuRow((string)$index, $label, + kind: MenuRowKind::BUTTON), $theme->rows->metrics, false)); + } + } + $hintHeight = $theme->showInputHints ? MenuActionHints::height([ActionHints::resolve('confirm', 'Read'), + ActionHints::resolve('cancel', 'Back')], $theme, $rightWidth - 2 * $p) + $gap : 0; + $textHeight = floor(($h - $headerHeight - 2 * $p - $hintHeight) / $m->cellHeight) * $m->cellHeight; + $scrollSpace = $m->scrollbarWidth + $gap; + $textWidth = $rightWidth - 2 * $p - $gutter - $scrollSpace; + if ($textHeight < 3 * $m->cellHeight || $textWidth < 8 * $m->cellWidth || $leftWidth < 2 * $p + 120) { + throw new RuntimeException('Journal theme leaves no readable list and detail viewport; terminal presentation retained.'); + } + $textY = $y + $headerHeight + $p; + return ['header' => new CanvasRectangle($x, $y, $w, $headerHeight), + 'list' => new CanvasRectangle($x, $y + $headerHeight, $leftWidth, $h - $headerHeight), + 'detail' => new CanvasRectangle($rightX, $y + $headerHeight, $rightWidth, $h - $headerHeight), + 'text' => new CanvasRectangle($rightX + $p + $gutter, $textY, $textWidth, $textHeight), + 'scroll' => new CanvasRectangle($rightX + $rightWidth - $p - $m->scrollbarWidth, + $textY, $m->scrollbarWidth, $textHeight), + 'hints' => new CanvasRectangle($rightX + $p, $y + $h - $p - max(1, $hintHeight - $gap), + $rightWidth - 2 * $p, max(1, $hintHeight - $gap)), + 'sectionX' => $rightX + $p, 'sectionWidth' => $rightWidth - 2 * $p - $scrollSpace, 'gutter' => $gutter]; + } + + private static function inset(CanvasRectangle $box, int $p): CanvasRectangle + { + return new CanvasRectangle($box->x + $p, $box->y + $p, $box->width - 2 * $p, $box->height - 2 * $p); + } +} diff --git a/src/UI/Presentation/SaveLoadMenuPresentation.php b/src/UI/Presentation/SaveLoadMenuPresentation.php new file mode 100644 index 00000000..d16a551f --- /dev/null +++ b/src/UI/Presentation/SaveLoadMenuPresentation.php @@ -0,0 +1,96 @@ + $slots */ + public static function compose(array $slots, int $activeIndex, MenuPresentationCatalog $theme, + MenuInfoText $info, ?string $status = null, float $time = 0, string $title = 'Continue', + string $prompt = 'Choose a save file to continue from.', string $statusColor = 'decrease'): PresentationCanvas + { + $view = new MenuCanvas($theme, time: $time); + $box = MenuLayout::getBounds(); + $m = $theme->metrics; + $p = $m->panelPadding; + $gap = max(1, $m->sectionGap / 2); + $headerHeight = max(88, 2 * $p + 2 * $m->cellHeight); + $footerHeight = $headerHeight; + $list = new CanvasRectangle($box->x, $box->y + $headerHeight, $box->width, + $box->height - $headerHeight - $footerHeight); + $header = new CanvasRectangle($box->x, $box->y, $box->width, $headerHeight); + $view->frame('save-prompt', $header, 'quiet'); + $view->prose('save-title', $title, new CanvasRectangle($box->x + $p, $box->y + $p, + $box->width - 2 * $p, $m->cellHeight), 'accent'); + $view->prose('save-prompt-text', $prompt, new CanvasRectangle($box->x + $p, $box->y + $p + $m->cellHeight, + $box->width - 2 * $p, $m->cellHeight)); + $heights = []; + $left = $box->x + $p + $theme->rows->metrics->padding; + $textWidth = $box->width - 2 * ($p + $theme->rows->metrics->padding); + foreach ($slots as $slot) { + $locationHeight = count(MenuCanvas::wrap($slot->isEmpty ? 'Empty File' : $slot->locationName, + (int)floor($textWidth / $m->cellWidth))) * $m->cellHeight; + $details = self::getDetailText($slot); + $detailWidth = $slot->isLoadable ? $textWidth * 0.7 : $textWidth; + $detailHeight = $details === '' ? 0 : count(MenuCanvas::wrap($details, (int)floor($detailWidth / $m->cellWidth))) * $m->cellHeight; + $heights[] = max(100, $m->cellHeight + $locationHeight + $detailHeight + $p) + $gap; + } + if ($heights !== []) { $heights[array_key_last($heights)] -= $gap; } + [$first, $last] = $slots === [] ? [0, -1] : $view->visibleRange('save-slots', $heights, $list, $activeIndex, + new CanvasRectangle($box->x + $box->width / 2, $box->y + $p, $box->width / 2 - $p, $m->cellHeight)); + $y = $list->y; + for ($index = $first; $index <= $last; $index++) { + $slot = $slots[$index]; + $id = 'save-slot-' . $slot->slot; + $card = new CanvasRectangle($list->x, $y, $list->width, + $heights[$index] - ($index === array_key_last($slots) ? 0 : $gap)); + $view->frame($id . '-frame', $card, borderLayer: 20); + $edges = ($theme->frames['panel'] ?? null)?->borderInsets($theme->assetRoot, $card) ?? [4, 4, 4, 4]; + $treatment = new CanvasRectangle($card->x + $edges[0], $card->y + $edges[1], + $card->width - $edges[0] - $edges[2], $card->height - $edges[1] - $edges[3]); + $view->prose($id . '-title', 'File ' . $slot->slot, + new CanvasRectangle($card->x + $p, $card->y + 4, $card->width - 2 * $p, $m->cellHeight), 'accent'); + $identity = new MenuRow('location', $slot->isEmpty ? 'Empty File' : $slot->locationName, + selected: $index === $activeIndex, focused: $index === $activeIndex); + $identityBox = new CanvasRectangle($card->x + $p, $y + $m->cellHeight + 4, + $card->width - 2 * $p, $card->height - $m->cellHeight - 4); + $layout = new MenuRowLayout($identityBox, rowHeight: (int)ceil(max($m->cellHeight + 1, $theme->rows->metrics->cursorHeight)), + cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true); + $view->record($id, $identity, $layout, $treatment); + $detailY = $identityBox->y + $layout->heightFor($identity, $theme->rows->metrics, false); + if (!$slot->isEmpty) { + $view->prose($id . '-detail', self::getDetailText($slot), + new CanvasRectangle($left, $detailY, $slot->isLoadable ? $textWidth * 0.7 : $textWidth, + $card->y + $card->height - $detailY - 4), $slot->isLoadable ? 'text' : 'decrease'); + if ($slot->isLoadable) { + $seconds = max(0, $slot->playTimeSeconds); + $duration = sprintf('%02d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60); + $view->prose($id . '-duration', $duration, new CanvasRectangle($left + $textWidth * 0.7, $detailY, + $textWidth * 0.3, $m->cellHeight), alignment: HorizontalAlignment::RIGHT); + } + } + $y += $heights[$index]; + } + $footer = new CanvasRectangle($box->x, $box->y + $box->height - $footerHeight, $box->width, $footerHeight); + $view->frame('save-info', $footer, 'quiet'); + MenuInfoPanel::renderContent($view, 'save-info', new CanvasRectangle($footer->x + $p, $footer->y + $p, + $footer->width - 2 * $p, 2 * $m->cellHeight), $status === null ? 'Choose a file.' : '', $status, + $statusColor, $info, + new CanvasRectangle($footer->x + $footer->width / 2, $footer->y, $footer->width / 2 - $p, $m->cellHeight)); + return $view->finish(); + } + + private static function getDetailText(SaveSlot $slot): string + { + return $slot->isEmpty ? '' : ($slot->isLoadable ? $slot->getLeaderSummary() : ($slot->statusMessage ?? 'Cannot be loaded.')); + } +} diff --git a/src/UI/Presentation/SettingsMenuContent.php b/src/UI/Presentation/SettingsMenuContent.php new file mode 100644 index 00000000..8cae823a --- /dev/null +++ b/src/UI/Presentation/SettingsMenuContent.php @@ -0,0 +1,29 @@ + $settings @param list $choiceIndices */ + public function __construct(public string $title, public array $settings, public array $choiceIndices, + public int $activeIndex, public MenuInfoText $info, public ?string $status = null, + public bool $statusError = false, public string $backLabel = 'Cancel', public bool $backFocused = false, + public ?\Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasRectangle $bounds = null) + { + if (!array_is_list($settings) || !array_is_list($choiceIndices) || count($settings) !== count($choiceIndices)) { + throw new InvalidArgumentException('Settings presentation requires matching ordered settings and choice indices.'); + } + foreach ($settings as $index => $setting) { + if (!$setting instanceof GameSetting || !is_int($choiceIndices[$index]) + || $choiceIndices[$index] < 0 || $choiceIndices[$index] >= count($setting->choices)) { + throw new InvalidArgumentException('Settings presentation requires existing settings and valid choices.'); + } + } + } +} diff --git a/src/UI/Presentation/SettingsMenuPresentation.php b/src/UI/Presentation/SettingsMenuPresentation.php new file mode 100644 index 00000000..c58c49b0 --- /dev/null +++ b/src/UI/Presentation/SettingsMenuPresentation.php @@ -0,0 +1,134 @@ +metrics; + $p = $m->panelPadding; + $gap = $m->sectionGap; + $box = $menu->bounds ?? MenuLayout::getBounds($width, $height); + $box->assertWithin($width, $height); + $x = $box->x + $p; + $innerWidth = $box->width - 2 * $p; + $setting = $menu->settings[$menu->activeIndex] ?? null; + $description = $setting?->description ?? ($menu->backFocused ? 'Return to the previous menu.' : 'No settings available.'); + $status = $menu->status ?? ''; + $detailHeight = 3 * $m->cellHeight + $gap; + $cancelWidth = MenuLayout::getButtonWidth($theme, $menu->backLabel, $innerWidth); + $hints = [ActionHints::resolve('confirm', 'Next'), ActionHints::resolve('cancel', 'Cancel')]; + $hintWidth = $innerWidth - $cancelWidth - $gap; + $hintHeight = MenuActionHints::height($hints, $theme, $hintWidth); + $footerHeight = max($m->rowHeight, $hintHeight); + $listTop = $box->y + $p + $m->rowHeight + 2 * $gap; + $detailTop = $box->y + $box->height - $p - $footerHeight - $gap - $detailHeight; + $listHeight = $detailTop - $gap - $listTop; + if ($listHeight < $m->rowHeight + $m->cellHeight) { + throw new RuntimeException('Config description/status leaves no complete setting row in its finite viewport; terminal presentation retained.'); + } + $view = new MenuCanvas($theme, $width, $height, $time); + $valuesView = new MenuCanvas($theme, $width, $height, $time); + $controls = new MenuControls($theme); + $view->frame('config-frame', $box); + $view->prose('config-title', $menu->title, new CanvasRectangle($x, $box->y + $p, $innerWidth, $m->rowHeight), + alignment: HorizontalAlignment::CENTER); + $controls->renderDivider('config-divider', new CanvasRectangle($x, $listTop - 2 * $gap, $innerWidth, max(1, $gap))); + $settings = $menu->settings; + $rail = $m->cellHeight; + $listBox = new CanvasRectangle($x, $listTop, $innerWidth - $m->scrollbarWidth - $gap, $listHeight); + $valueCells = max(1, (int)floor(($listBox->width - 2 * $theme->rows->metrics->padding) * 0.44 / $m->cellWidth)); + $layout = new MenuRowLayout($listBox, [new MenuRowColumn($valueCells, HorizontalAlignment::CENTER)], + $m->rowHeight, $m->cellWidth, $m->cellHeight, true); + $layout->assertFits($theme->rows->metrics); + $rows = $heights = $values = []; + $index = $menu->activeIndex; + foreach ($settings as $position => $entry) { + $choices = array_keys($entry->choices); + $choiceIndex = $menu->choiceIndices[$position]; + $value = (string)($choices[$choiceIndex] ?? ''); + $valueWidth = $valueCells * $m->cellWidth - 2 * ($rail + $gap); + if ($valueWidth < $m->cellWidth) { throw new RuntimeException('Config values require a wider finite viewport.'); } + $valueHeight = count(MenuCanvas::wrap($value, (int)floor($valueWidth / $m->cellWidth))) * $m->cellHeight; + $rows[] = new MenuRow((string)$position, $entry->label, [new MenuRowValue('')], + selected: $position === $index, focused: $position === $index); + $heights[] = max($layout->heightFor($rows[$position], $theme->rows->metrics, false), + $m->rowHeight + $valueHeight - $m->cellHeight); + $values[] = [$value, $choiceIndex, $valueHeight]; + } + $rangeBox = new CanvasRectangle($x + $innerWidth * 2 / 3, $box->y + $p, $innerWidth / 3, $m->cellHeight); + [$first, $last] = $rows === [] ? [0, -1] : $view->visibleRange('config-settings', $heights, $listBox, $index, $rangeBox); + if (array_sum($heights) <= $listHeight) { + $view->prose('config-settings-range', $rows === [] ? '0 / 0' : '1-' . count($rows) . ' / ' . count($rows), + $rangeBox, 'disabled', HorizontalAlignment::RIGHT); + } + $accent = SgrColorParser::parse(SelectionStyle::resolveColor()->value . ' ')['foreground'] ?? $theme->colors['accent']; + $y = $listTop; + for ($i = $first; $i <= $last; $i++) { + $bounds = new CanvasRectangle($x, $y, $listBox->width, $heights[$i]); + $labelGrowth = $layout->heightFor($rows[$i], $theme->rows->metrics, false) - $m->rowHeight; + $view->rows('config-setting', [$rows[$i]], new MenuRowLayout($bounds, $layout->columns, + $heights[$i] - $labelGrowth, $m->cellWidth, $m->cellHeight, true)); + [$value, $choiceIndex, $valueHeight] = $values[$i]; + $entry = $settings[$i]; + $valueX = $bounds->x + $bounds->width - $theme->rows->metrics->padding - $valueCells * $m->cellWidth; + $arrowY = $y + ($heights[$i] - $rail) / 2; + $controls->renderChevron('config-previous-' . $i, 'previous', new CanvasRectangle($valueX, $arrowY, $rail, $rail), + count($entry->choices) > 1 && ($entry->wraps || $choiceIndex > 0)); + $controls->renderChevron('config-next-' . $i, 'next', new CanvasRectangle($valueX + $valueCells * $m->cellWidth - $rail, $arrowY, $rail, $rail), + count($entry->choices) > 1 && ($entry->wraps || $choiceIndex < count($entry->choices) - 1)); + $valueBox = new CanvasRectangle($valueX + $rail + $gap, $y + ($heights[$i] - $valueHeight) / 2, + $valueCells * $m->cellWidth - 2 * ($rail + $gap), $valueHeight); + $numeric = !$entry->wraps && count($entry->choices) > 1 + && array_all($entry->choices, static fn($choice) => is_int($choice) || is_float($choice)); + $numericWidth = mb_strlen($value) * $m->cellWidth; + if ($numeric && $valueBox->width - $numericWidth - $gap > 2 * $rail) { + $controls->renderLevel('config-level-' . $i, new CanvasRectangle($valueBox->x, $arrowY, + $valueBox->width - $numericWidth - $gap, $rail), $choiceIndex / (count($entry->choices) - 1), $accent); + $valueBox = new CanvasRectangle($valueBox->x + $valueBox->width - $numericWidth, $valueBox->y, $numericWidth, $valueHeight); + } + $valuesView->prose('config-value-' . $i, $value, $valueBox, alignment: HorizontalAlignment::CENTER); + if ($i === $index && $theme->rows->metrics->accentWidth > 0) { + $edge = $theme->rows->metrics->focusWidth; + $controls->fill('config-selection-accent', new CanvasRectangle($x + $edge, $y + $edge, + $theme->rows->metrics->accentWidth, $heights[$i] - 2 * $edge), $accent); + } + $y += $heights[$i]; + } + if ($last + 1 < count($rows) || $first > 0) { + $controls->renderScrollbar('config-scroll', + new CanvasRectangle($x + $innerWidth - $m->scrollbarWidth, $listTop, $m->scrollbarWidth, $listHeight), + array_sum(array_slice($heights, 0, $first)), $y - $listTop, array_sum($heights)); + } + $controls->renderDivider('config-description-divider', new CanvasRectangle($x, $detailTop - $gap, $innerWidth, max(1, $gap / 2))); + $view->prose('config-description-title', 'Description', new CanvasRectangle($x, $detailTop, $innerWidth, $m->cellHeight), 'accent'); + MenuInfoPanel::renderContent($view, 'config', + new CanvasRectangle($x, $detailTop + $m->cellHeight + $gap, $innerWidth, 2 * $m->cellHeight), + $description, $status, $menu->statusError ? 'decrease' : 'disabled', $menu->info, + new CanvasRectangle($x + $innerWidth / 2, $detailTop, $innerWidth / 2, $m->cellHeight)); + $footerY = $box->y + $box->height - $p - $footerHeight; + if ($hintHeight > 0) { + $view->hints('config-hints', $hints, new CanvasRectangle($x, $footerY, $hintWidth, $hintHeight)); + } + $view->rows('config', [new MenuRow('cancel', $menu->backLabel, kind: MenuRowKind::BUTTON, + selected: $menu->backFocused, focused: $menu->backFocused)], + new MenuRowLayout(new CanvasRectangle($x + $innerWidth - $cancelWidth, $footerY, $cancelWidth, $m->rowHeight), + rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight)); + // Value text and controls sit above the shared row's selection artwork, not behind it. + $frame = MenuCanvas::overlay($view->finish(), $valuesView->finish(), $theme); + return MenuCanvas::overlay($frame, $controls->finish($width, $height), $theme); + } +} diff --git a/src/UI/Presentation/SkillMenuContent.php b/src/UI/Presentation/SkillMenuContent.php new file mode 100644 index 00000000..21261d85 --- /dev/null +++ b/src/UI/Presentation/SkillMenuContent.php @@ -0,0 +1,37 @@ + $tabs + * @param array $summary + * @param array $fields + * @param list $rows + */ + public function __construct( + public Character $character, + public string $title, + public array $tabs, + public int $tabIndex, + public array $summary, + public string $detailTitle, + public array $fields, + public string $detailText, + public array $rows, + public string $listTitle, + public int $index, + public string $emptyText, + public string $description, + public ?string $status, + public string $confirmLabel, + public bool $targeting = false, + public ?MenuInfoText $infoModel = null, + ) {} +} diff --git a/src/UI/Presentation/SkillMenuPresentation.php b/src/UI/Presentation/SkillMenuPresentation.php new file mode 100644 index 00000000..b4f2cc0b --- /dev/null +++ b/src/UI/Presentation/SkillMenuPresentation.php @@ -0,0 +1,158 @@ +metrics; + $p = $m->panelPadding; + $gap = $m->sectionGap; + $host = MenuLayout::getBounds($width, $height); + $innerWidth = $host->width - 2 * $p; + $actor = $content->character; + $portrait = isset($theme->portraits[$actor->actorId]) ? $m->portraitSize : 0; + $identityWidth = floor($innerWidth * 0.30); + $resourcesWidth = floor($innerWidth * 0.27); + $countsWidth = $innerWidth - $identityWidth - $resourcesWidth - 2 * $gap; + $nameWidth = $identityWidth - ($portrait > 0 ? $portrait + $gap : 0); + $identity = $content->title . "\n" . $actor->name . "\nRole: " . $actor->role->name; + $nameHeight = self::textHeight($identity, $nameWidth, $theme); + $stats = $actor->effectiveStats; + $resources = ['Lv' => (string)$actor->level, + 'HP' => number_format($stats->currentHp) . ' / ' . number_format($stats->totalHp), + 'MP' => number_format($stats->currentMp) . ' / ' . number_format($stats->totalMp)]; + $resourceHeight = self::fieldHeight($resources, $resourcesWidth, $theme); + $countHeight = self::fieldHeight($content->summary, $countsWidth, $theme); + $summaryHeight = max(140, 2 * $p + max($portrait, $nameHeight, $resourceHeight, $countHeight)); + $tabPadding = min($p, $gap); + $tabHeight = $m->rowHeight + 2 * $tabPadding; + $hints = [ActionHints::resolve('confirm', $content->confirmLabel), ActionHints::resolve('cancel', $content->targeting ? 'Back' : 'Cancel')]; + if (!$content->targeting) { + $hints[] = ActionHints::resolve('character_next', 'Next', KeyCode::TAB); + $hints[] = ActionHints::resolve('character_previous', 'Prev', KeyCode::SHIFT_TAB); + } + $hintHeight = MenuActionHints::height($hints, $theme, $innerWidth); + $infoHeight = MenuInfoPanel::getHeight($theme, $hints, $host->width); + $bodyHeight = $host->height - $summaryHeight - $tabHeight - $infoHeight; + if ($bodyHeight < 2 * $p + $m->cellHeight + $gap + $m->rowHeight + $m->cellHeight) { + throw new RuntimeException('Skill menu summary/description leaves no complete row in its finite viewport; terminal presentation retained.'); + } + $view = new MenuCanvas($theme, $width, $height, $time); + $summary = new CanvasRectangle($host->x, $host->y, $host->width, $summaryHeight); + $view->frame('skill-summary', $summary); + $x = $host->x + $p; + $y = $host->y + $p; + if ($portrait > 0) { $view->portrait($actor->actorId, new CanvasRectangle($x, $y, $portrait, $portrait), 'skill-portrait'); } + $view->prose('skill-identity', $identity, new CanvasRectangle($x + ($portrait > 0 ? $portrait + $gap : 0), $y, $nameWidth, $nameHeight)); + self::fields($view, 'skill-resource', $resources, new CanvasRectangle($x + $identityWidth + $gap, $y, $resourcesWidth, $resourceHeight)); + self::fields($view, 'skill-summary-field', $content->summary, + new CanvasRectangle($x + $identityWidth + $resourcesWidth + 2 * $gap, $y, $countsWidth, $countHeight)); + + $tabs = new CanvasRectangle($host->x, $host->y + $summaryHeight, $host->width, $tabHeight); + $view->frame('skill-tabs', $tabs, 'quiet'); + $tabWidth = ($innerWidth - (count($content->tabs) - 1) * $gap) / max(1, count($content->tabs)); + foreach ($content->tabs as $index => $label) { + $bounds = new CanvasRectangle($x + $index * ($tabWidth + $gap), $tabs->y + $tabPadding, $tabWidth, $m->rowHeight); + $view->rows('skill-tab', [new MenuRow((string)$index, $label, kind: MenuRowKind::BUTTON, selected: $index === $content->tabIndex)], + new MenuRowLayout($bounds, rowHeight: $m->rowHeight, cellWidth: $m->cellWidth, cellHeight: $m->cellHeight, wrapText: true)); + } + $detailWidth = $host->width * 380 / 1100; + $details = new CanvasRectangle($host->x, $tabs->y + $tabHeight, $detailWidth, $bodyHeight); + $list = new CanvasRectangle($details->x + $details->width, $details->y, $host->width - $detailWidth, $bodyHeight); + $view->frame('skill-details', $details); + $view->frame('skill-list', $list); + $detailY = $details->y + $p; + $detailInner = $details->width - 2 * $p; + $headingHeight = self::textHeight($content->detailTitle, $detailInner, $theme); + $fieldsHeight = self::fieldHeight($content->fields, $detailInner, $theme); + $detailTextHeight = self::textHeight($content->detailText, $detailInner, $theme); + $detailHeight = $headingHeight + $fieldsHeight + $detailTextHeight + + ($fieldsHeight > 0 ? $gap : 0) + ($detailTextHeight > 0 ? $gap : 0); + if ($detailHeight > $details->height - 2 * $p) { + throw new RuntimeException('Skill menu complete requirements/details exceed their finite viewport; terminal presentation retained.'); + } + $view->prose('skill-detail-title', $content->detailTitle, new CanvasRectangle($details->x + $p, $detailY, $detailInner, $headingHeight), 'accent'); + $detailY += $headingHeight; + if ($fieldsHeight > 0) { + $detailY += $gap; + self::fields($view, 'skill-detail-field', $content->fields, new CanvasRectangle($details->x + $p, $detailY, $detailInner, $fieldsHeight)); + $detailY += $fieldsHeight; + } + if ($detailTextHeight > 0) { + $view->prose('skill-requirements', $content->detailText, + new CanvasRectangle($details->x + $p, $detailY + $gap, $detailInner, $detailTextHeight)); + } + $listTitleHeight = self::textHeight($content->listTitle, $list->width - 2 * $p, $theme); + $view->prose('skill-list-title', $content->listTitle, + new CanvasRectangle($list->x + $p, $list->y + $p, $list->width - 2 * $p, $listTitleHeight), 'accent'); + $listBounds = new CanvasRectangle($list->x + $p, $list->y + $p + $listTitleHeight + $gap, + $list->width - 2 * $p, $list->height - 2 * $p - $listTitleHeight - $gap); + if ($content->rows === []) { $view->prose('skill-empty', $content->emptyText, $listBounds, 'disabled'); } + else { + $sizes = []; + foreach ($content->rows as $row) { + foreach ($row->values as $index => $value) { $sizes[$index] = max($sizes[$index] ?? 1, mb_strlen($value->text)); } + } + $available = (int)floor(($listBounds->width - 2 * $theme->rows->metrics->padding) / $m->cellWidth); + $cap = max(1, (int)floor($available * 0.60 / max(1, count($sizes)))); + $columns = array_map(fn($size) => new MenuRowColumn(min($size, $cap)), $sizes); + $view->rows('skill-entry', $content->rows, new MenuRowLayout($listBounds, $columns, + $m->rowHeight, $m->cellWidth, $m->cellHeight, true), $content->index); + } + $info = new CanvasRectangle($host->x, $host->y + $host->height - $infoHeight, $host->width, $infoHeight); + $view->frame('skill-info', $info, 'quiet'); + $infoY = $info->y + $p; + MenuInfoPanel::renderContent($view, 'skill', new CanvasRectangle($x, $infoY, $innerWidth, 2 * $m->cellHeight), + $content->description, $content->status, infoModel: $content->infoModel, + rangeBounds: new CanvasRectangle($x, $info->y + $info->height - $p, $innerWidth, $p)); + if ($hintHeight > 0) { $view->hints('skill-hints', $hints, + new CanvasRectangle($x, $infoY + 2 * $m->cellHeight + $gap, $innerWidth, $hintHeight)); } + return $view->finish(); + } + + private static function textHeight(string $text, float $width, MenuPresentationCatalog $theme): int + { + return $text === '' ? 0 : count(MenuCanvas::wrap($text, (int)floor($width / $theme->metrics->cellWidth))) * $theme->metrics->cellHeight; + } + + private static function fieldLayout(array $fields, CanvasRectangle $bounds, MenuPresentationCatalog $theme): MenuRowLayout + { + $m = $theme->metrics; + $metrics = $theme->rows->metrics; + $label = max(1, ...array_map('mb_strlen', array_keys($fields))); + $values = (int)floor(($bounds->width - 2 * $metrics->padding) / $m->cellWidth) - $label - $metrics->gapCells; + if ($values < 1) { throw new RuntimeException('Skill menu fields require a wider finite viewport.'); } + return new MenuRowLayout($bounds, [new MenuRowColumn($values)], $m->cellHeight + (int)ceil(max(1, $metrics->separatorWidth)), $m->cellWidth, $m->cellHeight, true); + } + + private static function fieldRows(array $fields): array + { + $rows = []; + foreach ($fields as $label => $value) { $rows[] = new MenuRow((string)count($rows), $label, [new MenuRowValue($value)]); } + return $rows; + } + + private static function fieldHeight(array $fields, float $width, MenuPresentationCatalog $theme): int + { + if ($fields === []) { return 0; } + $layout = self::fieldLayout($fields, new CanvasRectangle(0, 0, $width, 1), $theme); + return array_sum(array_map(fn($row) => $layout->heightFor($row, $theme->rows->metrics, false), self::fieldRows($fields))); + } + + private static function fields(MenuCanvas $view, string $id, array $fields, CanvasRectangle $bounds): void + { + $view->rows($id, self::fieldRows($fields), self::fieldLayout($fields, $bounds, $view->theme)); + } +} diff --git a/src/UI/Presentation/TitleAmbientPresentation.php b/src/UI/Presentation/TitleAmbientPresentation.php new file mode 100644 index 00000000..e99550fd --- /dev/null +++ b/src/UI/Presentation/TitleAmbientPresentation.php @@ -0,0 +1,27 @@ + */ + public static function getOperations(string $asset, array $effects, float $time, float $weight): array + { + V::validateKeys($effects, [], ['textureDrift', 'flowingWater', 'flame']); + $operations = []; + foreach ($effects as $kind => $data) { + $part = match ($kind) { + 'textureDrift' => TitleTextureMotion::getDriftOperations($asset, $data, $time, $weight), + 'flowingWater' => TitleTextureMotion::getWaterOperations($asset, $data, $time, $weight), + 'flame' => TitleFlameMotion::getOperations($data, $time, $weight), + }; + array_push($operations, ...$part); + } + return array_map(static fn($operation) => new CanvasCompositeOperation($operation), $operations); + } +} diff --git a/src/UI/Presentation/TitleFlameMotion.php b/src/UI/Presentation/TitleFlameMotion.php new file mode 100644 index 00000000..305f7f31 --- /dev/null +++ b/src/UI/Presentation/TitleFlameMotion.php @@ -0,0 +1,60 @@ + 0) { array_unshift($stops, [...$stops[0], 'offset' => 0]); } + $start = V::getPoint($gradient['start']); $start[1] += $height; + return [ + ['type' => 'fill', 'destination' => TitleMotionValues::getBounds([[$center[0] - $radius[0], $center[1] - $radius[1]], + [$center[0] + $radius[0], $center[1] + $radius[1]]]), 'blend' => 'screen', 'opacity' => $weight, + 'brush' => ['type' => 'radial', 'center' => $center, 'radius' => $radius, 'stops' => $stops]], + ['type' => 'fill', 'destination' => TitleMotionValues::getBounds($polygon), 'blend' => 'screen', 'opacity' => $weight, + 'brush' => ['type' => 'linear', 'start' => $start, 'end' => V::getPoint($gradient['end']), + 'stops' => TitleMotionValues::getStops($gradient['stops'], $intensity)], + 'masks' => [['type' => 'polygon', 'contours' => [$polygon]], + ['type' => 'polygon', 'contours' => [array_map(V::getPoint(...), V::getList($data['clip'], 3, 128))]]]], + ]; + } +} diff --git a/src/UI/Presentation/TitleGleamPresentation.php b/src/UI/Presentation/TitleGleamPresentation.php new file mode 100644 index 00000000..c40eb43f --- /dev/null +++ b/src/UI/Presentation/TitleGleamPresentation.php @@ -0,0 +1,48 @@ + $period) { throw new InvalidArgumentException('Title gleam duration must fit its period.'); } + $overscan = V::getNumber($data['overscan'], 0, 4096); + $band = TitleMotionValues::getPositive($data['bandWidth'], 4096); + $tilt = V::getNumber($data['tilt'], -1, 1); + $stops = TitleMotionValues::getStops($data['stops']); + $width = (int)ceil($logo->destination->width); $height = (int)ceil($logo->destination->height); + $rect = ['x' => 0, 'y' => 0, 'width' => $width, 'height' => $height]; + $contours = []; + foreach (V::getList($data['regions'], 1, 8) as $region) { + [$x, $y, $w, $h] = V::getList($region, 4, 4); + $normalized = V::getRectangle(['x' => $x, 'y' => $y, 'width' => $w, 'height' => $h], true); + $x = $normalized['x'] * $width; $y = $normalized['y'] * $height; + $w = $normalized['width'] * $width; $h = $normalized['height'] * $height; + $contours[] = [[$x, $y], [$x + $w, $y], [$x + $w, $y + $h], [$x, $y + $h]]; + } + $phase = fmod(fmod($time - $delay, $period) + $period, $period); + $x = -$overscan + min(1, $phase / $duration) * ($width + 2 * $overscan); + $backdrop = new CanvasCompositeOperation(['type' => 'image', 'asset' => $logo->asset, 'destination' => $rect]); + $sweep = new CanvasCompositeOperation(['type' => 'fill', 'destination' => $rect, 'blend' => 'screen', + 'brush' => ['type' => 'linear', 'start' => [$x - $band / 2, 0], 'end' => [$x + $band / 2, $height * $tilt], 'stops' => $stops], + 'masks' => [['type' => 'image_alpha', 'asset' => $logo->asset, 'destination' => $rect], + ['type' => 'polygon', 'contours' => $contours]]]); + if ($reduced || $phase >= $duration) { return null; } + return new CanvasComposite($logo->id, $width, $height, $logo->destination, + [$backdrop, $sweep], $logo->layer, $logo->opacity); + } +} diff --git a/src/UI/Presentation/TitleMenuPresentation.php b/src/UI/Presentation/TitleMenuPresentation.php new file mode 100644 index 00000000..dcdfe5b9 --- /dev/null +++ b/src/UI/Presentation/TitleMenuPresentation.php @@ -0,0 +1,84 @@ + $commands */ + public static function compose(TitlePresentationCatalog $catalog, TitlePlayback $playback, array $commands, + bool $showMenu = true): PresentationCanvas + { + $bounds = new CanvasRectangle(0, 0, 1350, 720); + $images = []; + $composites = []; + $mix = $playback->nightWeight; + foreach (['day' => 1.0, 'night' => $mix] as $name => $opacity) { + if ($opacity <= 0 || ($name === 'day' && $mix >= 1)) { continue; } + $scene = $catalog->scenes[$name]; + $layer = $name === 'day' ? 0 : 2; + $ambient = $name === 'day' ? max(0, 1 - 4 * $mix) : max(0, 4 * $mix - 3); + $weight = $ambient; + $effects = $scene['effects'] ?? []; + if (!$playback->reducedMotion && $effects !== [] && $ambient > 0) { + $operations = [new CanvasCompositeOperation(['type' => 'image', 'asset' => $scene['background'], 'destination' => $bounds->toArray()]), + ...TitleAmbientPresentation::getOperations($scene['background'], $effects, $playback->elapsed, + $name === 'night' ? $ambient / $opacity : $ambient)]; + $composites[] = new CanvasComposite('title-' . $name, 1350, 720, $bounds, $operations, $layer, $opacity); + } else { $images[] = new CanvasImage('title-' . $name, $scene['background'], $bounds, $layer, opacity: $opacity); } + foreach ($scene['sprites'] ?? [] as $id => $sprite) { + $image = TitleSpritePresentation::getImage($catalog->assetRoot, 'title-' . $name . '-' . $id, + $sprite, $playback->elapsed, $weight, $playback->reducedMotion); + if ($image !== null) { + $images[] = new CanvasImage($image->id, $image->asset, $image->destination, $layer + 1, + $image->sourceRect, $image->opacity, $image->clipRect); + } + } + } + CanvasImagePreflight::inspect($images, $catalog->assetRoot, $composites); + $base = new PresentationCanvas(1350, 720, $images, composites: $composites); + if (!$showMenu) { return $base; } + [$x, $y, $width] = $catalog->logoPlacement; + $size = PngAssetPreflight::inspect($catalog->assetRoot, $catalog->logo); + $height = $width * $size['height'] / $size['width']; + if ($height > $catalog->menu->y - $y) { + // Retain the authored safe region when a replacement logo changes aspect ratio. + $scale = ($catalog->menu->y - $y) / $height; + $x += ($width - $width * $scale) / 2; + $width *= $scale; $height *= $scale; + } + $opacity = $playback->entryOpacity; + $logo = new CanvasImage('title-logo', $catalog->logo, new CanvasRectangle($x, $y, $width, $height), 4, opacity: $opacity); + $gleam = $catalog->gleam === null ? null : TitleGleamPresentation::getComposite($logo, $catalog->gleam, $playback->elapsed, $playback->reducedMotion); + if ($gleam === null) { $images[] = $logo; } else { $composites[] = $gleam; } + $base = new PresentationCanvas(1350, 720, $images, composites: $composites); + $view = new MenuCanvas($catalog->theme); + $view->frame('title-panel', $catalog->menu); + [$bx, $by, $bw, $bh, $gap] = $catalog->buttons; + foreach ($commands as $index => $command) { + $top = $by + $index * ($bh + $gap); + if ($top + $bh > $catalog->menu->height) { throw new RuntimeException('Title commands exceed their authored panel.'); } + $view->rows('title', [$command], new MenuRowLayout(new CanvasRectangle($catalog->menu->x + $bx, + $catalog->menu->y + $top, $bw, $bh), rowHeight: $bh, + cellWidth: $catalog->theme->metrics->cellWidth, cellHeight: $catalog->theme->metrics->cellHeight)); + } + $ui = $view->finish(); + $ui = new PresentationCanvas(1350, 720, + array_map(static fn(CanvasImage $image) => new CanvasImage($image->id, $image->asset, $image->destination, + $image->layer, $image->sourceRect, $image->opacity * $opacity, $image->clipRect), $ui->images), + textLayers: array_map(static fn(CanvasTextLayer $text) => new CanvasTextLayer($text->id, $text->layer, + $text->x, $text->y, $text->grid, $text->runs, $text->clipRect, $text->opacity * $opacity, $text->glyphEffects), $ui->textLayers)); + return MenuCanvas::overlay($base, $ui, $catalog->theme); + } +} diff --git a/src/UI/Presentation/TitleMotionValues.php b/src/UI/Presentation/TitleMotionValues.php new file mode 100644 index 00000000..97a890c9 --- /dev/null +++ b/src/UI/Presentation/TitleMotionValues.php @@ -0,0 +1,56 @@ + is_int($value))) { throw new InvalidArgumentException('Title motion colors require RGB integers.'); } + return PresentationColor::rgb($parts[0], $parts[1], $parts[2])->toArray(); + } + + public static function getPositive(mixed $value, float $maximum = 16384): float + { + $number = V::getNumber($value, 0, $maximum); + if ($number <= 0) { throw new InvalidArgumentException('Title motion requires a positive value.'); } + return $number; + } + + public static function getWave(mixed $waves, float $time): float + { + $value = 0; + foreach (V::getList($waves, 0, 8) as $wave) { + [$amplitude, $frequency, $phase] = V::getList($wave, 3, 3); + $value += V::getNumber($amplitude, -128, 128) * sin($time * V::getNumber($frequency, 0, 100) + V::getNumber($phase, -100, 100)); + } + return $value; + } + + public static function getStops(mixed $stops, ?float $intensity = null): array + { + return array_map(static function ($stop) use ($intensity): array { + V::validateKeys($stop, ['offset', 'color', ...($intensity === null ? ['opacity'] : ['base', 'gain'])]); + $opacity = $intensity === null ? V::getNumber($stop['opacity'], 0, 1) + : max(0, min(1, V::getNumber($stop['base'], 0, 1) + $intensity * V::getNumber($stop['gain'], -1, 1))); + return ['offset' => V::getNumber($stop['offset'], 0, 1), 'color' => self::getColor($stop['color']), 'opacity' => $opacity]; + }, V::getList($stops, 2, 8)); + } + + public static function getBounds(array $points, float $padding = 0): array + { + $x = max(0, min(array_column($points, 0)) - $padding); + $y = max(0, min(array_column($points, 1)) - $padding); + return ['x' => $x, 'y' => $y, + 'width' => min(1350, max(array_column($points, 0)) + $padding) - $x, + 'height' => min(720, max(array_column($points, 1)) + $padding) - $y]; + } +} diff --git a/src/UI/Presentation/TitlePlayback.php b/src/UI/Presentation/TitlePlayback.php new file mode 100644 index 00000000..3f64330f --- /dev/null +++ b/src/UI/Presentation/TitlePlayback.php @@ -0,0 +1,71 @@ + $this->reducedMotion ? 1 : min(1, $this->elapsed / 0.28); } + + public function advance(float $now, ?DateTimeImmutable $localTime, bool $reducedMotion): void + { + if (!is_finite($now) || $now < 0 || ($this->lastTime !== null && $now < $this->lastTime)) { + throw new InvalidArgumentException('Title time must be finite, nonnegative and monotonic.'); + } + $first = !$this->initialized; + $delta = $this->lastTime === null ? 0 : min(0.1, $now - $this->lastTime); + $this->lastTime = $now; + $this->reducedMotion = $reducedMotion; + if ($this->obscured) { return; } + $this->initialized = true; + $this->elapsed += $delta; + if ($first || $now >= $this->nextClockCheck) { + $localTime ??= LocalClock::getTime(); + $hour = (int)$localTime->format('G'); + $target = $hour >= 6 && $hour < 18 ? 0.0 : 1.0; + if ($first) { $this->nightWeight = $this->target = $target; } + elseif ($target !== $this->target) { + $this->fadeFrom = $this->nightWeight; + $this->fadeStart = $this->elapsed; + $this->target = $target; + } + $boundary = $localTime->setTime($hour < 6 ? 6 : 18, 0); + if ($hour >= 18) { $boundary = $localTime->modify('+1 day')->setTime(6, 0); } + $this->nextClockCheck = $now + min(1, max(0.001, + (float)$boundary->format('U.u') - (float)$localTime->format('U.u'))); + } + $ratio = min(1, max(0, ($this->elapsed - $this->fadeStart) / ($reducedMotion ? 0.16 : 1))); + $ratio = $ratio * $ratio * (3 - 2 * $ratio); + if (!$first) { $this->nightWeight = $this->fadeFrom + ($this->target - $this->fadeFrom) * $ratio; } + else { $this->fadeFrom = $this->target; } + } + + public function setObscured(bool $obscured, float $now): void + { + if (!is_finite($now) || $now < 0 || ($this->lastTime !== null && $now < $this->lastTime)) { + throw new InvalidArgumentException('Title visibility time must be monotonic.'); + } + if ($this->obscured === $obscured) { return; } + $this->obscured = $obscured; + // No catch-up after a modal, destination, focus loss or scene suspension. + $this->lastTime = $now; + $this->nextClockCheck = 0; + } +} diff --git a/src/UI/Presentation/TitlePresentationCatalog.php b/src/UI/Presentation/TitlePresentationCatalog.php new file mode 100644 index 00000000..e940d943 --- /dev/null +++ b/src/UI/Presentation/TitlePresentationCatalog.php @@ -0,0 +1,102 @@ +theme = new MenuPresentationCatalog($assetRoot, $data['theme'] ?? []); + $this->logo = $data['logo'] ?? ''; + PngAssetPreflight::inspect($assetRoot, $this->logo); + $this->logoPlacement = self::getNumbers($data['logoPlacement'] ?? [409, 16, 532], 3); + [$x, $y, $width] = $this->logoPlacement; + if ($x < 0 || $y < 0 || $width <= 0 || $x + $width > 1350 || $y >= 720) { + throw new InvalidArgumentException('Title logo registration must fit the logical canvas.'); + } + $this->menu = new CanvasRectangle(...self::getNumbers($data['menu'] ?? [507, 374, 336, 292], 4)); + $this->menu->assertWithin(1350, 720); + if ($y >= $this->menu->y) { throw new InvalidArgumentException('Title logo must have room above its menu.'); } + $this->buttons = self::getNumbers($data['buttons'] ?? [24, 20, 288, 44, 8], 5); + [$bx, $by, $bw, $bh, $gap] = $this->buttons; + if (min($bx, $by, $gap) < 0 || min($bw, $bh) <= 0 || $bx + $bw > $this->menu->width + || $by + $bh > $this->menu->height) { + throw new InvalidArgumentException('Title buttons must fit their panel.'); + } + $scenes = $data['scenes'] ?? []; + if (!is_array($scenes) || count($scenes) !== 2 || !isset($scenes['day'], $scenes['night'])) { + throw new InvalidArgumentException('Title requires day and night scene roles.'); + } + foreach ($scenes as $scene) { + if (!is_array($scene) || array_diff(array_keys($scene), ['background', 'sprites', 'effects']) !== []) { + throw new InvalidArgumentException('Title scenery requires a background and optional sprite registrations.'); + } + PngAssetPreflight::inspect($assetRoot, $scene['background'] ?? ''); + TitleAmbientPresentation::getOperations($scene['background'], $scene['effects'] ?? [], 0, 1); + if (!is_array($scene['sprites'] ?? [])) { throw new InvalidArgumentException('Title sprites must be an array.'); } + if (count($scene['sprites'] ?? []) > 32) { throw new InvalidArgumentException('Title supports at most 32 sprite subjects per scene.'); } + foreach ($scene['sprites'] ?? [] as $id => $sprite) { + TitleSpritePresentation::validate($assetRoot, (string)$id, $sprite); + } + } + $this->scenes = $scenes; + $this->gleam = $data['gleam'] ?? null; + if ($this->gleam !== null) { + TitleGleamPresentation::getComposite(new \Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage('logo', + $this->logo, new CanvasRectangle(0, 0, $width, min(720, $width))), $this->gleam, 0, true); + } + } + + public static function getRequestedCapabilities(string $root): array + { + return file_exists($root . '/' . self::FILE) || is_link($root . '/' . self::FILE) + ? self::CAPABILITIES : []; + } + + public static function load(string $root): ?self + { + if (!file_exists($root . '/' . self::FILE) && !is_link($root . '/' . self::FILE)) { return null; } + $root = realpath($root); + $path = $root === false ? false : realpath($root . '/' . self::FILE); + if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) + || !is_file($path) || !is_readable($path)) { + throw new RuntimeException('Title presentation must be a readable file inside assets.'); + } + $data = (static fn(string $file): mixed => require $file)($path); + if (!is_array($data)) { throw new RuntimeException('Title presentation must return a plain array.'); } + return new self($root, $data); + } + + public static function getNumbers(mixed $values, int $count): array + { + if (!is_array($values) || !array_is_list($values) || count($values) !== $count + || !array_all($values, static fn($n) => (is_int($n) || is_float($n)) && is_finite((float)$n))) { + throw new InvalidArgumentException('Title registration requires finite numeric coordinates.'); + } + return $values; + } +} diff --git a/src/UI/Presentation/TitleSpritePresentation.php b/src/UI/Presentation/TitleSpritePresentation.php new file mode 100644 index 00000000..92945d88 --- /dev/null +++ b/src/UI/Presentation/TitleSpritePresentation.php @@ -0,0 +1,79 @@ + 128 + || !is_int($frames) || $frames < 1 || $frames > $columns * $rows + || (!is_int($duration) && !is_float($duration)) || !is_finite((float)$duration) || $duration <= 0 + || $size['width'] < $columns || $size['height'] < $rows) { + throw new InvalidArgumentException('Title sprite needs a valid atlas grid and frame duration.'); + } + new CanvasRectangle(...TitlePresentationCatalog::getNumbers($sprite['destination'] ?? null, 4)); + if (!is_bool($sprite['reducedVisible'] ?? true)) { throw new InvalidArgumentException('Invalid reduced-motion visibility.'); } + [$phase, $offset, $opacity, $fade] = TitlePresentationCatalog::getNumbers( + [$sprite['phaseSeconds'] ?? 0, $sprite['frameOffset'] ?? 0, $sprite['opacity'] ?? 1, $sprite['fadeFraction'] ?? 0], 4); + if ($phase < 0 || !is_int($offset) || $offset < 0 || $offset >= $frames || $opacity < 0 || $opacity > 1 + || $fade < 0 || $fade > 0.5) { throw new InvalidArgumentException('Invalid title sprite phase or opacity.'); } + if (isset($sprite['travel'])) { + TitlePresentationCatalog::getNumbers($sprite['travel'], 2); + [$period, $visible] = TitlePresentationCatalog::getNumbers([$sprite['period'] ?? 0, $sprite['visibleSeconds'] ?? 0], 2); + if ($visible <= 0 || $period < $visible) { throw new InvalidArgumentException('Invalid title sprite passage timing.'); } + } + } + + public static function getImage(string $root, string $id, array $sprite, float $elapsed, float $opacity, + bool $reduced): ?CanvasImage + { + if ($opacity <= 0 || ($reduced && !($sprite['reducedVisible'] ?? true))) { return null; } + $size = PngAssetPreflight::inspect($root, $sprite['asset']); + [$columns, $rows] = $sprite['grid'] ?? [1, 1]; + $frame = $reduced ? 0 : ((int)floor($elapsed / ($sprite['frameSeconds'] ?? 1)) + + ($sprite['frameOffset'] ?? 0)) % ($sprite['frames'] ?? 1); + $opacity *= $sprite['opacity'] ?? 1; + // Reconcile atlas cells from current artwork, not historical dimensions. + $left = (int)floor(($frame % $columns) * $size['width'] / $columns); + $top = (int)floor(intdiv($frame, $columns) * $size['height'] / $rows); + $sw = (int)floor(($frame % $columns + 1) * $size['width'] / $columns) - $left; + $sh = (int)floor((intdiv($frame, $columns) + 1) * $size['height'] / $rows) - $top; + [$x, $y, $w, $h] = $sprite['destination']; + if (!$reduced && isset($sprite['travel'])) { + $phase = fmod($elapsed + ($sprite['phaseSeconds'] ?? 0), $sprite['period']); + if ($phase >= $sprite['visibleSeconds']) { return null; } + $progress = $phase / $sprite['visibleSeconds']; + $fade = $sprite['fadeFraction'] ?? 0; + if ($fade > 0) { $opacity *= min(1, $progress / $fade, (1 - $progress) / $fade); } + $x += $sprite['travel'][0] * $phase / $sprite['visibleSeconds']; + $y += $sprite['travel'][1] * $phase / $sprite['visibleSeconds']; + } + $x1 = max(0, $x); $y1 = max(0, $y); $x2 = min(1350, $x + $w); $y2 = min(720, $y + $h); + if ($x2 <= $x1 || $y2 <= $y1) { return null; } + $sx1 = (int)floor(($x1 - $x) / $w * $sw); $sy1 = (int)floor(($y1 - $y) / $h * $sh); + $sx2 = min($sw, (int)ceil(($x2 - $x) / $w * $sw)); $sy2 = min($sh, (int)ceil(($y2 - $y) / $h * $sh)); + return new CanvasImage($id, $sprite['asset'], new CanvasRectangle($x1, $y1, $x2 - $x1, $y2 - $y1), 1, + new SpriteSourceRect($left + $sx1, $top + $sy1, $sx2 - $sx1, $sy2 - $sy1), $opacity); + } +} diff --git a/src/UI/Presentation/TitleTextureMotion.php b/src/UI/Presentation/TitleTextureMotion.php new file mode 100644 index 00000000..0f4c357b --- /dev/null +++ b/src/UI/Presentation/TitleTextureMotion.php @@ -0,0 +1,112 @@ + 'ellipse', 'center' => V::getPoint($exclusion['center']), + 'radius' => V::getPoint($exclusion['radius'], 0), 'feather' => V::getNumber($exclusion['feather'], 0, 4096), 'invert' => true]; + } + $operations = []; + foreach (V::getList($data['polygons'], 1, 8) as $polygon) { + $points = array_map(V::getPoint(...), V::getList($polygon, 3, 128)); + $bounds = TitleMotionValues::getBounds($points); + $mask = ['type' => 'polygon', 'contours' => [$points]]; + $operations[] = ['type' => 'image', 'asset' => $asset, 'destination' => $bounds, + 'source' => self::getSource($bounds), 'opacity' => $weight, 'masks' => [$mask], + 'displacement' => ['columns' => 2, 'rows' => 2, + 'offsets' => array_fill(0, 4, [-$amplitude[0] * $phase, -$amplitude[1] * $phase]), + 'masks' => [[...$mask, 'feather' => $feather], ...$exclusions]]]; + } + return $operations; + } + + public static function getWaterOperations(string $asset, array $data, float $time, float $weight): array + { + V::validateKeys($data, ['paths', 'cycleSeconds', 'refractionAmplitude', 'refractionWavelength', + 'refractionSeconds', 'streakColor', 'streakOpacity', 'mistColor']); + $cycle = TitleMotionValues::getPositive($data['cycleSeconds']); + $amplitude = V::getNumber($data['refractionAmplitude'], 0, 128); + $wavelength = TitleMotionValues::getPositive($data['refractionWavelength']); + $speed = TitleMotionValues::getPositive($data['refractionSeconds']); + $streakColor = TitleMotionValues::getColor($data['streakColor']); + $streakOpacity = V::getNumber($data['streakOpacity'], 0, 1); + $mistColor = TitleMotionValues::getColor($data['mistColor']); + $operations = []; + foreach (V::getList($data['paths'], 1, 6) as $index => $fall) { + V::validateKeys($fall, ['points', 'widths']); + $points = array_map(V::getPoint(...), V::getList($fall['points'], 2, 32)); + $widths = array_map(static fn($n) => TitleMotionValues::getPositive($n, 256), V::getList($fall['widths'], count($points), count($points))); + for ($i = 1; $i < count($points); $i++) { + if ($points[$i][1] <= $points[$i - 1][1]) { throw new InvalidArgumentException('Water path points must descend monotonically.'); } + } + $left = $right = []; + foreach ($points as $i => [$x, $y]) { $left[] = [$x - $widths[$i] / 2, $y]; $right[] = [$x + $widths[$i] / 2, $y]; } + $sides = [...$left, ...array_reverse($right)]; + $bounds = TitleMotionValues::getBounds($sides); + $offsets = []; + $rows = min(64, max(2, (int)ceil($bounds['height'] / 2) + 1)); + for ($row = 0; $row < $rows; $row++) { + $p = $row / ($rows - 1); + $y = $bounds['y'] + $bounds['height'] * $p; + $flow = $amplitude * sin($y / $wavelength - $time / $speed) * sin(M_PI * $p); + array_push($offsets, [0, -$flow], [0, -$flow]); + } + $operations[] = ['type' => 'image', 'asset' => $asset, 'source' => self::getSource($bounds), 'destination' => $bounds, + 'opacity' => .88 * $weight, 'masks' => [['type' => 'polygon', 'contours' => [$sides]]], + 'displacement' => ['columns' => 2, 'rows' => $rows, 'offsets' => $offsets]]; + for ($lane = 0; $lane < 7; $lane++) { + for ($streak = 0; $streak < 5; $streak++) { + $p = fmod($time / $cycle + $streak / 5 + $lane * .143 + $index * .217, 1); + $a = self::getPathPoint($points, $widths, $p); + $b = self::getPathPoint($points, $widths, min(1, $p + .055 + ($lane % 3) * .018)); + $offset = ($lane - 3) / 3; + $operations[] = ['type' => 'stroke', 'points' => [[$a[0] + $offset * $a[2] / 2, $a[1]], [$b[0] + $offset * $b[2] / 2, $b[1]]], + 'width' => $lane % 3 === 0 ? 1.5 : .8, 'blend' => 'screen', + 'opacity' => $streakOpacity * sin(M_PI * $p) * (.55 + .45 * sin($lane * 2.3 + $streak) ** 2) * $weight, + 'brush' => ['type' => 'solid', 'color' => $streakColor]]; + } + } + [$x, $y, $width] = self::getPathPoint($points, $widths, 1); + $radius = $width * 1.3; + $pulse = .5 + .5 * sin($time / .760 + $index * 1.9); + $operations[] = ['type' => 'fill', 'destination' => TitleMotionValues::getBounds([[$x - $radius, $y - 5], [$x + $radius, $y + 5]]), + 'blend' => 'screen', 'opacity' => (.10 + .09 * $pulse) * $weight, + 'brush' => ['type' => 'radial', 'center' => [$x, $y], 'radius' => [$radius, $radius], + 'stops' => [['offset' => 0, 'color' => $mistColor], ['offset' => 1, 'color' => $mistColor, 'opacity' => 0]]], + 'masks' => [['type' => 'ellipse', 'center' => [$x, $y], 'radius' => [$radius, 5]]]]; + } + return $operations; + } + + private static function getPathPoint(array $points, array $widths, float $progress): array + { + $scaled = $progress * (count($points) - 1); + $index = min(count($points) - 2, (int)floor($scaled)); + $fraction = $scaled - $index; + return [$points[$index][0] + ($points[$index + 1][0] - $points[$index][0]) * $fraction, + $points[$index][1] + ($points[$index + 1][1] - $points[$index][1]) * $fraction, + $widths[$index] + ($widths[$index + 1] - $widths[$index]) * $fraction]; + } + + private static function getSource(array $bounds): array + { + return ['x' => $bounds['x'] / 1350, 'y' => $bounds['y'] / 720, + 'width' => $bounds['width'] / 1350, 'height' => $bounds['height'] / 720]; + } +} diff --git a/src/UI/Text/MenuInfoText.php b/src/UI/Text/MenuInfoText.php new file mode 100644 index 00000000..80c506b1 --- /dev/null +++ b/src/UI/Text/MenuInfoText.php @@ -0,0 +1,60 @@ + */ + private array $lines = []; + private ?TextPage $measuredPage = null; + public ?TextPage $lastPage { get => $this->measuredPage; } + + public function reset(): void + { + $this->offset = 0; + $this->measuredPage = null; + $this->lines = []; + } + + /** Reading may measure/reflow or reset changed source, but never advances its position. */ + public function getPage(string $description, ?string $status, int $columns): TextPage + { + $source = $description; + if ($status !== null && $status !== '') { $source .= ($source === '' ? '' : "\n") . $status; } + $source = str_replace(["\r\n", "\r", "\t"], ["\n", "\n", ' '], TerminalText::stripAnsi($source)); + $lines = TextViewport::wrap($source, $columns); + if ($source !== $this->source) { $this->offset = 0; } + $this->source = $source; + $this->lines = $lines; + return $this->measuredPage = $this->getSnapshot($columns); + } + + /** Advances a whole page using the last measured layout, cycling after its final partial page. */ + public function advance(): bool + { + if ($this->lastPage === null || count($this->lines) <= 2) { return false; } + $this->offset = $this->getSnapshot($this->lastPage->columns)->nextOffset; + return true; + } + + private function getSnapshot(int $columns): TextPage + { + $first = 0; + foreach ($this->lines as $index => [$offset]) { + if ($offset > $this->offset) { break; } + $first = $index; + } + $first = intdiv($first, 2) * 2; + $total = count($this->lines); + return new TextPage($this->source, array_column(array_slice($this->lines, $first, 2), 1), + $first, $total, $columns, 2, $this->lines[max(0, $first - 2)][0], + $this->lines[$first + 2 < $total ? $first + 2 : 0][0]); + } +} diff --git a/src/UI/Text/TextPage.php b/src/UI/Text/TextPage.php new file mode 100644 index 00000000..475464ed --- /dev/null +++ b/src/UI/Text/TextPage.php @@ -0,0 +1,26 @@ + $lines */ + public function __construct( + public string $source, + public array $lines, + public int $first, + public int $total, + public int $columns, + public int $rows, + public int $previousOffset, + public int $nextOffset, + ) {} + + public function range(): string + { + return sprintf('Lines %d-%d / %d', $this->first + 1, $this->first + count($this->lines), $this->total); + } +} diff --git a/src/UI/Text/TextViewport.php b/src/UI/Text/TextViewport.php new file mode 100644 index 00000000..9ee3ee11 --- /dev/null +++ b/src/UI/Text/TextViewport.php @@ -0,0 +1,113 @@ +text) { $this->text = $text; $this->reset(); } + } + + public function reset(): void { $this->offset = 0; } + + /** Explicit list preview; owners must expose the complete value in their detail page. */ + public static function preview(string $text, int $columns): string + { + if ($columns < 4) { throw new InvalidArgumentException('A readable preview requires at least four cells.'); } + $text = str_replace(["\r\n", "\r", "\t"], ["\n", "\n", ' '], TerminalText::stripAnsi($text)); + $lines = self::wrap($text, $columns); + if (count($lines) === 1) { return $lines[0][1]; } + $prefix = ''; + $width = 0; + foreach (TerminalText::visibleSymbols($lines[0][1]) as $symbol) { + $size = max(TerminalText::getSymbolWidth($symbol), mb_strlen($symbol, 'UTF-8')); + if ($width + $size > $columns - 3) { break; } + $prefix .= $symbol; + $width += $size; + } + return rtrim($prefix) . '...'; + } + + /** A render reads a clamped snapshot without changing the reading position. */ + public function page(int $columns, int $rows): TextPage + { + if ($columns < 1 || $rows < 1) { throw new InvalidArgumentException('Text viewport needs positive columns and rows.'); } + $wrapped = self::wrap($this->text, $columns); + $first = 0; + foreach ($wrapped as $index => [$offset]) { + if ($offset > $this->offset) { break; } + $first = $index; + } + $maximum = max(0, count($wrapped) - $rows); + $first = min($first, $maximum); + return new TextPage($this->text, array_column(array_slice($wrapped, $first, $rows), 1), $first, count($wrapped), + $columns, $rows, $wrapped[max(0, $first - 1)][0], $wrapped[min($maximum, $first + 1)][0]); + } + + /** Semantic navigation supplies direction and the last successfully displayed PHP layout. */ + public function scroll(int $direction, TextPage $page): void + { + if ($direction === 0) { return; } + if ($page->source !== $this->text) { $page = $this->page($page->columns, $page->rows); } + $this->offset = $direction > 0 ? $page->nextOffset : $page->previousOffset; + } + + /** Complete graphemes fit both terminal cell widths and Canvas scalar budgets. + * @return non-empty-list Source byte offset and displayed line. + */ + public static function wrap(string $text, int $columns): array + { + if ($columns < 1 || !mb_check_encoding($text, 'UTF-8')) { throw new InvalidArgumentException('Text requires UTF-8 and a positive viewport width.'); } + preg_match_all('/\n|[^\S\n\x{00A0}\x{202F}]+|[\S\x{00A0}\x{202F}]+/u', $text, $matches, PREG_OFFSET_CAPTURE); + $lines = []; + $line = ''; + $width = 0; + $start = 0; + foreach ($matches[0] as [$token, $offset]) { + if ($token === "\n") { + $lines[] = [$start, $line]; + $line = ''; + $width = 0; + $start = $offset + 1; + continue; + } + preg_match_all('/\X/u', $token, $symbols, PREG_OFFSET_CAPTURE); + $sizes = array_map(fn($item) => max(TerminalText::getSymbolWidth($item[0]), mb_strlen($item[0], 'UTF-8')), $symbols[0]); + $tokenWidth = array_sum($sizes); + $whitespace = preg_match('/\A[^\S\x{00A0}\x{202F}]+\z/u', $token) === 1; + if (!$whitespace && $width > 0 && $width + $tokenWidth > $columns + && ($tokenWidth <= $columns || preg_match('/\S/u', $line) === 1)) { + $lines[] = [$start, $line]; + $line = ''; + $width = 0; + $start = $offset; + } + foreach ($symbols[0] as $index => [$symbol, $relative]) { + $size = $sizes[$index]; + if ($size > $columns) { throw new InvalidArgumentException('A complete text grapheme exceeds the viewport width.'); } + if ($width + $size > $columns) { + $lines[] = [$start, $line]; + $line = ''; + $width = 0; + $start = $offset + $relative; + } + $line .= $symbol; + $width += $size; + } + } + $lines[] = [$start, $line]; + return $lines; + } +} diff --git a/src/Util/Helpers.php b/src/Util/Helpers.php index 3cdaf08a..e04a2b44 100644 --- a/src/Util/Helpers.php +++ b/src/Util/Helpers.php @@ -190,7 +190,7 @@ function select( * @param string $message The message to show. * @param string $title The title of the dialog. Defaults to "". * @param string $help The help text to show. Defaults to "". - * @param WindowPosition $position The position of the dialog. Defaults to BOTTOM (i.e. the bottom of the screen). + * @param WindowPosition|null $position Explicit position, or top for narration and bottom for speech. * @param float $charactersPerSecond The number of characters to display per second. * @return void * @throws Exception @@ -199,7 +199,7 @@ function show_text( string $message, string $title = '', string $help = '', - WindowPosition $position = WindowPosition::BOTTOM, + ?WindowPosition $position = null, float $charactersPerSecond = 1 ): void { @@ -529,24 +529,7 @@ function os_program_exists(string $programName): bool */ function get_local_timezone(): string { - $timezoneCommand = 'timedatectl'; - - if (os_program_exists($timezoneCommand)) { - $output = shell_exec($timezoneCommand) ?? ''; - $lines = explode("\n", $output); - - foreach ($lines as $line) { - if (str_contains($line, 'Local time')) { - preg_match('/Local time: (.*)\s([A-Z]{1,5})$/', $line, $matches); - - if (! empty($matches[2])) { - return $matches[2]; - } - } - } - } - - return date_default_timezone_get(); + return \Ichiloto\Engine\Util\LocalClock::getTimezone()->getName(); } } diff --git a/src/Util/LocalClock.php b/src/Util/LocalClock.php new file mode 100644 index 00000000..5eb76f34 --- /dev/null +++ b/src/Util/LocalClock.php @@ -0,0 +1,60 @@ +setTimezone(self::getTimezone()); + } + + public static function getTimezone(): DateTimeZone + { + foreach (self::getTimezoneCandidates() as $candidate) { + $candidate = ltrim(trim($candidate), ':'); + if (str_contains($candidate, '/zoneinfo/')) { + $candidate = explode('/zoneinfo/', $candidate, 2)[1]; + } + if ($candidate === '') { continue; } + try { return new DateTimeZone($candidate); } + catch (DateInvalidTimeZoneException) { /* Try the next host source. */ } + } + + static $reported = false; + if (!$reported) { + $reported = true; + Debug::warn('Host timezone could not be detected; local presentation uses the PHP timezone.'); + } + return new DateTimeZone(date_default_timezone_get()); + } + + /** @return Generator */ + private static function getTimezoneCandidates(): Generator + { + $environment = getenv('TZ'); + if ($environment !== false) { yield $environment; } + + // macOS and most Linux hosts link this file to their selected IANA zone. + clearstatcache(true, '/etc/localtime'); + $localtime = realpath('/etc/localtime'); + if ($localtime !== false && str_contains($localtime, '/zoneinfo/')) { yield $localtime; } + if (is_readable('/etc/timezone')) { + $timezone = file_get_contents('/etc/timezone', length: 256); + if ($timezone !== false) { yield $timezone; } + } + + // ICU also resolves native Windows zones; intl remains an optional capability. + if (class_exists(IntlTimeZone::class)) { yield IntlTimeZone::createDefault()->getID(); } + } +} diff --git a/tests/Fixtures/BattlePresentation/shared-ui.php b/tests/Fixtures/BattlePresentation/shared-ui.php new file mode 100644 index 00000000..a81e8d3a --- /dev/null +++ b/tests/Fixtures/BattlePresentation/shared-ui.php @@ -0,0 +1,19 @@ + pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xD0\xC0\xA0\xFF", $width), $height))) . $chunk('IEND', '')); +} + +beforeEach(function () { + $this->saved = []; + foreach ([InputManager::class, ActionHints::class] as $class) { $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); } + ActionHints::useProvider(null); + $this->root = sys_get_temp_dir() . '/ichiloto-action-hints-' . bin2hex(random_bytes(5)); + mkdir($this->root); + actionHintPng($this->root . '/glyph.png', 81, 37); + actionHintPng($this->root . '/unknown.png', 31, 73); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::SPACE, KeyCode::ENTER]], + 'cancel' => ['keys' => [KeyCode::C, KeyCode::c, KeyCode::ESCAPE]]]); +}); + +afterEach(function () { + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + foreach (glob($this->root . '/*') as $path) { unlink($path); } + rmdir($this->root); +}); + +it('resolves compact readable keycaps and leaves action bindings and Controls aliases intact', function () { + $source = new FakeInputSource(KeyCode::ENTER); + InputManager::setInputSource($source); + $before = InputManager::getBindings(); + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')]; + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1']); + $frame = MenuActionHints::compose(640, 100, 'hints', $hints, $theme, new CanvasRectangle(10, 10, 620, 48)); + expect(implode('', array_column($frame->textLayers[0]->runs, 'text')))->toBe(' Enter : Confirm Escape : Cancel') + ->and(array_column($hints, 'action'))->toBe(['confirm', 'cancel']) + ->and(array_column(array_filter($frame->textLayers[0]->runs, fn($run) => $run->background !== null), 'text'))->toBe([' Enter ', ' Escape ']) + ->and($frame->images)->toBeEmpty()->and(InputManager::getBindings())->toBe($before) + ->and(new InputBindings()->describeKeys('cancel'))->toBe('C, c, ESCAPE')->and($source->keys)->toBe([KeyCode::ENTER]); + InputManager::handleInput(); + expect(Input::isButtonDown('confirm'))->toBeTrue()->and(Input::isButtonDown('cancel'))->toBeFalse(); +}); + +it('lets a theme hide inline hints without changing actions or the full Controls lookup', function () { + $bindings = InputManager::getBindings(); + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, + 'icons' => ['input.keyboard.ENTER' => 'glyph.png']]); + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')]; + $frame = MenuActionHints::compose(640, 100, 'hints', $hints, $theme, new CanvasRectangle(0, 0, 640, 24)); + expect(MenuActionHints::height($hints, $theme, 640))->toBe(0) + ->and($frame->images)->toBeEmpty()->and($frame->textLayers)->toBeEmpty() + ->and(InputManager::getBindings())->toBe($bindings) + ->and(new InputBindings()->describeKeys('cancel'))->toBe('C, c, ESCAPE'); + expect(fn() => new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => 'false'])) + ->toThrow(InvalidArgumentException::class); +}); + +it('measures padded controls as separate keycaps and keeps unbound hints plain', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1']); + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')]; + expect(MenuActionHints::height($hints, $theme, 340))->toBe(24) + ->and(MenuActionHints::height($hints, $theme, 330))->toBe(48); + $frame = MenuActionHints::compose(340, 48, 'hints', $hints, $theme, new CanvasRectangle(0, 0, 330, 48)); + $caps = array_values(array_filter($frame->textLayers[0]->runs, fn($run) => $run->background !== null)); + expect(array_column($caps, 'row'))->toBe([0, 1])->and(array_column($caps, 'column'))->toBe([0, 0]); + foreach ($caps as $cap) { + expect($cap->text)->toStartWith(' ')->toEndWith(' ') + ->and(mb_strlen($cap->text) - mb_strlen(trim($cap->text)))->toBe(2) + ->and($cap->column + mb_strlen($cap->text))->toBeLessThanOrEqual(33); + } + InputManager::setBindings([]); + $plain = MenuActionHints::compose(340, 48, 'hints', [ActionHints::resolve('cancel', 'Cancel')], $theme, + new CanvasRectangle(0, 0, 340, 48)); + expect(implode('', array_column($plain->textLayers[0]->runs, 'text')))->toBe('Unbound: Cancel') + ->and(array_filter($plain->textLayers[0]->runs, fn($run) => $run->background !== null))->toBeEmpty(); +}); + +it('uses only actual owner-declared aliases and keeps external profile identity separate from labels', function () { + InputManager::setBindings([]); + expect(ActionHints::resolve('character_previous', 'Prev', KeyCode::SHIFT_TAB)->control->label)->toBe('Shift+Tab') + ->and(ActionHints::resolve('back', 'Back')->control)->toBeNull(); + ActionHints::useProvider(new class implements ActionHintProvider { + public function controlForAction(string $action): ?ControlHint { return null; } + }); + expect(ActionHints::resolve('character_previous', 'Prev', KeyCode::SHIFT_TAB)->control)->toBeNull(); + $control = new ControlHint('pad-family', 'face-south', 'Localized primary control'); + expect($control->iconRole())->toBe('input.pad-family.face-south')->and($control->label)->toBe('Localized primary control'); +}); + +it('selects exact optional glyphs and falls back to readable controls for absent roles', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', + 'icons' => ['unknown' => 'unknown.png', 'input.keyboard.ENTER' => 'glyph.png']]); + $hints = [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')]; + $box = new CanvasRectangle(10, 10, 600, 24); + $frame = MenuActionHints::compose(640, 100, 'hints', $hints, $theme, $box); + expect(array_column($frame->images, 'asset'))->toBe(['glyph.png']) + ->and(implode('', array_column($frame->textLayers[0]->runs, 'text')))->toBe(': Confirm Escape : Cancel') + ->and(MenuActionHints::height($hints, $theme, $box->width))->toBe(24); + $before = $frame->images[0]->destination; + actionHintPng($this->root . '/glyph.png', 19, 95); + clearstatcache(); + $after = MenuActionHints::compose(640, 100, 'hints', $hints, $theme, $box)->images[0]; + expect($after->destination->width / $after->destination->height)->toEqualWithDelta(19 / 95, 0.000001) + ->and($after->destination->width)->toBeLessThan($before->width)->and($after->destination->height)->toBeLessThanOrEqual(24) + ->and($after->clipRect)->toBe($box); +}); + +it('accepts glyph-only registries without unrelated fallback art and still diagnoses invalid configured assets', function () { + $data = ['schema' => 'ichiloto.menu/1', 'icons' => ['input.keyboard.ENTER' => 'glyph.png']]; + $theme = new MenuPresentationCatalog($this->root, $data); + expect($theme->icons->asset('input.keyboard.ENTER'))->toBe('glyph.png') + ->and($theme->icons->asset('weapon.staff'))->toBeNull()->and($theme->icons->asset(null))->toBeNull(); + $cursorOnly = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'cursor' => 'glyph.png']); + expect($cursorOnly->icons->cursor)->toBe('glyph.png')->and($cursorOnly->icons->asset('slot.head'))->toBeNull(); + $frame = MenuActionHints::compose(640, 48, 'hints', + [ActionHints::resolve('confirm', 'Confirm'), ActionHints::resolve('cancel', 'Cancel')], $theme, + new CanvasRectangle(0, 0, 640, 48)); + expect(array_column($frame->images, 'asset'))->toBe(['glyph.png']) + ->and(implode('', array_column($frame->textLayers[0]->runs, 'text')))->toBe(': Confirm Escape : Cancel'); + file_put_contents($this->root . '/corrupt.png', 'not a PNG'); + foreach (['missing.png', 'corrupt.png'] as $asset) { + $data['icons']['input.unused.control'] = $asset; + expect(fn() => new MenuPresentationCatalog($this->root, $data))->toThrow(RuntimeException::class); + } +}); + +it('measures complete wrapped hints using the same snapshot and rejects insufficient space', function (bool $glyph) { + $data = ['schema' => 'ichiloto.menu/1']; + if ($glyph) { $data['icons'] = ['unknown' => 'unknown.png', 'input.keyboard.ENTER' => 'glyph.png']; } + $theme = new MenuPresentationCatalog($this->root, $data); + $hints = [ActionHints::resolve('confirm', 'Confirm'), new ActionHint('cancel', str_repeat('Long action ', 6), ControlHint::keyboard(KeyCode::ESCAPE))]; + $height = MenuActionHints::height($hints, $theme, 200); + $frame = MenuActionHints::compose(400, 300, 'hints', $hints, $theme, new CanvasRectangle(0, 0, 200, $height)); + $text = implode('', array_column($frame->textLayers[0]->runs, 'text')); + expect($height)->toBeGreaterThan(24)->and($text)->toContain('Confirm', ' Escape : ' . str_repeat('Long action ', 6)) + ->and($frame->textLayers[0]->bounds->height)->toBe((float)$height); + foreach ($frame->textLayers[0]->runs as $run) { + expect($run->column + mb_strlen($run->text))->toBeLessThanOrEqual(20); + } + expect(fn() => MenuActionHints::compose(400, 300, 'hints', $hints, $theme, new CanvasRectangle(0, 0, 200, $height - 1))) + ->toThrow(RuntimeException::class, 'finite viewport'); +})->with([false, true]); + +it('refreshes provider families on redraw without claiming or generating device events', function () { + $provider = new class implements ActionHintProvider { + public string $family = 'profile-one'; + public function controlForAction(string $action): ?ControlHint { return new ControlHint($this->family, 'face-south', $this->family); } + }; + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'icons' => ['unknown' => 'unknown.png', + 'input.profile-one.face-south' => 'glyph.png']]); + $bindings = InputManager::getBindings(); + $source = new FakeInputSource(KeyCode::ENTER); + InputManager::setInputSource($source); + ActionHints::useProvider($provider); + $first = ActionHints::resolve('confirm', 'Confirm'); + $provider->family = 'profile-two'; + $second = ActionHints::resolve('confirm', 'Confirm'); + $box = new CanvasRectangle(0, 0, 400, 24); + $one = MenuActionHints::compose(400, 100, 'hints', [$first], $theme, $box); + $two = MenuActionHints::compose(400, 100, 'hints', [$second], $theme, $box); + expect($first->control->family)->toBe('profile-one')->and($second->control->family)->toBe('profile-two') + ->and($first->action)->toBe($second->action)->and($one->images)->toHaveCount(1)->and($two->images)->toBeEmpty() + ->and(implode('', array_column($two->textLayers[0]->runs, 'text')))->toBe(' profile-two : Confirm') + ->and($source->keys)->toBe([KeyCode::ENTER])->and(InputManager::getBindings())->toBe($bindings); + ActionHints::useProvider(null); + expect(ActionHints::resolve('confirm', 'Confirm')->control->label)->toBe('Enter'); +}); diff --git a/tests/Unit/ActiveTimeOpeningTest.php b/tests/Unit/ActiveTimeOpeningTest.php new file mode 100644 index 00000000..52f11eed --- /dev/null +++ b/tests/Unit/ActiveTimeOpeningTest.php @@ -0,0 +1,269 @@ +calls++; + return $this->roll; + } +} + +function openingTestBattle(array $speeds = [12, 7, 7], array $settings = ['firstStrike' => 'normal'], int $seed = 17): array +{ + $game = new class extends Game { + public function __construct() {} + public function __destruct() {} + }; + $party = new Party(); + foreach ($speeds as $index => $speed) { + $party->addMember(new Character('Actor' . $index, 1, new Stats(currentHp: 100, speed: $speed))); + } + $enemy = (new ReflectionClass(Enemy::class))->newInstanceWithoutConstructor(); + new ReflectionProperty($enemy, 'name')->setValue($enemy, 'Enemy'); + new ReflectionProperty($enemy, 'stats')->setValue($enemy, new Stats(currentHp: 100, speed: 99)); + $troop = new Troop('Opening test', [$enemy]); + $ui = (new ReflectionClass(BattleScreen::class))->newInstanceWithoutConstructor(); + $context = new TurnStateExecutionContext($game, $party, $troop, $ui, []); + $engine = new class($game, new SeededCombatRandomSource($seed)) extends ActiveTimeBattleEngine { + public function resetOpening(TurnStateExecutionContext $context): void { $this->resetBattleState($context); } + public function spent(CharacterInterface $actor): void { $this->resetGauge($actor); } + public function fillRate(CharacterInterface $actor): float { return $this->getFillRate($actor); } + public function gauge(CharacterInterface $actor): float { return $this->gaugeValues[spl_object_id($actor)]; } + public function setGauge(CharacterInterface $actor, float $value): void { $this->gaugeValues[spl_object_id($actor)] = $value; } + }; + $engine->configure(new ActiveTimeBattleConfig($party, $troop, $ui, settings: $settings)); + $engine->resetOpening($context); + return [$engine, $context, $party->battlers->toArray(), $enemy]; +} + +beforeEach(function () { + $this->priorDelta = new ReflectionProperty(Time::class, 'deltaTime')->getValue(); +}); + +afterEach(function () { + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, $this->priorDelta); +}); + +it('resolves the normal preemptive and ambush intervals with exactly one roll', function () { + $counts = ['normal' => 0, 'party' => 0, 'troop' => 0]; + foreach (range(1, 100) as $roll) { + $random = new OpeningTestRandom($roll); + $counts[EncounterAdvantage::forBattle([], $random)->value]++; + expect($random->calls)->toBe(1); + } + expect($counts)->toBe(['normal' => 86, 'party' => 8, 'troop' => 6]); +}); + +it('honors explicit opening overrides without a roll', function (mixed $value, EncounterAdvantage $expected) { + $random = new OpeningTestRandom(1); + expect(EncounterAdvantage::forBattle(['firstStrike' => $value], $random))->toBe($expected) + ->and($random->calls)->toBe(0); +})->with([ + ['party', EncounterAdvantage::PARTY], ['troop', EncounterAdvantage::TROOP], + ['normal', EncounterAdvantage::NORMAL], [null, EncounterAdvantage::NORMAL], +]); + +it('rejects invalid opening data rather than silently rerolling', function () { + foreach (['typo', '', false, [], 1] as $value) { + expect(fn() => EncounterAdvantage::fromSetting($value))->toThrow(InvalidArgumentException::class); + } + foreach ([[-1, 6], [8, -1], [80, 30]] as [$party, $troop]) { + expect(fn() => EncounterAdvantage::forBattle([], new OpeningTestRandom(1), $party, $troop)) + ->toThrow(InvalidArgumentException::class); + } +}); + +it('normalizes shared opening settings with legacy project compatibility', function () { + $data = ['title' => 'Test', 'currency' => [], 'startingPositions' => ['player' => []]]; + $legacy = ['activeTime' => ['surpriseAttackChancePercent' => 12, 'backAttackChancePercent' => 3]]; + $system = SystemData::fromArray($data + ['battle' => $legacy]); + expect((array) $system->getOpeningSettings())->toBe(['preemptiveChancePercent' => 12, 'ambushChancePercent' => 3]); + $system = SystemData::fromArray($data + ['battle' => $legacy + [ + 'opening' => ['preemptiveChancePercent' => 20, 'ambushChancePercent' => 0], + ]]); + expect((array) $system->getOpeningSettings())->toBe(['preemptiveChancePercent' => 20, 'ambushChancePercent' => 0]) + ->and($system->getActiveTimeSettings()->surpriseAttackChancePercent)->toBe(20) + ->and($system->getActiveTimeSettings()->openingVariance)->toBe(70) + ->and($system->getActiveTimeSettings()->openingSpeedFactorPercent)->toBe(50); + expect(EncounterAdvantage::forBattle(['opening' => (array) $system->getOpeningSettings()], new OpeningTestRandom(20))) + ->toBe(EncounterAdvantage::PARTY); +}); + +it('rejects combined invalid opening chances when loading project settings', function (array $battle) { + $data = ['title' => 'Test', 'currency' => [], 'startingPositions' => ['player' => []], 'battle' => $battle]; + expect(fn() => SystemData::fromArray($data))->toThrow(InvalidArgumentException::class, + 'Opening advantage chances must be non-negative and total at most 100.'); +})->with([ + [['opening' => ['preemptiveChancePercent' => 60, 'ambushChancePercent' => 60]]], + [['activeTime' => ['surpriseAttackChancePercent' => 60, 'backAttackChancePercent' => 60]]], + [['opening' => ['preemptiveChancePercent' => 95], 'activeTime' => ['backAttackChancePercent' => 10]]], +]); + +it('accepts a complete opening distribution and explicit overrides of legacy values', function () { + $data = ['title' => 'Test', 'currency' => [], 'startingPositions' => ['player' => []], 'battle' => [ + 'opening' => ['preemptiveChancePercent' => 60, 'ambushChancePercent' => 40], + 'activeTime' => ['surpriseAttackChancePercent' => 60, 'backAttackChancePercent' => 60], + ]]; + $system = SystemData::fromArray($data); + expect((array)$system->getOpeningSettings())->toBe(['preemptiveChancePercent' => 60, 'ambushChancePercent' => 40]) + ->and($system->getActiveTimeSettings()->backAttackChancePercent)->toBe(40); +}); + +it('uses the shared configured chances in the actual ATB opening', function (array $chances, string $message) { + [$engine] = openingTestBattle(settings: ['opening' => $chances]); + expect($engine->consumeEncounterAlert())->toBe($message); +})->with([ + [['preemptiveChancePercent' => 100, 'ambushChancePercent' => 0], 'Preemptive strike! The party moves first.'], + [['preemptiveChancePercent' => 0, 'ambushChancePercent' => 100], 'Ambushed! The enemy strikes first.'], +]); + +it('preserves runtime chance overrides over project settings including legacy callers', function () { + $root = sys_get_temp_dir() . '/ichiloto-opening-' . uniqid(); + mkdir($root . '/assets/Data', 0777, true); + $data = ['title' => 'Test', 'currency' => [], 'startingPositions' => ['player' => []], + 'battle' => ['opening' => ['preemptiveChancePercent' => 8, 'ambushChancePercent' => 6]]]; + file_put_contents($root . '/assets/Data/system.php', 'newInstanceWithoutConstructor(); + $party = new Party(); + $troop = new Troop('Test'); + $legacy = ['activeTime' => ['surpriseAttackChancePercent' => 12, 'backAttackChancePercent' => 3]]; + expect($loader->newConfig($party, $troop, [], $legacy)->settings['opening']) + ->toBe(['preemptiveChancePercent' => 12, 'ambushChancePercent' => 3]); + $config = $loader->newConfig($party, $troop, [], $legacy + [ + 'opening' => ['preemptiveChancePercent' => 20], 'firstStrike' => null, + ]); + expect($config->settings['opening'])->toBe(['preemptiveChancePercent' => 20, 'ambushChancePercent' => 3]) + ->and(EncounterAdvantage::forBattle($config->settings, new OpeningTestRandom(1))) + ->toBe(EncounterAdvantage::NORMAL); + } finally { + chdir($cwd); + unlink($root . '/assets/Data/system.php'); + rmdir($root . '/assets/Data'); rmdir($root . '/assets'); rmdir($root); + } +}); + +it('gives every favored battler an opening turn before the surprised side', function (string $opening) { + [$engine, $context, $party, $enemy] = openingTestBattle(settings: ['firstStrike' => $opening]); + $favored = $opening === 'party' ? $party : [$enemy]; + $surprised = $opening === 'party' ? [$enemy] : $party; + foreach ($favored as $actor) { expect($engine->gauge($actor))->toBe(100.0); } + foreach ($surprised as $actor) { expect($engine->gauge($actor))->toBe(0.0); } + // Even a delayed frame cannot let newly filled gauges overtake the opening queue. + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, 10.0); + $engine->progressGauges($context); + foreach ($favored as $_) { expect($engine->claimNextReadyBattler())->toBeIn($favored); } + expect($engine->claimNextReadyBattler())->toBeIn($surprised); + expect($engine->consumeEncounterAlert())->not->toBeNull() + ->and($engine->consumeEncounterAlert())->toBeNull(); +})->with(['party', 'troop']); + +it('keeps normal opening gauges bounded and varied even at maximum speed', function () { + [$engine, $context, $party] = openingTestBattle([9999, 9999, 9999]); + $observed = []; + foreach (range(1, 100) as $_) { + $engine->resetOpening($context); + foreach ($party as $actor) { + $gauge = $engine->gauge($actor); + expect($gauge)->toBeGreaterThanOrEqual(0)->toBeLessThanOrEqual(90); + $observed[] = $gauge; + } + expect($engine->claimNextReadyBattler())->toBeNull(); + } + expect(count(array_unique($observed)))->toBeGreaterThan(100); +}); + +it('allows every party member to lead while retaining a speed advantage', function () { + [$engine, $context, $party, $enemy] = openingTestBattle(seed: 16092026); + $enemy->stats->currentHp = 0; + $counts = array_fill_keys(array_map(fn($actor) => $actor->name, $party), 0); + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, 10.0); + foreach (range(1, 1000) as $_) { + $engine->resetOpening($context); + $engine->progressGauges($context); + $counts[$engine->claimNextReadyBattler()->name]++; + } + expect($counts['Actor0'])->toBeGreaterThan($counts['Actor1'])->toBeGreaterThan($counts['Actor2']) + ->toBeLessThan(700); + expect(min($counts))->toBeGreaterThan(100); +}); + +it('orders readiness by threshold crossing rather than capped gauge or frame size', function () { + $orders = []; + foreach ([0.01, 10.0] as $delta) { + [$engine, $context, $party, $enemy] = openingTestBattle([99, 7, 7]); + $enemy->stats->currentHp = 0; + $engine->setGauge($party[0], 0.0); + $engine->setGauge($party[1], 90.0); + $engine->setGauge($party[2], 80.0); + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, $delta); + // Readiness is retained while commands execute; drain only after all have filled. + for ($elapsed = 0.0; $elapsed < 2.0; $elapsed += $delta) { $engine->progressGauges($context); } + $order = []; + while ($actor = $engine->claimNextReadyBattler()) { $order[] = $actor->name; } + $orders[] = $order; + } + expect($orders)->toBe([['Actor1', 'Actor2', 'Actor0'], ['Actor1', 'Actor2', 'Actor0']]); +}); + +it('resets spent gauges to zero and preserves speed based refill without rerolling the opener', function () { + [$engine, $context, $party] = openingTestBattle(settings: ['firstStrike' => 'party']); + $actor = $engine->claimNextReadyBattler(); + $engine->spent($actor); + expect($engine->gauge($actor))->toBe(0.0) + ->and($engine->fillRate($party[0]))->toBe(47.0) + ->and($engine->fillRate($party[1]))->toBe(42.0); + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, 0.5); + $engine->progressGauges($context); + expect($engine->gauge($actor))->toBe($engine->fillRate($actor) * 0.5); +}); + +it('applies battle speed stages to both sides and never queues knocked out actors', function () { + [$engine, $context, $party, $enemy] = openingTestBattle([12, 7, 7]); + $party[0]->setStatStage('speed', 1); + $enemy->setStatStage('speed', -1); + expect($engine->fillRate($party[0]))->toBeGreaterThan(47.0) + ->and($engine->fillRate($enemy))->toBeLessThan(134.0); + $party[1]->stats->currentHp = 0; + $engine->resetOpening($context); + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, 10.0); + $engine->progressGauges($context); + while ($actor = $engine->claimNextReadyBattler()) { expect($actor)->not->toBe($party[1]); } + expect($engine->gauge($party[1]))->toBe(0.0); +}); + +it('clears the prior opening and readiness state when a battle stops', function () { + [$engine, $context, $party] = openingTestBattle(settings: ['firstStrike' => 'party']); + $engine->stop(); + expect($engine->claimNextReadyBattler())->toBeNull() + ->and($engine->consumeEncounterAlert())->toBeNull() + ->and($engine->getGaugePercentage($party[0]))->toBe(0.0); + $engine->configure(new ActiveTimeBattleConfig($context->party, $context->troop, $context->ui, settings: ['firstStrike' => 'normal'])); + $engine->resetOpening($context); + expect($engine->claimNextReadyBattler())->toBeNull() + ->and($engine->consumeEncounterAlert())->toBeNull(); +}); diff --git a/tests/Unit/ApplicationSceneTeardownTest.php b/tests/Unit/ApplicationSceneTeardownTest.php new file mode 100644 index 00000000..987cc4ce --- /dev/null +++ b/tests/Unit/ApplicationSceneTeardownTest.php @@ -0,0 +1,628 @@ +observers = new ItemList(ObserverInterface::class); + $this->staticObservers = new ItemList(StaticObserverInterface::class); + // Only lookup maps production class names to headless presentation fixtures; + // every manager lifecycle/transition method remains the production method. + $this->sceneManager = new class extends SceneManager { + public function __construct() {} + public function findScene(string $className): ?SceneInterface + { + return $this->scenes->find(fn(SceneInterface $scene) => $scene instanceof $className); + } + }; + new ReflectionProperty(SceneManager::class, 'scenes')->setValue($this->sceneManager, new ItemList(SceneInterface::class)); + new ReflectionProperty(SceneManager::class, 'game')->setValue($this->sceneManager, $this); + new ReflectionProperty(SceneManager::class, 'eventManager')->setValue($this->sceneManager, EventManager::getInstance($this)); + new ReflectionProperty(SceneManager::class, 'battleLoader')->setValue($this->sceneManager, new class extends BattleLoader { + public function __construct() {} + public function newConfig(Party $party, Troop $troop, array $battleEvents, array $extraSettings = []): BattleConfig + { + return new BattleConfig($party, $troop, $battleEvents, $extraSettings); + } + }); + new ReflectionProperty(SceneManager::class, 'saveManager')->setValue($this->sceneManager, new class extends SaveManager { + public int $writes = 0; + public function __construct() {} + public function autoSave(GameScene $scene): SaveSlot + { + $this->writes++; + throw new RuntimeException('Shutdown must not save.'); + } + }); + $this->audioManager = new class($this) extends RecordingAudioManager { + public int $shutdowns = 0; + public function shutdown(): void { $this->shutdowns++; parent::shutdown(); } + }; + } + public function __destruct() {} + public function updateAfterQuit(): void { parent::update(); } + public function useBattleEngineType(BattleEngineType $type): void { $this->engineSelections++; } +} + +final class TeardownBattle extends BattleScene +{ + public int $configures = 0; + public function __construct(TeardownGame $game) + { + $this->sceneManager = $game->sceneManager; + $this->eventManager = EventManager::getInstance($game); + $this->camera = new class($this, 24, 8) extends Camera { + public int $starts = 0; + public int $stops = 0; + public function start(): void { $this->starts++; } + public function stop(): void { $this->stops++; } + public function suspend(): void {} + }; + } + public function configure(SceneConfigurationInterface $config): void + { + $this->configures++; + $this->config = $config; + Console::write('BATTLE', 0, 0); + } +} + +final class TeardownGameOver extends GameOverScene +{ + public function __construct() {} + public function start(): void { $this->started = true; } + public function stop(): void { $this->started = false; } +} + +final class TeardownTitle extends TitleScene +{ + public function __construct() {} + public function start(): void { $this->started = true; } + public function stop(): void { $this->started = false; } +} + +final class TeardownProbeScene extends AbstractScene +{ + public int $stops = 0; + public function __construct(bool $started, private ?Closure $onStop = null) { $this->started = $started; } + public function stop(): void { $this->stops++; ($this->onStop)?->__invoke(); $this->started = false; } +} + +final class TeardownPresentation implements EventPresentationInterface +{ + public int $resets = 0; + public ?Closure $onReset = null; + public function beginText(string $text, string $name = ''): void {} + public function beginChoice(string $prompt, array $options, string $title = ''): void {} + public function update(): void {} + public function render(): void {} + public function isComplete(): bool { return false; } + public function choiceResult(): ?int { return null; } + public function reset(): void { $this->resets++; ($this->onReset)?->__invoke(); } +} + +final class TeardownOutcome implements EventSessionCompletionTargetInterface +{ + public int $completed = 0; + public int $failed = 0; + public ?Closure $onFailure = null; + public function onEventSessionCompleted(GameScene $gameScene, EventExecutionSession $session): void { $this->completed++; } + public function onEventSessionFailed(GameScene $gameScene, EventExecutionSession $session): void + { + $this->failed++; + ($this->onFailure)?->__invoke(); + } +} + +/** Real field ownership and lifecycle; no project bootstrap, native renderer or audio backend. */ +final class TeardownField extends GameScene +{ + public TeardownPresentation $testPresentation; + public int $musicRefreshes = 0; + public function __construct(TeardownGame $game) + { + $this->sceneManager = $game->sceneManager; + $this->eventManager = EventManager::getInstance($game); + $this->gameState = new GameState(); + $this->party = new class extends Party { public function isDefeated(): bool { return false; } }; + $this->currentMapId = 'teardown-map'; + $this->mapManager = new class extends MapManager { + public function __construct() {} + public function canMoveTo(int $x, int $y, ?CollisionType &$collisionType = null): bool { return true; } + }; + $this->camera = new class($this, 24, 8, worldSpace: array_fill(0, 30, str_repeat('.', 60))) extends Camera { + public int $stops = 0; + public int $starts = 0; + public int $suspends = 0; + public bool $throwOnStop = false; + public function start(): void { $this->starts++; } + public function suspend(): void { $this->suspends++; } + public function stop(): void + { + $this->stops++; + if ($this->throwOnStop) { throw new RuntimeException('camera stop failed'); } + } + }; + $this->fieldState = new class(new SceneStateContext($this)) extends FieldState { + public int $renders = 0; + public int $resumes = 0; + public function renderTheField(bool $forceFullRepaint = false): void { $this->renders++; } + public function resume(): void { $this->resumes++; } + }; + $this->state = $this->fieldState; + $this->npcManager = new NpcManager($this); + $this->npcManager->configure([['id' => 'guide', 'name' => 'Guide', 'sprite' => 'N', 'x' => 7, 'y' => 4]]); + $this->cinematicStage = new CinematicStageManager($this); + $this->cinematicPresentation = new CinematicPresentationManager($this); + $this->cinematicController = new CinematicController($this); + $this->testPresentation = new TeardownPresentation(); + $this->eventInterpreter = new EventInterpreter($this, $this->testPresentation); + $game->sceneManager->addScenes($this); + $game->sceneManager->currentScene = $this; + $this->start(); + } + public function renderBackgroundTile(int $x, int $y): void {} + public function refreshFieldMusic(bool $force = false, bool $keepSilence = false): void { $this->musicRefreshes++; } + public function restoreBackgroundMusic(): void {} + public function deferFieldWork(): void + { + $this->hasDeferredAutoSave = true; + $this->hasPendingAutomaticTriggerEvaluation = true; + } +} + +function teardownCinematic(): CinematicDefinition +{ + return CinematicDefinition::fromArrays([ + 'id' => 'teardown', 'name' => 'Teardown', + 'cast' => [['id' => 'pose', 'sprite' => '@', 'subject' => ['kind' => 'npc', 'id' => 'guide']]], + 'finalizer' => [['type' => 'set_switch', 'name' => 'must-not-run', 'value' => true]], + ], [['type' => 'parallel', 'lanes' => [ + [['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'secondsPerStep' => 1, + 'steps' => [['direction' => 'right', 'count' => 5]]]], + [['type' => 'camera', 'operation' => 'shake', 'seconds' => 10]], + ]]]); +} + +beforeEach(function () { + $this->staticBefore = []; + foreach ([Console::class, Cursor::class, InputManager::class, EventManager::class, + ConfigStore::class, Timers::class, Debug::class] as $class) { + $this->staticBefore[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->logDirectory = sys_get_temp_dir() . '/ichiloto-teardown-' . bin2hex(random_bytes(8)); + $this->previousDirectory = getcwd(); + Debug::configure(['log_directory' => $this->logDirectory]); + putSceneAudioConfig(['save' => ['autosave' => true]]); + new ReflectionProperty(EventManager::class, 'instance')->setValue(null, null); + foreach (['frameDepth' => 0, 'isRecomposing' => false, 'terminalHandedBack' => false, + 'output' => null, 'terminalOutputStream' => null] as $name => $value) { + new ReflectionProperty(Console::class, $name)->setValue(null, $value); + } + ob_start(); + Console::syncDimensions(24, 8); + Console::setTerminalOutputEnabled(false); + $this->game = new TeardownGame(); +}); + +afterEach(function () { + $this->game->quit(); + chdir($this->previousDirectory); + ob_end_clean(); + foreach ($this->staticBefore as $class => $state) { + foreach ($state as $name => $value) { + new ReflectionProperty($class, $name)->setValue(null, $value); + } + } + if (is_dir($this->logDirectory . '/assets')) { + unlink($this->logDirectory . '/assets/Data/troops.php'); + rmdir($this->logDirectory . '/assets/Data'); + rmdir($this->logDirectory . '/assets'); + } + foreach (glob($this->logDirectory . '/*') ?: [] as $file) { unlink($file); } + if (is_dir($this->logDirectory)) { rmdir($this->logDirectory); } + elseif (is_file($this->logDirectory)) { unlink($this->logDirectory); } +}); + +it('continues scene and platform cleanup once after a scene throws, including a partial start', function () { + $broken = new TeardownProbeScene(true, function () { + $this->game->tickWhileBlocked(); + throw new RuntimeException('scene stop failed'); + }); + $active = new TeardownProbeScene(true); + $partial = new TeardownProbeScene(false); + $unused = new TeardownProbeScene(false); + $this->game->sceneManager->addScenes($broken, $active, $partial, $unused); + $this->game->sceneManager->currentScene = $partial; + $transport = new FakeRendererTransport(); + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), __DIR__, + protocol: RendererProtocolVersion::V1), $transport); + $runtime->start('Teardown test', 24, 8); + $this->game->useRendererRuntime($runtime); + Timers::setFrameTick(fn() => throw new RuntimeException('stale frame callback')); + $this->game->quit(); + $this->game->quit(); + $this->game->updateAfterQuit(); + expect([$broken->stops, $active->stops, $partial->stops, $unused->stops])->toBe([1, 1, 1, 0]) + ->and($this->game->audioManager->shutdowns)->toBe(1) + ->and($transport->shutdowns)->toBe(1) + ->and(new ReflectionProperty(Timers::class, 'frameTick')->getValue())->toBeNull() + ->and(file_get_contents($this->logDirectory . '/error.log'))->toContain('scene stop failed'); +}); + +it('releases a cinematic route, effect, real-subject lease and input without a finalizer or duplicate outcome', function () { + $scene = new TeardownField($this->game); + $outcome = new TeardownOutcome(); + $session = $scene->startCinematic(teardownCinematic(), $outcome); + $scene->eventInterpreter->update(0.1); + $scene->eventInterpreter->update(0.1); + $lanes = $session->rootLane()->parallelGroup->lanes(); + $route = $lanes[0]->pendingOperation; + $effect = $lanes[1]->pendingOperation; + $npc = $scene->npcManager->findById('guide'); + expect($npc->position->x)->toBe(8.0)->and($scene->cinematicStage->suppresses($npc))->toBeTrue(); + $scene->cinematicPresentation->hideField(); + $scene->cinematicPresentation->showOverlay('title_card', 'temporary'); + $scene->gameState->setSwitch('committed', true); + $scene->deferFieldWork(); + $renders = $scene->fieldState->renders; + $scene->testPresentation->onReset = function () use ($scene): void { + $scene->resume(); + $scene->restoreFieldAfterOverlay(); + $scene->autoSave(); + }; + $outcome->onFailure = function () use ($scene): void { + $scene->stop(); + expect($scene->startCinematic('not-a-real-asset'))->toBeNull() + ->and($scene->startEventScript([['type' => 'wait', 'seconds' => 10]]))->toBeNull(); + }; + $this->game->quit(); + $this->game->quit(); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($route->isComplete)->toBeTrue()->and($effect->isComplete)->toBeTrue() + ->and($scene->hasUnstableEventSession())->toBeFalse() + ->and($scene->cinematicController->active())->toBeNull() + ->and($scene->cinematicStage->all())->toBe([]) + ->and($scene->cinematicStage->suppresses($npc))->toBeFalse() + ->and($scene->cinematicPresentation->hasTransitionCover())->toBeFalse() + ->and(new ReflectionProperty(CinematicPresentationManager::class, 'overlay')->getValue($scene->cinematicPresentation))->toBeNull() + ->and($npc->position->x)->toBe(7.0) + ->and($scene->camera->followsPlayer)->toBeTrue() + ->and($scene->fieldState->renders)->toBe($renders)->and($scene->fieldState->resumes)->toBe(0) + ->and($scene->musicRefreshes)->toBe(0)->and($this->game->sceneManager->saveManager->writes)->toBe(0) + ->and($scene->hasDeferredAutoSave)->toBeFalse() + ->and(new ReflectionProperty(GameScene::class, 'hasPendingAutomaticTriggerEvaluation')->getValue($scene))->toBeFalse() + ->and($scene->gameState->getSwitch('committed'))->toBeTrue() + ->and($scene->gameState->getSwitch('must-not-run'))->toBeFalse() + ->and($scene->hasStoryEvent('cinematic:teardown:completed'))->toBeFalse() + ->and([$outcome->completed, $outcome->failed, $scene->camera->stops])->toBe([0, 1, 1]); +}); + +it('continues platform teardown even when reporting a scene cleanup error also throws', function () { + file_put_contents($this->logDirectory, 'not a log directory'); + $broken = new TeardownProbeScene(true, fn() => throw new RuntimeException('scene cleanup failed')); + $later = new TeardownProbeScene(true); + $this->game->sceneManager->addScenes($broken, $later); + set_error_handler(static function (int $severity, string $message): never { + throw new ErrorException($message, 0, $severity); + }); + try { + $this->game->quit(); + } finally { + restore_error_handler(); + } + expect([$broken->stops, $later->stops, $this->game->audioManager->shutdowns])->toBe([1, 1, 1]); +}); + +it('fails an ordinary event and cancels its route without rolling back committed movement', function () { + $scene = new TeardownField($this->game); + $outcome = new TeardownOutcome(); + $session = $scene->startEventScript([['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', + 'steps' => [['direction' => 'right', 'count' => 5]]]], 'ordinary', $outcome); + $scene->eventInterpreter->update(0.1); + $route = $session->rootLane()->pendingOperation; + $position = $scene->npcManager->findById('guide')->position->x; + $this->game->quit(); + expect($session->status)->toBe(EventExecutionStatus::FAILED)->and($route->isComplete)->toBeTrue() + ->and($scene->npcManager->findById('guide')->position->x)->toBe($position) + ->and($scene->hasUnstableEventSession())->toBeFalse() + ->and([$outcome->completed, $outcome->failed])->toBe([0, 1]); +}); + +it('allows a stopped field to start afresh without reviving the previous continuation', function () { + $scene = new TeardownField($this->game); + $outcome = new TeardownOutcome(); + $session = $scene->startCinematic(CinematicDefinition::fromArrays(['id' => 'completed', 'name' => 'Completed'], + [['type' => 'wait', 'seconds' => 10]]), $outcome); + $scene->deferFieldWork(); + $scene->stop(); + $scene->start(); + expect($scene->isStarted())->toBeTrue() + ->and($scene->startEventScript([['type' => 'wait', 'seconds' => 10]]))->not->toBeNull(); + $this->game->quit(); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and([$outcome->completed, $outcome->failed])->toBe([0, 1]); +}); + +it('does not repeat a completed cinematic outcome or undo committed transforms at quit', function () { + $scene = new TeardownField($this->game); + $outcome = new TeardownOutcome(); + $session = $scene->startCinematic(CinematicDefinition::fromArrays([ + 'id' => 'completed', 'name' => 'Completed', + 'cast' => [['id' => 'pose', 'sprite' => '@', 'subject' => ['kind' => 'npc', 'id' => 'guide']]], + ], [['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', + 'steps' => [['direction' => 'right', 'count' => 1]]]]), $outcome); + $scene->eventInterpreter->update(1); + expect($session->status)->toBe(EventExecutionStatus::COMPLETED); + $this->game->quit(); + expect([$outcome->completed, $outcome->failed])->toBe([1, 0]) + ->and($scene->npcManager->findById('guide')->position->x)->toBe(8.0) + ->and($scene->hasStoryEvent('cinematic:completed:completed'))->toBeTrue() + ->and($this->game->sceneManager->saveManager->writes)->toBe(0); +}); + +it('releases cinematic ownership even when a failure callback and camera stop throw', function () { + $scene = new TeardownField($this->game); + $outcome = new TeardownOutcome(); + $outcome->onFailure = fn() => throw new RuntimeException('outcome callback failed'); + $scene->camera->throwOnStop = true; + $session = $scene->startCinematic(teardownCinematic(), $outcome); + $later = new TeardownProbeScene(true); + $this->game->sceneManager->addScenes($later); + $this->game->quit(); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($scene->hasUnstableEventSession())->toBeFalse() + ->and($scene->cinematicController->active())->toBeNull() + ->and($scene->cinematicStage->all())->toBe([]) + ->and($scene->isStarted())->toBeFalse() + ->and([$outcome->failed, $later->stops, $this->game->audioManager->shutdowns])->toBe([1, 1, 1]); +}); + +it('disposes an ordinary event at a true scene unload', function () { + $scene = new TeardownField($this->game); + $outcome = new TeardownOutcome(); + $session = $scene->startEventScript([['type' => 'wait', 'seconds' => 10]], 'continuation', $outcome); + $this->game->sceneManager->unloadScene($scene); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($scene->hasUnstableEventSession())->toBeFalse() + ->and([$outcome->failed, $scene->camera->stops])->toBe([1, 1]); +}); + +function startTeardownBattle(TeardownGame $game, string $directory, bool $cinematic, bool $advance = true, bool $immediate = false): array +{ + mkdir($directory . '/assets/Data', 0777, true); + file_put_contents($directory . '/assets/Data/troops.php', ' "Teardown Troop", "enemies" => []]];'); + chdir($directory); + ConfigStore::put(EnemyStore::class, makeBareScene(EnemyStore::class)); + $scene = new TeardownField($game); + $battle = new TeardownBattle($game); + $game->sceneManager->addScenes($battle, new TeardownGameOver(), new TeardownTitle()); + $outcome = new TeardownOutcome(); + $commands = [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'steps' => [['direction' => 'right', 'count' => 1]]], + ['type' => 'start_battle', 'troop' => 'Teardown Troop', 'resultVariable' => 'battle-result'], + ['type' => 'set_variable', 'name' => 'after-battle', 'value' => 1], + ]; + if ($immediate) { array_shift($commands); } + $session = $cinematic + ? $scene->startCinematic(CinematicDefinition::fromArrays([ + 'id' => 'battle-continuation', 'name' => 'Battle Continuation', + 'presentation' => ['initial' => 'black'], + 'cast' => [['id' => 'pose', 'sprite' => '@', 'subject' => ['kind' => 'npc', 'id' => 'guide']]], + ], $commands), $outcome) + : $scene->startEventScript($commands, 'battle-continuation', $outcome); + if ($advance) { $scene->eventInterpreter->update(1); } + return [$scene, $battle, $session, $outcome]; +} + +it('suspends real scripted battle ownership and resumes it exactly once without restarting either scene', function (bool $cinematic) { + $events = []; + EventManager::getInstance($this->game)->addEventListener(EventType::SCENE, + function (SceneEvent $event) use (&$events): void { $events[] = $event->sceneEventType; }); + [$scene, $battle, $session, $outcome] = startTeardownBattle($this->game, $this->logDirectory, $cinematic); + expect($session->failureMessage)->toBeNull()->and($session->status)->toBe(EventExecutionStatus::SUSPENDED) + ->and($this->game->sceneManager->currentScene)->toBe($battle) + ->and($scene->isStarted())->toBeTrue()->and($scene->camera->stops)->toBe(0) + ->and($scene->camera->suspends)->toBe(1) + ->and($scene->npcManager->findById('guide')->position->x)->toBe(8.0) + ->and($events)->toContain(SceneEventType::SUSPEND)->not->toContain(SceneEventType::UNLOAD); + if ($cinematic) { + expect($scene->cinematicController->active())->not->toBeNull() + ->and($scene->cinematicStage->all())->toHaveCount(1); + } + $battle->result = new BattleResult('Victory'); + $battle->stop(); // The existing BattleEndState stops before handing back. + $this->game->sceneManager->returnFromBattleScene(); + $this->game->sceneManager->returnFromBattleScene(); + $scene->eventInterpreter->update(1); + expect($session->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($this->game->sceneManager->currentScene)->toBe($scene) + ->and($scene->gameState->getVariable('battle-result'))->toBe('victory') + ->and($scene->gameState->getVariable('after-battle'))->toBe(1) + ->and([$outcome->completed, $outcome->failed])->toBe([1, 0]) + ->and([$scene->camera->starts, $scene->fieldState->resumes, $battle->camera->starts, + $battle->camera->stops, $battle->configures, $this->game->engineSelections])->toBe([1, 1, 1, 1, 1, 1]) + ->and($events)->toContain(SceneEventType::RESUME); +})->with([false, true]); + +it('does not resume or repaint a suspended field when a battle modal closes', function () { + [$scene, $battle, $session] = startTeardownBattle($this->game, $this->logDirectory, true); + $renders = $scene->fieldState->renders; + $events = EventManager::getInstance($this->game); + $events->dispatchEvent(new ModalEvent(ModalEventType::OPEN, null)); + $events->dispatchEvent(new ModalEvent(ModalEventType::CLOSE, null)); + expect($scene->fieldState->resumes)->toBe(0)->and($scene->fieldState->renders)->toBe($renders) + ->and($scene->camera->suspends)->toBe(1)->and($session->status)->toBe(EventExecutionStatus::SUSPENDED) + ->and($this->game->sceneManager->currentScene)->toBe($battle); +}); + +it('rejects untracked or nested suspension without disturbing the active battle caller', function () { + $scene = new TeardownField($this->game); + $this->game->sceneManager->addScenes(new TeardownBattle($this->game), new TeardownTitle()); + foreach ([TitleScene::class, BattleScene::class] as $target) { + expect(fn() => $this->game->sceneManager->loadScene($target, suspendCurrent: true))->toThrow(LogicException::class); + } + expect($this->game->sceneManager->currentScene)->toBe($scene)->and($scene->camera->suspends)->toBe(0); + $this->game->sceneManager->loadBattleScene($scene->party, new Troop('Test')); + expect(fn() => $this->game->sceneManager->loadBattleScene($scene->party, new Troop('Nested')))->toThrow(LogicException::class) + ->and($this->game->engineSelections)->toBe(1)->and($scene->camera->suspends)->toBe(1); + $this->game->sceneManager->returnFromBattleScene(); + expect($this->game->sceneManager->currentScene)->toBe($scene)->and($scene->fieldState->resumes)->toBe(1); +}); + +it('does not paint a cinematic over battle when control changes during controller start or a field tick', function (bool $immediate) { + [$scene, $battle, $session] = startTeardownBattle($this->game, $this->logDirectory, true, advance: false, immediate: $immediate); + $renders = $scene->fieldState->renders; + if (! $immediate) { + $scene->fieldState->execute(); + } + expect($session->failureMessage)->toBeNull()->and($session->status)->toBe(EventExecutionStatus::SUSPENDED) + ->and($this->game->sceneManager->currentScene)->toBe($battle) + ->and($scene->fieldState->renders)->toBe($renders) + ->and(Console::charAt(0, 0))->toBe('B'); +})->with([false, true]); + +it('disposes retained battle callers on quit or abandonment without completion or autosave', function (bool $cinematic, string $exit) { + [$scene, $battle, $session, $outcome] = startTeardownBattle($this->game, $this->logDirectory, $cinematic); + $scene->deferFieldWork(); + $renders = $scene->fieldState->renders; + if ($exit === 'quit') { + $this->game->quit(); + } elseif ($exit === 'defeat') { + // Mirrors the existing default-defeat callback before the game-over load. + $scene->failEventAfterBattle('Default defeat'); + $this->game->sceneManager->loadGameOverScene(); + } else { + $this->game->sceneManager->loadScene(TitleScene::class); + } + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($scene->hasUnstableEventSession())->toBeFalse() + ->and($scene->cinematicStage->all())->toBe([])->and($scene->cinematicController->active())->toBeNull() + ->and($scene->npcManager->findById('guide')->position->x)->toBe($cinematic ? 7.0 : 8.0) + ->and($scene->fieldState->renders)->toBe($renders) + ->and([$outcome->completed, $outcome->failed, $scene->camera->stops, $battle->camera->stops])->toBe([0, 1, 1, 1]) + ->and($this->game->sceneManager->saveManager->writes)->toBe(0) + ->and(new ReflectionProperty(SceneManager::class, 'sceneBeforeBattle')->getValue($this->game->sceneManager))->toBeNull(); +})->with([false, true])->with(['quit', 'defeat', 'title']); + +it('abandons real retained callers through Pause confirmation once even if a failure callback throws', function (bool $cinematic, string $destination, bool $throw) { + [$scene, $battle, $session, $outcome] = startTeardownBattle($this->game, $this->logDirectory, $cinematic); + if ($throw) { $outcome->onFailure = fn() => throw new RuntimeException('pause abandonment failure'); } + ConfigStore::put(\Ichiloto\Engine\Util\Config\PlaySettings::class, + new \Ichiloto\Engine\Util\Config\PlaySettings(['width' => 135, 'height' => 36])); + Console::syncDimensions(135, 36); + InputManager::setInputSource(new class implements \Ichiloto\Engine\IO\InputSources\InputSourceInterface { + public function poll(): ?\Ichiloto\Engine\IO\Enumerations\KeyCode { return null; } + public function reset(bool $drainBufferedInput = false): void {} + }); + InputManager::setBindings(['confirm' => ['keys' => [\Ichiloto\Engine\IO\Enumerations\KeyCode::ENTER]]]); + $pause = new class(new SceneStateContext($battle)) extends \Ichiloto\Engine\Scenes\Battle\States\BattlePauseState { + public float $now = 0; + protected function createMenu(): \Ichiloto\Engine\Battle\Presentation\BattlePauseMenu + { + return new \Ichiloto\Engine\Battle\Presentation\BattlePauseMenu(clock: fn(): float => $this->now); + } + }; + new ReflectionProperty(BattleScene::class, 'pauseState')->setValue($battle, $pause); + $battle->setState($pause); + $pause->now = 0.2; $pause->execute(); + $pause->menu->navigate($destination === 'title' ? 2 : 3); + new ReflectionProperty(InputManager::class, 'keyPress')->setValue(null, \Ichiloto\Engine\IO\Enumerations\KeyCode::ENTER); + $pause->execute(); + $pause->now += 0.08; $pause->execute(); $pause->now += 0.08; $pause->execute(); + expect($pause->menu->selection)->toBe(0)->and($session->status)->toBe(EventExecutionStatus::SUSPENDED); + $pause->menu->navigate(1); + new ReflectionProperty(InputManager::class, 'keyPress')->setValue(null, \Ichiloto\Engine\IO\Enumerations\KeyCode::ENTER); + $pause->execute(); + $oldMenu = $pause->menu; + $renders = $scene->fieldState->renders; + $pause->now += 0.13; $pause->execute(); + $pause->now += 1; $pause->execute(); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and([$outcome->completed, $outcome->failed])->toBe([0, 1]) + ->and($scene->cinematicStage->all())->toBe([]) + ->and($scene->npcManager->findById('guide')->position->x)->toBe($cinematic ? 7.0 : 8.0) + ->and($scene->fieldState->renders)->toBe($renders) + ->and($this->game->sceneManager->saveManager->writes)->toBe(0) + ->and($pause->menu)->toBeNull()->and($oldMenu->tick())->toBeNull() + ->and(new ReflectionProperty(SceneManager::class, 'sceneBeforeBattle')->getValue($this->game->sceneManager))->toBeNull(); + if ($destination === 'title') { expect($this->game->sceneManager->currentScene)->toBeInstanceOf(TitleScene::class); } + else { expect($this->game->hasStopped())->toBeTrue(); } +})->with([false, true])->with(['title', 'quit'])->with([false, true]); + +it('completes an ordinary battle through the real end state without invoking an unused Pause cleanup', function () { + [$scene, $battle, $session, $outcome] = startTeardownBattle($this->game, $this->logDirectory, false); + $engine = $this->createMock(\Ichiloto\Engine\Battle\Interfaces\BattleEngineInterface::class); + $engine->expects($this->once())->method('stop'); + new ReflectionProperty(Game::class, 'engine')->setValue($this->game, $engine); + $pause = new class(new SceneStateContext($battle)) extends \Ichiloto\Engine\Scenes\Battle\States\BattlePauseState { + public function exit(): void { throw new RuntimeException('Unused Pause cleanup must not run.'); } + }; + new ReflectionProperty(BattleScene::class, 'pauseState')->setValue($battle, $pause); + $battle->result = new BattleResult('Victory'); + $battle->setState(new \Ichiloto\Engine\Scenes\Battle\States\BattleEndState(new SceneStateContext($battle))); + $scene->eventInterpreter->update(1); + expect($pause->hasOwnedResources())->toBeFalse() + ->and($this->game->sceneManager->currentScene)->toBe($scene) + ->and($scene->gameState->getVariable('battle-result'))->toBe('victory') + ->and([$outcome->completed, $outcome->failed])->toBe([1, 0]) + ->and($session->status)->toBe(EventExecutionStatus::COMPLETED); +}); diff --git a/tests/Unit/BattlePauseLifecycleTest.php b/tests/Unit/BattlePauseLifecycleTest.php new file mode 100644 index 00000000..18a3ebaa --- /dev/null +++ b/tests/Unit/BattlePauseLifecycleTest.php @@ -0,0 +1,507 @@ +key; $this->key = null; return $key; } + public function reset(bool $drainBufferedInput = false): void { if ($drainBufferedInput) { $this->key = null; } } +} + +final class PauseGameFixture extends Game +{ + public int $quits = 0; + public function __construct() + { + $this->sceneManager = new ReflectionClass(SceneManager::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(SceneManager::class, 'game')->setValue($this->sceneManager, $this); + new ReflectionProperty(SceneManager::class, 'scenes')->setValue($this->sceneManager, new ItemList(SceneInterface::class)); + $this->engine = new class($this) extends ActiveTimeBattleEngine { + public int $starts = 0; + public int $runs = 0; + public function start(): void { $this->starts++; parent::start(); } + public function run(BattleEngineContextInterface $context): void + { + $this->runs++; + $this->progressGauges($this->turnStateExecutionContext); + } + public function snapshot(): array + { + return [$this->gaugeValues, $this->readyBattlers, $this->gaugeTime, $this->readyTimes, + $this->turnStateExecutionContext, $this->state, $this->turnQueue]; + } + }; + } + public function __destruct() {} + public function quit(): void + { + $this->quits++; + new ReflectionProperty(Game::class, 'terminalCleanedUp')->setValue($this, true); + $this->sceneManager->stop(); + } +} + +final class PauseBattleFixture extends BattleScene +{ + public float $now = 0; + public int $baseFrames = 0; + public bool $graphical = false; + public ?PresentationCanvas $graphicalFrame = null; + public function __construct(PauseGameFixture $game) + { + $this->sceneManager = $game->sceneManager; + $this->eventManager = EventManager::getInstance($game); + $this->camera = new class extends Camera { + public int $updates = 0; + public function __construct() {} + public function start(): void {} + public function update(): void { $this->updates++; } + public function stop(): void {} + public function suspend(): void {} + public function resizeViewport(int $width, int $height): void {} + }; + $party = new Party(); + $party->addMember(new Character('Pause Hero', 1, new Stats(currentHp: 100, speed: 10))); + $this->config = new BattleConfig($party, new Troop('Pause test'), settings: ['firstStrike' => 'normal']); + $this->ui = new class($this) extends BattleScreen { + public int $updates = 0; + public function update(): void { $this->updates++; parent::update(); } + }; + $this->sceneStateContext = new SceneStateContext($this); + $this->runState = new BattleRunState($this->sceneStateContext); + $this->pauseState = new class($this->sceneStateContext) extends BattlePauseState { + protected function createMenu(): BattlePauseMenu + { + return new BattlePauseMenu(clock: fn(): float => $this->scene->now); + } + }; + $game->sceneManager->currentScene = $this; + $game->sceneManager->addScenes($this); + $this->setState($this->runState); + $this->start(); + } + public function getPresentationCanvas(): ?PresentationCanvas + { + if ($this->state instanceof BattlePauseState) { return parent::getPresentationCanvas(); } + $this->baseFrames++; + return $this->graphical ? ($this->graphicalFrame ?? new PresentationCanvas(1350, 720)) : null; + } +} + +function pauseKey(PauseBattleFixture $scene, PauseInputFixture $source, ?KeyCode $key = null, float $advance = 0): void +{ + $scene->now += $advance; + $source->key = $key; + InputManager::handleInput(); + $scene->update(); +} + +beforeEach(function () { + $this->statics = []; + foreach ([Console::class, InputManager::class, EventManager::class, ConfigStore::class, Time::class] as $class) { + $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->input = InputManager::getInputSource(); + $this->bindings = InputManager::getBindings(); + $this->delta = new ReflectionProperty(Time::class, 'deltaTime')->getValue(); + $this->event = new ReflectionProperty(EventManager::class, 'instance')->getValue(); + new ReflectionProperty(EventManager::class, 'instance')->setValue(null, null); + $this->configs = new ReflectionProperty(ConfigStore::class, 'store')->getValue(); + new ReflectionProperty(ConfigStore::class, 'store')->setValue(null, []); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + putSceneAudioConfig([]); + Console::setTerminalOutputEnabled(false); + new ReflectionProperty(Console::class, 'terminalHandedBack')->setValue(null, false); + Console::syncDimensions(135, 36); + Console::clear(); + Console::setLayerTracking(true); + $this->source = new PauseInputFixture(); + InputManager::setInputSource($this->source); + InputManager::setBindings([ + 'pause' => ['keys' => [KeyCode::P]], 'confirm' => ['keys' => [KeyCode::ENTER]], + 'cancel' => ['keys' => [KeyCode::C]], 'quit' => ['keys' => [KeyCode::Q]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]], + ]); + $this->game = new PauseGameFixture(); + $this->scene = new PauseBattleFixture($this->game); +}); + +afterEach(function () { + $this->scene->pauseState?->exit(); + foreach ($this->statics as $class => $values) { + foreach ($values as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } +}); + +it('retains real ATB startup context gauges queue and actions across repeated pauses with no opening fallthrough', function () { + $engine = $this->game->engine; + $before = $engine->snapshot(); + for ($repeat = 0; $repeat < 3; $repeat++) { + pauseKey($this->scene, $this->source, KeyCode::P); + $cameraUpdates = $this->scene->camera->updates; + expect($this->scene->state)->toBe($this->scene->pauseState)->and(Input::isButtonDown('pause'))->toBeFalse(); + pauseKey($this->scene, $this->source, KeyCode::ENTER, 60); + expect($engine->snapshot())->toBe($before)->and($engine->runs)->toBe(0) + ->and($this->scene->camera->updates)->toBe($cameraUpdates); + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, KeyCode::ENTER, 0.2); + expect($this->scene->state)->toBe($this->scene->runState)->and($engine->starts)->toBe(1) + ->and($engine->snapshot())->toBe($before)->and(Input::isButtonDown('confirm'))->toBeFalse(); + } + new ReflectionProperty(Time::class, 'deltaTime')->setValue(null, 0.01); + pauseKey($this->scene, $this->source); + expect($engine->runs)->toBe(1)->and($engine->snapshot()[2] - $before[2])->toBe(0.01); +}); + +it('reuses Config without entering the field and returns Config focus with opening and closing input consumed', function () { + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, null, 0.2); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, KeyCode::ENTER, 0.2); + expect($this->scene->pauseState->configMenu)->not->toBeNull()->and($this->game->engine->runs)->toBe(0) + ->and($this->game->sceneManager->currentScene)->toBe($this->scene); + pauseKey($this->scene, $this->source, KeyCode::C); + expect($this->scene->pauseState->configMenu)->toBeNull()->and($this->scene->pauseState->menu->selection)->toBe(1) + ->and($this->scene->state)->toBe($this->scene->pauseState)->and(Input::isButtonDown('cancel'))->toBeFalse(); +}); + +it('repaints resize and modal resume without resetting selection or confirmation or rebuilding the retained frame', function () { + $this->scene->graphical = true; + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, null, 0.2); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, null, 0.08); + pauseKey($this->scene, $this->source, null, 0.08); + $menu = $this->scene->pauseState->menu; + $this->scene->onScreenResize(135, 36); + $this->scene->resume(); + $frame = $this->scene->getPresentationCanvas(); + expect($menu->selection)->toBe(0)->and($menu->heading())->toBe('Return to title?') + ->and($this->scene->pauseState->menu)->toBe($menu)->and($this->scene->baseFrames)->toBe(1) + ->and(array_column($frame->textLayers, 'id'))->toContain('battle-pause'); +}); + +it('discards pending destructive completion on stop and cannot resume or execute after application stop', function () { + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, null, 0.2); + $menu = $this->scene->pauseState->menu; + $menu->navigate(3); $menu->confirm(); + pauseKey($this->scene, $this->source, null, 0.08); + pauseKey($this->scene, $this->source, null, 0.08); + $menu->navigate(1); $menu->confirm(); + $this->scene->stop(); + $this->scene->now += 10; + expect($menu->tick())->toBeNull()->and($this->scene->pauseState->menu)->toBeNull(); + $this->game->quit(); + $this->scene->resume(); $this->scene->resumeBattle(); $this->scene->update(); + expect($this->game->quits)->toBe(1)->and($this->game->engine->runs)->toBe(0); +}); + +it('omits redundant control footer text from terminal pause and confirmations', function (int $selection) { + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, null, 0.2); + for ($index = 0; $index < $selection; $index++) { + pauseKey($this->scene, $this->source, KeyCode::DOWN); + } + if ($selection > 0) { + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, null, 0.08); + pauseKey($this->scene, $this->source, null, 0.08); + } + $rows = new ReflectionProperty(Console::class, 'overlays')->getValue()['battle-pause']['rows']; + $text = implode("\n", array_map(fn($row) => implode('', $row), $rows)); + expect($text)->not->toContain('Confirm', 'Back') + ->and($text)->toContain(...$this->scene->pauseState->menu->labels()); +})->with([0, 2, 3]); + +it('uses actual horizontal confirmation input with side-by-side centered terminal choices', function () { + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, null, 0.2); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, null, 0.08); + pauseKey($this->scene, $this->source, null, 0.08); + $rows = new ReflectionProperty(Console::class, 'overlays')->getValue()['battle-pause']['rows']; + $choice = array_values(array_filter(array_map(fn($row) => implode('', $row), $rows), fn($row) => str_contains($row, 'Cancel'))); + expect($choice)->toHaveCount(1)->and($choice[0])->toContain('Cancel', 'To Title'); + pauseKey($this->scene, $this->source, KeyCode::RIGHT); + expect($this->scene->pauseState->menu->selection)->toBe(1); + pauseKey($this->scene, $this->source, KeyCode::P); + expect($this->scene->pauseState->menu->selection)->toBe(1)->and($this->scene->pauseState->menu->heading())->toBe('Return to title?'); + pauseKey($this->scene, $this->source, KeyCode::C); + pauseKey($this->scene, $this->source, null, 0.08); + pauseKey($this->scene, $this->source, null, 0.08); + expect($this->scene->pauseState->menu->selection)->toBe(2)->and($this->scene->pauseState->menu->heading())->toBe('PAUSED'); +}); + +it('preserves remaining alert and feedback intervals once using each timing owners clock', function () { + $ui = $this->scene->ui; + $field = $ui->fieldWindow; + $feedbackNow = 100.0; + new ReflectionProperty($field, 'feedbackTiming')->setValue($field, + new \Ichiloto\Engine\Battle\Presentation\BattleFeedbackTiming(function () use (&$feedbackNow): float { return $feedbackNow; })); + new ReflectionProperty($field, 'feedback')->setValue($field, [[ + 'sequence' => 1, 'battler' => $this->scene->party->battlers[0], 'lines' => [], 'shownAt' => 99.5, 'durationSeconds' => 2.0, + ]]); + new ReflectionProperty(Time::class, 'time')->setValue(null, 10.0); + $ui->alert('Remaining alert'); + $deadline = new ReflectionProperty($ui, 'alertHideTime')->getValue($ui); + $ui->pauseTiming(); + new ReflectionProperty(Time::class, 'time')->setValue(null, 11.0); + $feedbackNow = 102.0; + $ui->pauseTiming(); // Nested/repeated suspension must not move either start marker. + new ReflectionProperty(Time::class, 'time')->setValue(null, 20.0); + $feedbackNow = 130.0; + $ui->resumeTiming(); $ui->resumeTiming(); + expect(new ReflectionProperty($ui, 'alertHideTime')->getValue($ui) - 20)->toBe($deadline - 10) + ->and($field->getFeedback()[0]['shownAt'])->toBe(129.5) + ->and($field->getFeedback()[0]['durationSeconds'])->toBe(2.0); + $ui->pauseTiming(); + new ReflectionProperty(Time::class, 'time')->setValue(null, 25.0); + $feedbackNow = 134.0; + $ui->resumeTiming(); + expect(new ReflectionProperty($ui, 'alertHideTime')->getValue($ui) - 25)->toBe($deadline - 10) + ->and($field->getFeedback()[0]['shownAt'])->toBe(133.5); + $ui->pauseTiming(); + $this->scene->stop(); + new ReflectionProperty(Time::class, 'time')->setValue(null, 60.0); + $feedbackNow = 200.0; + $ui->resumeTiming(); + expect($field->getFeedback()[0]['shownAt'])->toBe(133.5) + ->and(new ReflectionProperty($ui, 'alertHideTime')->getValue($ui))->toBe($deadline + 15); +}); + +it('releases Config live cells on return resize or stop without changing the retained frame or newer scene writes', function (string $end) { + $this->scene->graphical = true; + Console::write('ORIGINAL', 15, 2); + pauseKey($this->scene, $this->source, KeyCode::P); + $retained = new ReflectionProperty(BattlePauseState::class, 'battlefield')->getValue($this->scene->pauseState); + pauseKey($this->scene, $this->source, null, 0.2); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, null, 0.2); + expect(array_column(Console::presentationSnapshot()->textLayers, 'id'))->toContain('pause-config'); + $config = $this->scene->pauseState->configMenu; + pauseKey($this->scene, $this->source, KeyCode::DOWN); + $setting = $config->selection->getActiveSetting(); + $this->scene->onScreenResize(135, 36); + expect($config->selection->getActiveSetting())->toBe($setting) + ->and(new ReflectionProperty(BattlePauseState::class, 'battlefield')->getValue($this->scene->pauseState))->toBe($retained); + if ($end === 'return') { + pauseKey($this->scene, $this->source, KeyCode::C); + } elseif ($end === 'new-scene') { + Console::write('NEW SCENE', 15, 2); + $this->scene->stop(); + } else { + $this->game->quit(); + } + expect(array_column(Console::presentationSnapshot()->textLayers, 'id'))->not->toContain('pause-config'); + if ($end === 'new-scene') { expect(Console::snapshot()->rows[2])->toContain('NEW SCENE'); } + expect($this->scene->baseFrames)->toBe(1)->and($this->game->engine->starts)->toBe(1); +})->with(['return', 'stop', 'new-scene']); + +it('disposes partial entry without touching a Console layer it never acquired', function () { + Console::withLayer('pause-config', fn() => Console::write('unrelated owner', 0, 0), 10000); + $source = new class implements InputSourceInterface { + public function poll(): ?KeyCode { return null; } + public function reset(bool $drainBufferedInput = false): void { if ($drainBufferedInput) { throw new RuntimeException('partial entry'); } } + }; + InputManager::setInputSource($source); + expect(fn() => $this->scene->pauseBattle())->toThrow(RuntimeException::class, 'partial entry'); + $pause = $this->scene->pauseState; + expect($pause->hasOwnedResources())->toBeTrue(); + $menu = $pause->menu; + $this->scene->stop(); + expect($pause->hasOwnedResources())->toBeFalse()->and($menu->isClosed())->toBeTrue() + ->and(Console::snapshot()->rows[0])->toStartWith('unrelated owner'); +}); + +it('makes only owned Config and unskinned Pause cells opaque over the retained battlefield', function () { + $this->scene->graphical = true; + $field = $this->scene->graphicalFrame = new PresentationCanvas(1350, 720, [ + new CanvasImage('arena', 'test-sprite.png', new CanvasRectangle(0, 0, 1350, 720)), + new CanvasImage('hud', 'test-sprite.png', new CanvasRectangle(0, 600, 1350, 120), 100), + ], textLayers: [new CanvasTextLayer('retained-hud', 101, 0, 600, new RendererGridConfig(135, 6, 10, 20), + [new PresentationTextRun(0, 0, 'Retained HUD', PresentationColor::rgb(255, 255, 255))])]); + $before = $field->toArray(); + Console::write('WORLD MUST NOT BE PROJECTED', 0, 0); + pauseKey($this->scene, $this->source, KeyCode::P); + + $assertCoverage = function (string $id, array $regions) use ($field, $before): void { + $console = Console::presentationSnapshot(); + $source = array_column($console->textLayers, null, 'id')[$id]; + $frame = $this->scene->getPresentationCanvas(); + $layer = array_column($frame->textLayers, null, 'id')[$id]; + $covered = $expected = []; + $colored = $blank = false; + foreach ($source->runs as $index => $run) { + $projected = $layer->runs[$index]; + expect([$projected->row, $projected->column, $projected->text, $projected->foreground?->toArray()]) + ->toBe([$run->row, $run->column, $run->text, $run->foreground?->toArray()]) + ->and($projected->background)->toEqual($run->background ?? PresentationColor::rgb(15, 23, 30)); + $colored = $colored || $run->foreground !== null; + $blank = $blank || str_contains($run->text, ' '); + for ($column = $run->column; $column < $run->column + mb_strlen($run->text, 'UTF-8'); $column++) { + $covered[$run->row . ':' . $column] = true; + } + } + foreach ($regions as [$x, $y, $width, $height]) { + for ($row = $y; $row < $y + $height; $row++) { + for ($column = $x; $column < $x + $width; $column++) { $expected[$row . ':' . $column] = true; } + } + } + ksort($covered); ksort($expected); + expect($covered)->toBe($expected) + ->and($frame->images)->toBe($field->images)->and($frame->indicators)->toBe($field->indicators) + ->and($frame->textLayers)->toHaveCount(count($field->textLayers) + 1) + ->and($frame->textLayers[0])->toBe($field->textLayers[0]) + ->and($field->toArray())->toBe($before) + ->and(Console::presentationSnapshot())->toEqual($console); + if ($id === 'pause-config') { expect($colored)->toBeTrue()->and($blank)->toBeTrue(); } + }; + $overlay = new ReflectionProperty(Console::class, 'overlays')->getValue()['battle-pause']; + $rows = array_keys($overlay['rows']); + $columns = array_keys($overlay['rows'][$rows[0]]); + $assertCoverage('battle-pause', [[min($columns), min($rows), count($columns), count($rows)]]); + + pauseKey($this->scene, $this->source, null, 0.2); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, null, 0.2); + $config = $this->scene->pauseState->configMenu; + $regions = static fn(): array => array_map(static fn($window): array => [ + (int)$window->getPosition()->x, (int)$window->getPosition()->y, $window->getWidth(), $window->getHeight(), + ], [$config->selection, $config->detail]); + $assertCoverage('pause-config', $regions()); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + $assertCoverage('pause-config', $regions()); + $setting = $config->selection->getActiveSetting(); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 150, 'height' => 40])); + Console::syncDimensions(150, 40); + $this->scene->onScreenResize(150, 40); + $assertCoverage('pause-config', $regions()); + expect($config->selection->getActiveSetting())->toBe($setting); + pauseKey($this->scene, $this->source, KeyCode::C); + expect(array_column($this->scene->getPresentationCanvas()->textLayers, 'id'))->not->toContain('pause-config'); + $this->scene->stop(); + expect($this->scene->getPresentationCanvas())->toBeNull()->and($field->toArray())->toBe($before); +}); + +it('uses shared menu-only Config over the frozen battle with honest optional fallback and original back focus', function (string $themeState) { + $root = sys_get_temp_dir() . '/ichiloto-pause-config-' . bin2hex(random_bytes(5)); + mkdir($root . '/Data/Presentation', 0777, true); + $debug = new ReflectionClass(Debug::class)->getStaticProperties(); + Debug::configure(['log_directory' => $root . '/logs']); + $caps = $themeState === 'unsupported' ? ['graphical_canvas'] : MenuPresentationCatalog::CAPABILITIES; + $transport = new FakeRendererTransport(); + $transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $root, + requiredCapabilities: $caps), $transport); + try { + if ($themeState !== 'absent') { + $data = ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]; + if ($themeState === 'invalid') { $data['frames']['slider.track'] = ['asset' => 'missing.png']; } + file_put_contents($root . '/' . MenuPresentationCatalog::FILE, 'start('Config fixture', 135, 36); + $this->game->useRendererRuntime($runtime); + InputManager::setInputSource($this->source); + $field = $this->scene->graphicalFrame = new PresentationCanvas(1350, 720, textLayers: [ + new CanvasTextLayer('retained-field', 50, 0, 0, new RendererGridConfig(10, 1, 10, 20), + [new PresentationTextRun(0, 0, 'Battle', PresentationColor::rgb(250, 250, 250))]), + ]); + $this->scene->graphical = true; + $engine = $this->game->engine->snapshot(); + pauseKey($this->scene, $this->source, KeyCode::P); + pauseKey($this->scene, $this->source, null, 0.2); + pauseKey($this->scene, $this->source, KeyCode::DOWN); + pauseKey($this->scene, $this->source, KeyCode::ENTER); + pauseKey($this->scene, $this->source, null, 0.2); + $config = $this->scene->pauseState->configMenu; + expect($config)->not->toBeNull()->and($this->scene->pauseSkin)->toBeNull(); + $frame = $this->scene->getPresentationCanvas(); + $this->scene->getPresentationCanvas(); + $layers = array_column($frame->textLayers, null, 'id'); + expect($layers['retained-field'])->toBe($field->textLayers[0]); + if ($themeState === 'valid') { + expect($layers)->toHaveKey('config-title')->not->toHaveKey('pause-config')->not->toHaveKey('menu-background'); + $backing = $layers['config-frame']->clipRect; + expect($backing->x)->toBeGreaterThan(0)->and($backing->y)->toBeGreaterThan(0) + ->and($layers['config-frame']->layer)->toBeGreaterThan($layers['retained-field']->layer); + $runtime->present($this->scene); + $wire = $transport->sent[array_key_last($transport->sent)]->payload; + expect(array_column($wire['canvas']['textLayers'], 'id'))->toContain('config-title', 'retained-field') + ->and(is_file($root . '/logs/error.log'))->toBeFalse(); + } else { + expect($layers)->toHaveKey('pause-config')->not->toHaveKey('config-title'); + $text = implode('', array_column($layers['pause-config']->runs, 'text')); + expect($text)->toContain('Volume', '75%', 'Description', 'Sets the master volume'); + if ($themeState !== 'absent') { + $log = file_get_contents($root . '/logs/error.log'); + expect(substr_count($log, 'Config presentation degraded to terminal'))->toBe(1); + } else { expect(is_file($root . '/logs/error.log'))->toBeFalse(); } + } + pauseKey($this->scene, $this->source, KeyCode::DOWN); + $this->scene->onScreenResize(135, 36); + expect($config->selection->getActiveIndex())->toBe(1) + ->and($this->game->engine->snapshot())->toBe($engine)->and($this->game->engine->runs)->toBe(0); + pauseKey($this->scene, $this->source, KeyCode::C); + expect($this->scene->pauseState->configMenu)->toBeNull()->and($this->scene->pauseState->menu->selection)->toBe(1) + ->and(Input::isButtonDown('cancel'))->toBeFalse()->and($this->scene->baseFrames)->toBe(1); + expect(array_column($this->scene->getPresentationCanvas()->textLayers, 'id'))->not->toContain('config-title', 'pause-config'); + } finally { + $runtime->shutdown(); + foreach ($debug as $name => $value) { new ReflectionProperty(Debug::class, $name)->setValue(null, $value); } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($root); + } +})->with(['valid', 'absent', 'unsupported', 'invalid']); diff --git a/tests/Unit/BattlePausePresentationTest.php b/tests/Unit/BattlePausePresentationTest.php new file mode 100644 index 00000000..f6b90ae1 --- /dev/null +++ b/tests/Unit/BattlePausePresentationTest.php @@ -0,0 +1,163 @@ + new CanvasNineSlice('panel.png', new SpriteSourceRect(0, 0, 96, 96), 24, 24, 24, 24), + 'selector' => new CanvasNineSlice('selector.png', new SpriteSourceRect(5, 5, 22, 22)), + 'divider' => new CanvasNineSlice('divider.png', new SpriteSourceRect(0, 0, 96, 16)), + default => new CanvasNineSlice($role . '.png', new SpriteSourceRect(0, 0, 96, 48), 12, 12, 12, 12), + }; + } + return new BattlePauseSkin($textures, array_fill_keys(BattlePauseSkin::COLORS, PresentationColor::rgb(200, 190, 180))); +} + +it('shares opaque Console cell conversion without replacing explicit selection colors or filling gaps', function () { + $foreground = PresentationColor::ansi16(12); + $background = PresentationColor::ansi256(24); + $source = [new PresentationTextRun(3, 5, ' '), new PresentationTextRun(3, 10, 'Selected', $foreground, $background)]; + $runs = BattleCanvasUiAdapter::opaqueRuns($source); + expect($runs)->toHaveCount(2) + ->and([$runs[0]->row, $runs[0]->column, $runs[0]->text])->toBe([3, 5, ' ']) + ->and($runs[0]->background)->toEqual(PresentationColor::rgb(15, 23, 30)) + ->and($runs[1])->toBe($source[1])->and($source[0]->background)->toBeNull() + ->and(BattleCanvasUiAdapter::opaqueRuns([]))->toBe([]); +}); + +it('locks opening input, defaults Resume and emits each exit once without a callback', function () { + $now = 0.0; + $menu = new BattlePauseMenu(clock: function () use (&$now): float { return $now; }); + $menu->open(); + $menu->navigate(3); $menu->confirm(); + expect($menu->selection)->toBe(0)->and($menu->tick())->toBeNull(); + $now = 0.2; $menu->tick(); $menu->confirm(); + expect($menu->tick())->toBeNull()->and($menu->isReady())->toBeFalse(); + $now = 0.4; + expect($menu->tick())->toBe(PauseAction::RESUME)->and($menu->tick())->toBeNull(); + $menu->open(1); $now = 0.6; $menu->tick(); $menu->confirm(); + $menu->close(); $now = 1; + expect($menu->tick())->toBeNull(); + $menu->open(); + expect($menu->selection)->toBe(0)->and($menu->confirmation)->toBeNull(); +}); + +it('defaults destructive confirmations to Cancel and restores the originating focus', function (int $index, PauseAction $action) { + $now = 0.0; + $menu = new BattlePauseMenu(clock: function () use (&$now): float { return $now; }); + $menu->open(); $now = 0.2; $menu->tick(); $menu->navigate($index); $menu->confirm(); + $now += 0.08; $menu->tick(); $now += 0.08; $menu->tick(); + expect($menu->confirmation)->toBe($action)->and($menu->selection)->toBe(0)->and($menu->labels()[0])->toBe('Cancel'); + $menu->back(pauseShortcut: true); + expect($menu->isReady())->toBeTrue()->and($menu->confirmation)->toBe($action); + $menu->confirm(); $now += 0.08; $menu->tick(); $now += 0.08; $menu->tick(); + expect($menu->confirmation)->toBeNull()->and($menu->selection)->toBe($index); + $menu->confirm(); $now += 0.08; $menu->tick(); $now += 0.08; $menu->tick(); + $menu->navigate(1); $menu->confirm(); $now += 0.13; + expect($menu->tick())->toBe($action)->and($menu->tick())->toBeNull(); +})->with([[2, PauseAction::TITLE], [3, PauseAction::EXIT]]); + +it('composes centered independent labels over the unchanged scene at every approved size', function (int $width, int $height, bool $confirm) { + $now = 0.0; + $menu = new BattlePauseMenu(clock: function () use (&$now): float { return $now; }); + $menu->open(); $now = 0.2; $menu->tick(); + if ($confirm) { $menu->navigate(2); $menu->confirm(); $now += 0.08; $menu->tick(); $now += 0.08; $menu->tick(); } + $image = new CanvasImage('actual-scene', 'actual.png', new CanvasRectangle(0, 0, $width, $height)); + $field = new PresentationCanvas($width, $height, [$image]); + foreach (range(0, count($menu->labels()) - 1) as $selection) { + $frame = GraphicalBattlePause::frame($field, pauseSkinFixture(), $menu); + expect($frame->images[0])->toBe($image) + ->and(array_column($frame->textLayers, 'id'))->not->toContain('pause-hints'); + $cursor = array_filter($frame->images, fn($part) => str_starts_with($part->id, 'pause-cursor-')); + expect($cursor)->toHaveCount($confirm ? 0 : 1) + ->and(array_filter($frame->images, fn($part) => str_starts_with($part->id, 'pause-focus-')))->not->toBeEmpty(); + $layers = array_column($frame->textLayers, null, 'id'); + foreach ($menu->labels() as $index => $label) { + $text = $layers['pause-label-' . $index]; + $center = $width / 2 + ($confirm ? ($index === 0 ? -117 : 117) : 0); + expect($text->runs[0]->text)->toBe($label) + ->and($text->bounds->x + $text->bounds->width / 2)->toEqualWithDelta($center, 0.001); + } + foreach ($frame->images as $part) { + if ($part === $image) { continue; } + expect($part->destination->width)->toBeLessThanOrEqual($confirm ? 520 : 400); + } + $menu->navigate(1); $now += 0.3; + } + expect($menu->confirmation !== null)->toBe($confirm); +})->with([[1350, 720], [960, 540], [736, 414]])->with([false, true]); + +it('reserves cursor motion for the root list while confirmation focus stays steady and returns intact', function (bool $reducedMotion, int $index) { + $now = 0.0; + $menu = new BattlePauseMenu($reducedMotion, function () use (&$now): float { return $now; }); + $menu->open($index); $now = 0.2; $menu->tick(); + $field = new PresentationCanvas(1350, 720); + $skin = pauseSkinFixture(); + $cursor = fn($frame) => array_values(array_filter($frame->images, fn($part) => str_starts_with($part->id, 'pause-cursor-'))); + $focus = fn($frame) => array_values(array_filter($frame->images, fn($part) => str_starts_with($part->id, 'pause-focus-'))); + $first = GraphicalBattlePause::frame($field, $skin, $menu); + $now += 0.3; + $next = GraphicalBattlePause::frame($field, $skin, $menu); + expect($cursor($first))->toHaveCount(1)->and($focus($first))->not->toBeEmpty()->toEqual($focus($next)); + if ($reducedMotion) { expect($cursor($first))->toEqual($cursor($next)); } + else { expect($cursor($first)[0]->destination->x)->not->toBe($cursor($next)[0]->destination->x); } + $menu->confirm(); $now += 0.08; $menu->tick(); $now += 0.08; $menu->tick(); + expect($menu->confirmation)->not->toBeNull()->and($menu->selection)->toBe(0); + foreach ([0, 1] as $selection) { + $menu->navigate($selection); + $first = GraphicalBattlePause::frame($field, $skin, $menu); + $now += 0.3; + $next = GraphicalBattlePause::frame($field, $skin, $menu); + $selected = array_filter($first->images, fn($part) => str_starts_with($part->id, 'pause-row-' . $selection . '-')); + expect($cursor($first))->toBe([])->and($cursor($next))->toBe([]) + ->and($focus($first))->not->toBeEmpty()->toEqual($focus($next)) + ->and(array_unique(array_column($selected, 'asset')))->toBe(['selected.png']) + ->and($first->toArray())->toBe($next->toArray()); + } + $menu->navigate(-1); $menu->confirm(); + $pressed = GraphicalBattlePause::frame($field, $skin, $menu); + expect($cursor($pressed))->toBe([])->and($focus($pressed))->not->toBeEmpty() + ->and(array_column($pressed->images, 'asset'))->toContain('pressed.png'); + $now += 0.08; $menu->tick(); $now += 0.08; $menu->tick(); + $restored = GraphicalBattlePause::frame($field, $skin, $menu); + expect($menu->confirmation)->toBeNull()->and($menu->selection)->toBe($index) + ->and($cursor($restored))->toHaveCount(1)->and($focus($restored))->not->toBeEmpty(); +})->with([false, true])->with([2, 3]); + +it('uses short owned fades but no scale or moving cursor in reduced motion', function () { + $now = 0.0; + $menu = new BattlePauseMenu(true, function () use (&$now): float { return $now; }); + $menu->open(); $now = 0.08; + expect($menu->opacity())->toBe(0.5)->and($menu->scale())->toBe(1.0); + $now = 0.2; $menu->tick(); + $field = new PresentationCanvas(1350, 720); + $a = GraphicalBattlePause::frame($field, pauseSkinFixture(), $menu); + $now += 0.6; + $b = GraphicalBattlePause::frame($field, pauseSkinFixture(), $menu); + expect($a->toArray())->toBe($b->toArray()); + $menu->back(); $now += 0.06; + expect($menu->opacity())->toEqualWithDelta(0.5, 0.000001)->and($menu->scale())->toBe(1.0); +}); + +it('rejects incomplete declared skins and leaves legacy catalogs optional', function () { + $skin = pauseSkinFixture(); + $textures = $skin->textures; unset($textures['focus']); + expect(fn() => new BattlePauseSkin($textures, $skin->colors))->toThrow(InvalidArgumentException::class) + ->and((new BattlePresentationCatalog([], [], []))->pause)->toBeNull(); + expect(fn() => GraphicalBattlePause::preflight($skin, sys_get_temp_dir()))->toThrow(RuntimeException::class); +}); diff --git a/tests/Unit/BattleResultWindowTest.php b/tests/Unit/BattleResultWindowTest.php index d7efda40..1ec2c877 100644 --- a/tests/Unit/BattleResultWindowTest.php +++ b/tests/Unit/BattleResultWindowTest.php @@ -1,6 +1,8 @@ and($window->isComplete())->toBeTrue() ->and($window->getHelp())->toBe('enter:Continue'); }); + +it('keeps every structured loot row reachable instead of truncating the result', function () { + $items = []; + for ($i = 1; $i <= 23; $i++) { + $items[] = ['id' => 'loot-' . $i, 'name' => 'Unique Reward ' . $i, 'description' => '', 'quantity' => $i]; + } + $playback = new BattleResultsPlayback(new BattleRewards(0, 0, [], $items), reducedMotion: true); + $window = new BattleResultWindowTestProxy(makeBattleResultTestScreen()); + $window->displayPlayback($playback); + $all = implode("\n", $window->getContent()); + for ($i = 1; $i < $playback->pageCount(); $i++) { + $playback->navigate(1); + $window->displayPlayback($playback); + $all .= "\n" . implode("\n", $window->getContent()); + } + foreach ($items as $item) { expect($all)->toContain($item['name'] . ' x' . $item['quantity']); } + expect($playback->isFinished())->toBeFalse(); + $playback->navigate(1); + expect($playback->scrollOffset)->toBe($playback->pageCount() - 1); + $playback->navigate(-1); + expect($playback->scrollOffset)->toBe($playback->pageCount() - 2); +}); + +it('shows retained quantities rather than claiming capped inventory received every drop', function () { + $playback = new BattleResultsPlayback(new BattleRewards(0, 2, [], [ + ['id' => 'potion', 'name' => 'Potion', 'description' => '', 'quantity' => 4, 'received' => 1], + ]), reducedMotion: true); + $window = new BattleResultWindowTestProxy(makeBattleResultTestScreen()); + $window->displayPlayback($playback); + expect(implode("\n", $window->getContent()))->toContain('Potion x4 (retained 1)'); +}); + +it('hides the terminal action hint while its replacement is input locked', function () { + $playback = new BattleResultsPlayback(new BattleRewards(0, 0, [])); + $window = new BattleResultWindowTestProxy(makeBattleResultTestScreen()); + $window->displayPlayback($playback); + expect($window->getHelp())->toBe('enter:Complete'); + $playback->confirm(); + $window->displayPlayback($playback); + expect($window->getHelp())->toBe(''); + $playback->update(0.33); + $window->displayPlayback($playback); + expect($window->getHelp())->toBe('enter:Continue'); +}); diff --git a/tests/Unit/BattleResultsPlaybackTest.php b/tests/Unit/BattleResultsPlaybackTest.php new file mode 100644 index 00000000..624be698 --- /dev/null +++ b/tests/Unit/BattleResultsPlaybackTest.php @@ -0,0 +1,159 @@ + 0, 2 => 100, 3 => 200, 4 => 300, 5 => 500]; + $snapshot = fn(int $exp, array $stats) => new ProgressionSnapshot($id, ucfirst($id), + $exp >= 500 ? 5 : min(4, intdiv($exp, 100) + 1), $exp, 5, $thresholds, $stats); + return new BattleProgression($to - $from, $snapshot($from, ['maxHp' => 80, 'speed' => 5, 'grace' => 5]), + $snapshot($to, ['maxHp' => 100, 'speed' => 4, 'grace' => 5]), $learned); +} + +it('requires distinct confirmations and finishes an exit on update without a third press', function () { + $playback = new BattleResultsPlayback(new BattleRewards(0, 0, [])); + expect($playback->isComplete())->toBeFalse()->and($playback->confirm())->toBeFalse() + ->and($playback->isComplete())->toBeTrue()->and($playback->confirm())->toBeFalse() + ->and($playback->isExiting())->toBeFalse(); + $playback->update(0.33); + expect($playback->confirm())->toBeFalse()->and($playback->isExiting())->toBeTrue(); + $playback->update(0.42); + expect($playback->isFinished())->toBeTrue()->and($playback->confirm())->toBeTrue(); +}); + +it('locks confirmation through the outgoing button gap and incoming button', function (bool $natural) { + $p = new BattleResultsPlayback(new BattleRewards(260, 0, [playbackAward()])); + $p->update($natural ? 2.25 : 0.5); + if (!$natural) { $p->confirm(); } + expect($p->isComplete())->toBeTrue() + ->and($p->confirmation())->toBe(['label' => 'Complete', 'opacity' => 1.0, 'enabled' => false]); + foreach ([[0.06, 'Complete', 0.5], [0.09, 'Continue', 0.0], [0.10, 'Continue', 0.5]] as [$delta, $label, $alpha]) { + $p->update($delta); + $button = $p->confirmation(); + expect($button['label'])->toBe($label)->and($button['opacity'])->toEqualWithDelta($alpha, 0.000001) + ->and($button['enabled'])->toBeFalse()->and($p->confirm())->toBeFalse() + ->and($p->currentStage()['kind'])->toBe('primary'); + } + $p->update(0.09); + expect($p->confirmation())->toBe(['label' => 'Continue', 'opacity' => 1.0, 'enabled' => true]); + $p->confirm(); + expect($p->currentStage()['kind'])->toBe('level'); + $p->update(1.32); + expect($p->confirmation()['label'])->toBe('Complete')->and($p->confirmation()['enabled'])->toBeFalse(); + $p->update(0.33); + expect($p->confirmation())->toBe(['label' => 'Continue', 'opacity' => 1.0, 'enabled' => true]); +})->with([true, false]); + +it('carries natural completion overshoot into the button transition independently of frame rate', function () { + $facts = new BattleRewards(0, 0, []); + $single = new BattleResultsPlayback($facts); + $split = new BattleResultsPlayback($facts); + $single->update(2.5); + $split->update(2.0); + $split->update(0.3); + $split->update(0.2); + expect($single->confirmation())->toBe($split->confirmation()) + ->and($single->confirmation()['opacity'])->toEqualWithDelta(0.5, 0.000001); +}); + +it('holds event order per character and counts ability groups independently of inner entries', function () { + $skills = [ + ['name' => 'Cure', 'kind' => 'Magic', 'description' => 'Healing', 'cost' => 8], + ['name' => 'Focus', 'kind' => 'Ability', 'description' => 'Focus yourself', 'cost' => 2], + ]; + $rewards = new BattleRewards(260, 10, [playbackAward(learned: $skills), playbackAward('reserve')], + specialRewards: [['title' => 'Seal', 'description' => 'Actual authored reward']]); + $playback = new BattleResultsPlayback($rewards, true); + $stages = []; + while (!$playback->isFinished()) { + $stages[] = $playback->currentStage(); + $playback->update(0.1); + $playback->confirm(); + } + expect(array_column($stages, 'kind'))->toBe(['primary', 'level', 'ability', 'ability', 'level', 'special']) + ->and(array_column($stages, 'actor'))->toBe([null, 0, 0, 0, 1, null]) + ->and($playback->eventCounter())->toBe(['current' => 4, 'total' => 4]) + ->and($playback->rewards)->toBe($rewards); +}); + +it('rejects repeated streams and ignores confirmations during page transition', function () { + $p = new BattleResultsPlayback(new BattleRewards(260, 0, [playbackAward()])); + $p->confirm(); + foreach (range(1, 20) as $_) { $p->update(0.04); $p->confirm(); } + expect($p->currentStage()['kind'])->toBe('primary'); + $p->update(0.081); + $p->confirm(); + expect($p->currentStage()['kind'])->toBe('level')->and($p->isComplete())->toBeFalse(); + $p->update(0.1); + $p->confirm(); + expect($p->stageTime())->toBe(0.0); + $p->update(1.3); + expect($p->isComplete())->toBeTrue(); +}); + +it('samples all EXP gauges together with visible thresholds and exact final facts', function () { + $p = new BattleResultsPlayback(new BattleRewards(260, 0, [playbackAward(), playbackAward('reserve')])); + $p->update(0.899); + expect($p->progress(0)['experience'])->toBe(80)->and($p->progress(1))->toBe($p->progress(0)); + $p->update(0.29); + expect($p->progress(0)['ratio'])->toBe(1.0)->and($p->progress(0)['level'])->toBe(1); + $p->update(0.05); + expect($p->progress(0)['level'])->toBe(2)->and($p->progress(0)['ratio'])->toBeLessThan(1.0); + $p->update(100); + expect($p->progress(0))->toMatchArray(['level' => 4, 'current' => 40, 'needed' => 200, + 'ratio' => 0.2, 'maximum' => false, 'experience' => 340]); +}); + +it('has equivalent capped and zero outcomes with reduced motion and no automatic event advance', function () { + $facts = new BattleRewards(0, 0, [playbackAward(from: 500, to: 500)]); + $normal = new BattleResultsPlayback($facts); + $reduced = new BattleResultsPlayback($facts, true); + $normal->update(100); + expect($normal->currentStage()['kind'])->toBe('primary')->and($reduced->isComplete())->toBeTrue() + ->and($normal->progress(0))->toBe($reduced->progress(0)) + ->and($normal->progress(0)['maximum'])->toBeTrue(); +}); + +it('keeps a consumer-defined page limit and resets it at the next event', function () { + $p = new BattleResultsPlayback(new BattleRewards(260, 0, [playbackAward()]), true); + $p->setScrollLimit(9); + foreach (range(0, 11) as $_) { $p->navigate(1); } + expect($p->scrollOffset)->toBe(9)->and($p->pageCount())->toBe(10); + $p->setScrollLimit(2); + expect($p->scrollOffset)->toBe(2); + $p->confirm(); + expect($p->scrollOffset)->toBe(0)->and($p->pageCount())->toBeLessThan(10); +}); + +it('retains long content, signed stats and actual MP costs without ANSI or control characters', function () { + $skill = ['name' => str_repeat('Very long ', 40), 'kind' => 'Ability', + 'description' => "\e[31mRED\e[0m\n" . str_repeat('d', 601), 'cost' => 8]; + $p = new BattleResultsPlayback(new BattleRewards(260, 0, [playbackAward(learned: [$skill])]), true); + $p->confirm(); + $level = implode("\n", array_column(BattleResultsContent::eventLines($p), 'text')); + expect($level)->toContain('+20', '-1', '(0)'); + $p->update(0.1); + $p->confirm(); + $lines = BattleResultsContent::eventLines($p); + $text = implode("\n", array_column($lines, 'text')); + expect($text)->toContain('Cost: 8 MP', 'RED')->not->toContain("\e") + ->and(substr_count($text, 'd'))->toBeGreaterThanOrEqual(601)->and($p->pageCount())->toBeGreaterThan(1); + foreach ($lines as $line) { expect(mb_strlen($line['text']))->toBeLessThanOrEqual(50); } +}); + +it('rejects invalid playback time and invalid page geometry', function () { + $p = new BattleResultsPlayback(new BattleRewards(0, 0, [])); + foreach ([-1.0, INF, NAN] as $delta) { expect(fn() => $p->update($delta))->toThrow(InvalidArgumentException::class); } + expect(fn() => $p->setScrollLimit(-1))->toThrow(InvalidArgumentException::class); +}); + +it('keeps newlines but strips C0 and C1 controls before native cell projection', function () { + expect(BattleResultsContent::wrap("a\t b\u{0085}c\nsecond\x01 line", 40))->toBe(['a bc', 'second line']); +}); diff --git a/tests/Unit/BattleRewardsTest.php b/tests/Unit/BattleRewardsTest.php index 5d2d7ea1..3f77429d 100644 --- a/tests/Unit/BattleRewardsTest.php +++ b/tests/Unit/BattleRewardsTest.php @@ -1,36 +1,31 @@ newInstanceWithoutConstructor(); - ConfigStore::put(ItemStore::class, $store); -}); - -afterEach(function () { - ConfigStore::remove(ItemStore::class); -}); - -it('drops nothing when no candidate passes its drop-rate roll', function () { - $herb = new Item('Herb', 'A fragrant healing herb.', '🌿', 10); - $rewards = new BattleRewards(10, 5, [new DropItem($herb, 0.0)]); - - expect($rewards->item)->toBeNull(); +use Ichiloto\Engine\Entities\Stats; +use Ichiloto\Engine\Progression\ExperienceAwarder; + +it('detaches battle progression from live gameplay objects while preserving the legacy outcome API', function () { + $character = new Character('Snapshot', 0, new Stats(currentHp: 100)); + $award = ExperienceAwarder::award($character, 10); + $rewards = new BattleRewards(10, 3, [$award]); + $result = new BattleResult('Victory', rewards: $rewards); + $character->addExperience(10000); + expect($result->outcome())->toBe('victory') + ->and($rewards->progression[0])->toBeInstanceOf(BattleProgression::class) + ->and(property_exists($rewards->progression[0], 'character'))->toBeFalse() + ->and($rewards->progression[0]->before->experience)->toBe(0) + ->and($rewards->progression[0]->after->experience)->toBe(10); }); -it('drops the item when its drop rate always passes', function () { - $herb = new Item('Herb', 'A fragrant healing herb.', '🌿', 10); - $rewards = new BattleRewards(10, 5, [new DropItem($herb, 1.0)]); - - expect($rewards->item)->toBe($herb); -}); - -it('drops nothing when there are no drop candidates', function () { - $rewards = new BattleRewards(10, 5, []); - - expect($rewards->item)->toBeNull(); +it('coalesces rewards by stable definition rather than display name without retaining mutable items', function () { + $first = new Item('Same Name', 'One', '', 0, quantity: 2, id: 'one'); + $second = new Item('Same Name', 'Two', '', 0, quantity: 3, id: 'two'); + $rows = BattleRewards::snapshotItems([$first, clone $first, $second]); + $first->quantity = 99; + expect($rows)->toHaveCount(2) + ->and(array_column($rows, 'quantity', 'id'))->toBe(['one' => 4, 'two' => 3]); }); diff --git a/tests/Unit/CanvasCompositeTest.php b/tests/Unit/CanvasCompositeTest.php new file mode 100644 index 00000000..b0301103 --- /dev/null +++ b/tests/Unit/CanvasCompositeTest.php @@ -0,0 +1,135 @@ + 0, 'y' => 0, 'width' => $size, 'height' => $size]; +} + +function getCompositeTestFill(int $size = 16): array +{ + return ['type' => 'fill', 'destination' => getCompositeTestRect($size), + 'brush' => ['type' => 'solid', 'color' => ['kind' => 'rgb', 'r' => 1, 'g' => 2, 'b' => 3]]]; +} + +it('serializes bounded raster operations without changing legacy canvas packets', function () { + $operation = new CanvasCompositeOperation(getCompositeTestFill()); + $composite = new CanvasComposite('effect', 16, 16, new CanvasRectangle(2, 3, 32, 32), [$operation], 5, 0.4); + $canvas = new PresentationCanvas(100, 100, composites: [$composite]); + expect(new PresentationCanvas(100, 100)->toArray())->not->toHaveKey('composites') + ->and($canvas->toArray()['composites'][0]['opacity'])->toBe(0.4) + ->and($canvas->toArray()['composites'][0]['operations'][0]['blend'])->toBe('source_over') + ->and(CanvasCompositeBudget::getWork([$composite]))->toBe(16 * 16 * 6); +}); + +it('rejects malformed operation fields at the PHP boundary', function (array $changes) { + expect(fn() => new CanvasCompositeOperation(array_replace(getCompositeTestFill(), $changes))) + ->toThrow(InvalidArgumentException::class); +})->with([ + [['opacity' => null]], [['opacity' => '1']], [['opacity' => NAN]], [['opacity' => INF]], + [['opacity' => 1.01]], [['blend' => 'multiply']], [['time' => 1]], [['masks' => null]], + [['destination' => ['x' => 0, 'y' => 0, 'width' => 0, 'height' => 1]]], + [['brush' => ['type' => 'solid', 'color' => '#FFFFFF']]], +]); + +it('detaches nested reference inputs and validates finite displacement fields', function () { + $x = 1; + $offset = [&$x, 2]; + $data = ['type' => 'image', 'asset' => 'synthetic.png', 'destination' => getCompositeTestRect(), + 'source' => ['x' => 0.25, 'y' => 0.25, 'width' => 0.5, 'height' => 0.5], + 'displacement' => ['columns' => 2, 'rows' => 2, 'offsets' => [&$offset, $offset, $offset, $offset]]]; + $operation = new CanvasCompositeOperation($data); + $x = 100; $offset = [8, 9]; + expect($operation->data['displacement']['offsets'][0])->toBe([1.0, 2.0]); + foreach ([['columns' => 1], ['rows' => 65], ['columns' => '2'], ['offsets' => [[1, 2]]], + ['offsets' => array_fill(0, 4, [4097, 0])]] as $changes) { + $bad = $data; $bad['displacement'] = array_replace($data['displacement'], $changes); + expect(fn() => new CanvasCompositeOperation($bad))->toThrow(InvalidArgumentException::class); + } +}); + +it('validates gradient endpoints and strict masks while allowing clipped path coordinates', function () { + $color = ['kind' => 'ansi16', 'index' => 15]; + $brush = ['type' => 'linear', 'start' => [-2, -3], 'end' => [20, 20], + 'stops' => [['offset' => 0, 'color' => $color], ['offset' => 1, 'color' => $color, 'opacity' => 0]]]; + expect(new CanvasCompositeBrush($brush)->data['stops'][1]['opacity'])->toBe(0.0); + foreach ([['start' => [20, 20]], ['stops' => [['offset' => 0.1, 'color' => $color], ['offset' => 1, 'color' => $color]]], + ['stops' => [['offset' => 0, 'color' => $color], ['offset' => 0, 'color' => $color]]]] as $changes) { + expect(fn() => new CanvasCompositeBrush(array_replace($brush, $changes)))->toThrow(InvalidArgumentException::class); + } + $mask = ['type' => 'polygon', 'contours' => [[[-2, -2], [16, 0], [16, 16]]], 'feather' => 2, 'invert' => true]; + expect(new CanvasCompositeMask($mask)->data['invert'])->toBeTrue(); + foreach ([['contours' => [[[0, 0], [0, 0], [16, 16]]]], ['feather' => -1], ['invert' => 1], ['unused' => true]] as $changes) { + expect(fn() => new CanvasCompositeMask(array_replace($mask, $changes)))->toThrow(InvalidArgumentException::class); + } + expect(fn() => new CanvasCompositeMask(['type' => 'ellipse', 'center' => [0, 0], 'radius' => [0, 2]])) + ->toThrow(InvalidArgumentException::class); +}); + +it('enforces raster aggregate operation node and cold-work limits', function () { + $bounds = new CanvasRectangle(0, 0, 16, 16); + foreach ([[0, 1], [4097, 1], [4096, 4096]] as [$width, $height]) { + expect(fn() => new CanvasComposite('bad', $width, $height, $bounds, []))->toThrow(InvalidArgumentException::class); + } + $fill = new CanvasCompositeOperation(getCompositeTestFill()); + $one = new CanvasComposite('one', 16, 16, $bounds, [$fill]); + expect(fn() => new CanvasComposite('bad', 1, 1, $bounds, [$fill]))->toThrow(InvalidArgumentException::class); + expect(fn() => new PresentationCanvas(16, 16, composites: [$one, $one]))->toThrow(InvalidArgumentException::class); + $items = array_map(fn($i) => new CanvasComposite('c' . $i, 16, 16, $bounds, []), range(0, 8)); + expect(fn() => new PresentationCanvas(16, 16, composites: $items))->toThrow(InvalidArgumentException::class); + $items = array_map(fn($i) => new CanvasComposite('c' . $i, 2048, 2048, $bounds, []), range(0, 2)); + expect(fn() => new PresentationCanvas(16, 16, composites: $items))->toThrow(InvalidArgumentException::class); + $items = array_map(fn($i) => new CanvasComposite('c' . $i, 16, 16, $bounds, array_fill(0, 129, $fill)), range(0, 1)); + expect(fn() => new PresentationCanvas(16, 16, composites: $items))->toThrow(InvalidArgumentException::class); + $image = new CanvasCompositeOperation(['type' => 'image', 'asset' => 'x.png', 'destination' => getCompositeTestRect(), + 'displacement' => ['columns' => 64, 'rows' => 64, 'offsets' => array_fill(0, 4096, [0, 0])]]); + expect(fn() => new PresentationCanvas(16, 16, composites: [new CanvasComposite('grid', 16, 16, $bounds, array_fill(0, 5, $image))])) + ->toThrow(InvalidArgumentException::class); + $expensive = new CanvasCompositeOperation(getCompositeTestFill(2048)); + expect(fn() => new PresentationCanvas(16, 16, composites: [new CanvasComposite('work', 2048, 2048, $bounds, array_fill(0, 16, $expensive))])) + ->toThrow(InvalidArgumentException::class, 'cold raster work'); +}); + +it('negotiates compositing before submission and keeps overlays above the entire background', function () { + $effect = new CanvasComposite('effect', 16, 16, new CanvasRectangle(0, 0, 16, 16), [new CanvasCompositeOperation(getCompositeTestFill())], 7); + $canvas = new PresentationCanvas(100, 100, composites: [$effect]); + foreach ([['graphical_canvas'], ['graphical_canvas', 'canvas_compositing']] as $caps) { + $transport = new FakeRendererTransport(); + $client = new RendererClient($transport); + $client->start(new RendererSessionConfig('Effects', __DIR__, protocol: RendererProtocolVersion::V2, requiredCapabilities: $caps)); + $transport->batches[] = [RendererEvent::fromJson(json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => $caps]))]; + $client->pump(); + $presenter = new RendererPresentation($client, new RendererGridConfig(1, 1)); + if (count($caps) === 1) { expect(fn() => $presenter->presentCanvas($canvas))->toThrow(RendererProtocolException::class); } + else { expect($presenter->presentCanvas($canvas))->toBeTrue(); } + } + $theme = new MenuPresentationCatalog(__DIR__, ['schema' => 'ichiloto.menu/1']); + $overlay = new PresentationCanvas(100, 100, composites: [new CanvasComposite('overlay', 16, 16, new CanvasRectangle(0, 0, 16, 16), [])]); + $combined = MenuCanvas::overlay($canvas, $overlay, $theme); + expect(array_column($combined->composites, 'id'))->toBe(['effect', 'overlay']) + ->and($combined->composites[1]->layer)->toBeGreaterThan(7); + expect(fn() => new RendererSessionConfig('Effects', __DIR__, requiredCapabilities: ['canvas_compositing']))->toThrow(InvalidArgumentException::class); +}); + +it('preflights alpha and displaced PNG references rather than freezing their bytes or dimensions', function () { + $root = __DIR__ . '/../Fixtures/Renderer'; + $image = new CanvasCompositeOperation(['type' => 'image', 'asset' => 'test-sprite.png', 'destination' => getCompositeTestRect(), + 'masks' => [['type' => 'image_alpha', 'asset' => 'test-sprite.png', 'destination' => getCompositeTestRect()]]]); + $composite = new CanvasComposite('image', 16, 16, new CanvasRectangle(0, 0, 16, 16), [$image]); + $result = CanvasImagePreflight::inspect([], $root, [$composite]); + expect($result['sources'])->toHaveCount(1)->and($result['regions'])->toBe([]); + expect(fn() => new CanvasCompositeOperation(['type' => 'image', 'asset' => '../invalid.png', 'destination' => getCompositeTestRect()])) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/CanvasCompositingTest.php b/tests/Unit/CanvasCompositingTest.php index cf3cd6eb..c4c47650 100644 --- a/tests/Unit/CanvasCompositingTest.php +++ b/tests/Unit/CanvasCompositingTest.php @@ -9,9 +9,9 @@ use Ichiloto\Engine\Rendering\Presentation\Canvas\PresentationCanvas; use Ichiloto\Engine\Rendering\Presentation\PresentationTextRun; use Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; it('keeps logical nine-slice corners and shared edges identical at both densities', function () { $destination = new CanvasRectangle(0.25, 0.5, 140.5, 120.25); diff --git a/tests/Unit/CanvasFixtureTest.php b/tests/Unit/CanvasFixtureTest.php index 1c7785cc..759de2fe 100644 --- a/tests/Unit/CanvasFixtureTest.php +++ b/tests/Unit/CanvasFixtureTest.php @@ -15,10 +15,10 @@ use Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect; use Ichiloto\Engine\Rendering\Presentation\StyledPresentationFrame; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\RendererEvent; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Tests\Support\Input\FakeRendererTransport; diff --git a/tests/Unit/CanvasGlyphEffectsTest.php b/tests/Unit/CanvasGlyphEffectsTest.php index 6c417740..b2b0f18c 100644 --- a/tests/Unit/CanvasGlyphEffectsTest.php +++ b/tests/Unit/CanvasGlyphEffectsTest.php @@ -10,10 +10,10 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationTextRun; use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\RendererEvent; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Tests\Support\Input\FakeRendererTransport; diff --git a/tests/Unit/CanvasImagePreflightTest.php b/tests/Unit/CanvasImagePreflightTest.php new file mode 100644 index 00000000..08b60956 --- /dev/null +++ b/tests/Unit/CanvasImagePreflightTest.php @@ -0,0 +1,84 @@ +root = sys_get_temp_dir() . '/ichiloto-canvas-budget-' . bin2hex(random_bytes(5)); + mkdir($this->root); + // Header-only fixtures exercise PHP preflight, not the native PNG decoder. + $this->png = function (string $name, int $width, int $height): void { + file_put_contents($this->root . '/' . $name, "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', $width, $height)); + }; +}); + +afterEach(function () { + foreach (glob($this->root . '/*') ?: [] as $file) { unlink($file); } + rmdir($this->root); +}); + +it('deduplicates canonical sources and guarded crops independently of destination opacity and clipping', function () { + ($this->png)('sheet.png', 100, 80); + symlink($this->root . '/sheet.png', $this->root . '/alias.png'); + $bounds = new CanvasRectangle(0, 0, 10, 10); + $crop = new SpriteSourceRect(0, 0, 20, 30); + $images = [ + new CanvasImage('first', 'sheet.png', $bounds, 0, $crop), + new CanvasImage('alias', 'alias.png', $bounds, 0, $crop, 0, $bounds), + new CanvasImage('other-crop', 'sheet.png', $bounds, 0, new SpriteSourceRect(20, 0, 20, 30)), + ]; + $budget = CanvasImagePreflight::inspect($images, $this->root); + expect($budget['sources'])->toBe(['sheet.png' => 100 * 80 * 4]) + ->and($budget['regions'])->toHaveCount(2) + ->and(array_sum($budget['regions']))->toBe(2 * 24 * 34 * 4); +}); + +it('counts full decoded sources even for invisible one-pixel crops', function () { + $images = []; + foreach (['a.png', 'b.png'] as $name) { + ($this->png)($name, 4096, 4096); + $images[] = new CanvasImage($name, $name, new CanvasRectangle(0, 0, 1, 1), 0, + new SpriteSourceRect(0, 0, 1, 1), 0); + } + expect(fn() => CanvasImagePreflight::inspect($images, $this->root)) + ->toThrow(RuntimeException::class, 'PNG sources'); +}); + +it('rejects guarded regions independently when the decoded source still fits', function () { + ($this->png)('large.png', 4096, 4096); + $image = new CanvasImage('large', 'large.png', new CanvasRectangle(0, 0, 1, 1)); + expect(fn() => CanvasImagePreflight::inspect([$image], $this->root)) + ->toThrow(RuntimeException::class, 'prepared regions'); +}); + +it('accounts for nine-slice regions and rejects source bounds before rendering', function () { + ($this->png)('panel.png', 24, 24); + $texture = new CanvasNineSlice('panel.png', new SpriteSourceRect(0, 0, 24, 24), 8, 8, 8, 8); + $images = CanvasImagePreflight::textures([$texture]); + $budget = CanvasImagePreflight::inspect($images, $this->root); + expect($images)->toHaveCount(9) + ->and(array_sum($budget['sources']))->toBe(24 * 24 * 4) + ->and(array_sum($budget['regions']))->toBe(9 * 12 * 12 * 4); + $invalid = new CanvasImage('invalid', 'panel.png', new CanvasRectangle(0, 0, 1, 1), 0, + new SpriteSourceRect(24, 0, 1, 1)); + expect(fn() => CanvasImagePreflight::inspect([$invalid], $this->root))->toThrow(RuntimeException::class, 'bounds'); +}); + +it('loads whole PNGs without authored source dimensions and reconciles borders after replacement', function () { + $bounds = new CanvasRectangle(0, 0, 200, 100); + foreach ([[32, 48], [8, 6], [100, 80]] as [$width, $height]) { + ($this->png)('panel.png', $width, $height); + $texture = CanvasNineSlice::fromPng($this->root, 'panel.png', 12, 12, 12, 12); + $images = $texture->images('panel', $bounds, 0); + CanvasImagePreflight::inspect($images, $this->root); + expect($texture->source->toArray())->toBe(['x' => 0, 'y' => 0, 'width' => $width, 'height' => $height]) + ->and(array_sum(array_map(fn($image) => $image->destination->width * $image->destination->height, $images))) + ->toEqualWithDelta($bounds->width * $bounds->height, 0.000001); + } + expect(fn() => CanvasNineSlice::fromPng($this->root, 'panel.png', left: -1)) + ->toThrow(InvalidArgumentException::class); + expect(fn() => CanvasNineSlice::fromPng($this->root, 'missing.png'))->toThrow(RuntimeException::class); +}); diff --git a/tests/Unit/CanvasPresentationTest.php b/tests/Unit/CanvasPresentationTest.php index d635f74f..6b651f44 100644 --- a/tests/Unit/CanvasPresentationTest.php +++ b/tests/Unit/CanvasPresentationTest.php @@ -17,6 +17,7 @@ use Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect; use Ichiloto\Engine\Rendering\Presentation\StyledPresentationFrame; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererStartupException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; @@ -24,7 +25,6 @@ use Ichiloto\Engine\Rendering\Transport\RendererEvent; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Tests\Support\Input\FakeRendererTransport; diff --git a/tests/Unit/CharacterMenuPresentationTest.php b/tests/Unit/CharacterMenuPresentationTest.php new file mode 100644 index 00000000..5122531a --- /dev/null +++ b/tests/Unit/CharacterMenuPresentationTest.php @@ -0,0 +1,584 @@ +modals = new Stack(ModalInterface::class); } + public function alert(string $message, string $title = '', int $width = DEFAULT_DIALOG_WIDTH): void { $this->messages[] = $message; } +} + +class EquipmentMenuPresentationProbe extends EquipmentMenuState +{ + public function cycle(int $offset): void { $this->selectCharacterByOffset($offset); } + public function canvas(MenuPresentationCatalog $theme): ?PresentationCanvas { return $this->composeMenuCanvas($theme, 0); } + public function currentMode(): mixed { return $this->mode; } +} + +/** Only synthetic image geometry is asserted; mutable project art is never pinned. */ +function characterMenuPng(string $path, int $width, int $height): void +{ + $chunk = static fn(string $type, string $bytes) => pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xDD\xEE\xFF\xFF", $width), $height))) . $chunk('IEND', '')); +} + +function characterMenuTheme(bool $alternative): array +{ + $data = ['schema' => 'ichiloto.menu/1', 'icons' => ['unknown' => 'unknown.png', 'weapon.staff' => 'staff.png', + 'slot.head' => 'head.png'], 'cursor' => 'cursor.png', 'portraits' => ['actor.one' => 'portrait.png']]; + if (!$alternative) { return $data; } + $art = ['asset' => 'state.png', 'cuts' => [3, 4, 3, 4]]; + return [...$data, 'colors' => ['text' => [42, 37, 25], 'background' => [235, 221, 199], 'panel' => [246, 235, 212]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 34, 'panelPadding' => 16, 'sectionGap' => 8, 'portraitSize' => 88], + 'rowMetrics' => ['padding' => 24, 'iconWidth' => 30, 'iconHeight' => 16, 'cursorWidth' => 10, 'cursorHeight' => 20, + 'cursorInset' => 6, 'cursorTravel' => 3, 'cursorPeriod' => 2, 'recordSeparatorOpacity' => 0.35], + 'rowArtwork' => ['selected' => $art, 'focus' => ['asset' => 'focus.png'], + 'command.normal' => $art, 'command.selected' => $art, 'command.focus' => ['asset' => 'focus.png']], + 'frames' => ['panel' => $art, 'quiet' => $art, 'portrait' => $art]]; +} + +function characterMenuText(PresentationCanvas $canvas): array +{ + $result = []; + foreach ($canvas->textLayers as $layer) { + if (str_ends_with($layer->id, '-text') || in_array($layer->id, ['status-name', 'status-role', 'equipment-identity', 'equipment-description'], true)) { + $result[$layer->id] = array_column($layer->runs, 'text'); + } + } + return $result; +} + +function characterMenuWriteTheme(string $root, array $theme): void +{ + file_put_contents($root . '/' . MenuPresentationCatalog::FILE, 'savedStatics = []; + foreach ([Console::class, InputManager::class, ActionHints::class, ConfigStore::class, Debug::class, ModalManager::class, \Ichiloto\Engine\Audio\AudioManager::class] as $class) { + $this->savedStatics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-character-menu-' . bin2hex(random_bytes(5)); + mkdir($this->root . '/Data/Presentation', 0777, true); + foreach (['unknown', 'staff', 'head', 'cursor', 'portrait', 'state', 'focus'] as $name) { + characterMenuPng($this->root . '/' . $name . '.png', $name === 'portrait' ? 73 : 31, $name === 'portrait' ? 39 : 47); + } + Debug::configure(['log_directory' => $this->root . '/logs']); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + $this->game = new CharacterMenuGameProbe(); + new ReflectionProperty(\Ichiloto\Engine\Audio\AudioManager::class, 'instance')->setValue(null, null); + $this->alerts = new CharacterMenuAlerts(); + new ReflectionProperty(ModalManager::class, 'instance')->setValue(null, $this->alerts); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $manager = makeBareScene(SceneManager::class); + new ReflectionProperty($manager, 'game')->setValue($manager, $this->game); + $this->scene = makeBareScene(GameScene::class); + new ReflectionProperty($this->scene, 'sceneManager')->setValue($this->scene, $manager); + $ui = makeBareScene(\Ichiloto\Engine\UI\UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + $this->party = new Party(); + $this->actor = new Character('First Actor', 0, new Stats(totalHp: 901, totalMp: 123, attack: 34), actorId: 'actor.one'); + $this->other = new Character('Second Actor', 0, new Stats(), actorId: 'actor.two'); + $this->party->addMember($this->actor); + $this->party->addMember($this->other); + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, $this->party); + $this->equipped = new Weapon('Owner Staff', 'A complete current equipment description.', '/', 10, + parameterChanges: new ParameterChanges(attack: 5), equipmentType: WeaponType::STAFF, id: 'weapon.current'); + $this->candidate = new Weapon('Other Staff', 'A different candidate.', '/', 10, + parameterChanges: new ParameterChanges(attack: 11), equipmentType: WeaponType::STAFF, id: 'weapon.candidate'); + $this->party->inventory->addItems($this->equipped, $this->candidate); + $slot = $this->actor->equipment[0]; + $slot->equipment = $this->equipped; + $this->state = new EquipmentMenuPresentationProbe(new SceneStateContext($this->scene)); + $this->state->character = $this->actor; + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $this->state); + $this->state->enter(); + $this->runtime = null; + ob_start(); +}); + +afterEach(function () { + $this->runtime?->shutdown(); + ob_end_clean(); + foreach ($this->savedStatics as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +function characterMenuRuntime(string $root, array $caps): RendererRuntime +{ + $transport = new FakeRendererTransport(); + $transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $root, + requiredCapabilities: $caps), $transport); + $runtime->start('Menu fixture', 135, 36); + return $runtime; +} + +function characterMenuCandidates(EquipmentMenuPresentationProbe $state): EquipmentSelectionMode +{ + $slots = new EquipmentSlotSelectionMode($state); + $state->setMode($slots); + $mode = new EquipmentSelectionMode($state); + $mode->character = $state->character; + $mode->equipmentSlot = $state->character->equipment[0]; + $mode->previousMode = $slots; + $state->setMode($mode); + return $mode; +} + +it('projects actual Equipment and complete Status data through two themes without changing owners', function () { + $themes = [new MenuPresentationCatalog($this->root, characterMenuTheme(false)), new MenuPresentationCatalog($this->root, characterMenuTheme(true))]; + $commands = $this->state->equipmentMenu->getItems()->toArray(); + $frames = array_map(fn($theme) => $this->state->canvas($theme), $themes); + expect(characterMenuText($frames[0]))->toBe(characterMenuText($frames[1])); + foreach ($frames as $frame) { + $layers = array_column($frame->textLayers, null, 'id'); + foreach ($commands as $index => $command) { + $text = $layers['equipment-command-' . $index . '-text']; + expect($text->x + $text->grid->columns * $text->grid->cellWidth / 2) + ->toBe($text->clipRect->x + $text->clipRect->width / 2) + ->and(array_column($text->runs, 'text'))->toBe([$command->getLabel()]); + } + expect($layers)->toHaveKeys(['equipment-slots-slot-0-separator', 'equipment-slots-slot-4-separator']) + ->not->toHaveKey('equipment-command-0-separator'); + $images = array_column($frame->images, null, 'id'); + expect(array_filter($images, fn($image) => $image->asset === 'head.png'))->not->toBeEmpty() + ->and(array_filter($images, fn($image) => str_contains($image->id, 'equipment-command-') && str_contains($image->id, '-cursor')))->toBeEmpty(); + } + expect(array_filter($frames[1]->images, fn($image) => $image->asset === 'state.png'))->not->toBeEmpty() + ->and($this->state->equipmentMenu->getItems()->toArray())->toBe($commands) + ->and($this->actor->equipment[0]->equipment)->toBe($this->equipped); + $status = array_map(fn($theme) => CharacterMenuPresentation::status($this->actor, $theme), $themes); + expect(characterMenuText($status[0]))->toBe(characterMenuText($status[1])); + $text = characterMenuText($status[0]); + expect($text)->toHaveKeys(['status-resources-level-text', 'status-resources-hp-text', 'status-resources-mp-text', + 'status-resources-ap-text', 'status-exp-current-text', 'status-exp-next-text', 'status-stats-grace-text', 'status-slots-slot-4-text']) + ->and($text['status-resources-hp-text'])->toBe(['HP', $this->actor->effectiveStats->currentHp . ' / 901']); +}); + +it('keeps equipped selection separate from candidate focus quantity zero and owner preview', function () { + $mode = characterMenuCandidates($this->state); + $source = $mode->getPresentationCandidates(); + expect($source[0]['available'])->toBe(0)->and($source[0]['current'])->toBeTrue(); + $preview = $this->state->characterDetailPanel->getPresentationPreview(); + expect($preview->attack)->toBe($this->actor->effectiveStats->attack - 5); + $preview->attack = 999; + expect($this->state->characterDetailPanel->getPresentationPreview()->attack)->not->toBe(999); + $mode->selectNext(); + new ReflectionMethod($mode, 'updateCharacterDetailPanel')->invoke($mode); + $before = $this->actor->effectiveStats->attack; + foreach ([false, true] as $alternative) { + $frame = $this->state->canvas(new MenuPresentationCatalog($this->root, characterMenuTheme($alternative))); + $text = characterMenuText($frame); + expect($text['equipment-candidates-candidate-0-text'])->toBe(['Owner Staff', '0']) + ->and($text['equipment-candidates-candidate-1-text'])->toBe(['Other Staff', '1']) + ->and($text['equipment-stats-attack-text'])->toBe(['Attack', (string)$before, "\u{2192}", (string)($before + 6), "\u{2191}"]); + $ids = [...array_column($frame->textLayers, 'id'), ...array_column($frame->images, 'id')]; + expect(array_filter($ids, fn($id) => str_starts_with($id, 'equipment-candidates-candidate-0-selected')))->not->toBeEmpty() + ->and(array_filter($ids, fn($id) => str_starts_with($id, 'equipment-candidates-candidate-0-focus')))->toBeEmpty() + ->and(array_filter($ids, fn($id) => str_starts_with($id, 'equipment-candidates-candidate-1-focus')))->not->toBeEmpty() + ->and(array_filter($ids, fn($id) => str_starts_with($id, 'equipment-candidates-candidate-1-cursor')))->not->toBeEmpty(); + } + expect($this->actor->effectiveStats->attack)->toBe($before)->and($mode->getPresentationIndex())->toBe(1) + ->and($this->actor->equipment[0]->equipment)->toBe($this->equipped); +}); + +it('reuses semantic arrow artwork without moving equipment values or changing previews', function () { + $mode = characterMenuCandidates($this->state); + $data = characterMenuTheme(false); + $fallback = new MenuPresentationCatalog($this->root, $data); + $data['icons'] += ['comparison.next' => 'head.png', 'comparison.up' => 'staff.png', 'comparison.down' => 'cursor.png', + 'navigation.next' => 'cursor.png', 'navigation.up' => 'head.png', 'navigation.down' => 'staff.png']; + $art = new MenuPresentationCatalog($this->root, $data); + foreach (['cursor.png', 'staff.png'] as $directionAsset) { + $neutral = $this->state->canvas($fallback); + $frame = $this->state->canvas($art); + $images = array_column($frame->images, null, 'id'); + $id = 'equipment-stats-attack'; + expect($images[$id . '-value-1-1-1']->asset)->toBe('head.png') + ->and($images[$id . '-value-3-1-1']->asset)->toBe($directionAsset); + $before = array_column($neutral->textLayers, null, 'id')[$id . '-text']; + $after = array_column($frame->textLayers, null, 'id')[$id . '-text']; + expect($after->bounds)->toEqual($before->bounds); + foreach ([1, 2] as $index) { + $previous = $before->runs[$index === 1 ? 1 : 3]; + expect($after->runs[$index]->text)->toBe($previous->text) + ->and($after->runs[$index]->column)->toBe($previous->column) + ->and($after->runs[$index]->foreground)->toEqual($previous->foreground); + } + $mode->selectNext(); + new ReflectionMethod($mode, 'updateCharacterDetailPanel')->invoke($mode); + } + expect($this->actor->equipment[0]->equipment)->toBe($this->equipped); +}); + +it('wraps complete long labels and follows the actual candidate index in a finite viewport', function () { + new ReflectionProperty($this->candidate, 'name')->setValue($this->candidate, str_repeat('Long candidate label ', 5)); + for ($index = 0; $index < 25; $index++) { + $this->party->inventory->addItems(new Weapon('Candidate ' . $index, '', '/', 10, id: 'weapon.extra-' . $index)); + } + $mode = characterMenuCandidates($this->state); + $mode->selectNext(); + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme(false)); + $frame = $this->state->canvas($theme); + $text = characterMenuText($frame)['equipment-candidates-candidate-1-text']; + expect(implode('', array_slice($text, 0, -1)))->toBe($this->candidate->name); + for ($index = 0; $index < 25; $index++) { $mode->selectNext(); } + $frame = $this->state->canvas($theme); + expect(characterMenuText($frame))->toHaveKey('equipment-candidates-candidate-26-text') + ->and(array_column($frame->textLayers, 'id'))->toContain('equipment-candidates-range') + ->and($mode->getPresentationIndex())->toBe(26); + new ReflectionProperty($this->actor, 'name')->setValue($this->actor, 'A deliberately longer complete actor display name'); + $status = CharacterMenuPresentation::status($this->actor, $theme); + expect(implode('', characterMenuText($status)['status-name']))->toBe($this->actor->name); +}); + +it('uses optional state delegation and restores the same active menu after a modal frame', function () { + characterMenuWriteTheme($this->root, characterMenuTheme(true)); + $this->runtime = characterMenuRuntime($this->root, MenuPresentationCatalog::CAPABILITIES); + $this->game->useRendererRuntime($this->runtime); + $mode = characterMenuCandidates($this->state); + $mode->selectNext(); + $before = characterMenuText($this->scene->getPresentationCanvas()); + $modalManager = makeBareScene(ModalManager::class); + $stack = new Stack(ModalInterface::class); + new ReflectionProperty($modalManager, 'modals')->setValue($modalManager, $stack); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $modalManager); + $modal = new AlertModal($this->game, 'Equipment optimized!', 'Equipment'); + $stack->push($modal); + $modal->show(); + InputManager::setInputSource(new FakeInputSource(KeyCode::TAB)); + expect(array_column($this->scene->getPresentationCanvas()->textLayers, 'id'))->toContain('menu-modal-message') + ->and($this->state->currentMode())->toBe($mode); + $modal->hide(); + expect(characterMenuText($this->scene->getPresentationCanvas()))->toBe($before); + $stack->pop(); + expect(characterMenuText($this->scene->getPresentationCanvas()))->toBe($before) + ->and($mode->getPresentationIndex())->toBe(1)->and($this->actor->equipment[0]->equipment)->toBe($this->equipped); + InputManager::handleInput(); + expect(InputManager::getPressedKeyCode())->toBe(KeyCode::TAB); + $this->state->execute(); + expect($this->state->character)->toBe($this->other) + ->and($this->state->currentMode())->toBeInstanceOf(EquipmentMenuCommandSelectionMode::class); +}); + +it('loads menu-only capabilities without a battle theme and keeps absent and terminal paths unchanged', function () { + $resolver = new class implements RendererExecutableResolverInterface { + public function resolve(string $rendererId): string { return PHP_BINARY; } + }; + $registry = new RendererRegistry($resolver); + expect($registry->require('terminal')->createRuntime($this->root))->toBeNull() + ->and(MenuPresentationCatalog::load($this->root))->toBeNull()->and($this->scene->getPresentationCanvas())->toBeNull(); + $unconfigured = $registry->require('gpui')->createRuntime($this->root); + $config = new ReflectionProperty($unconfigured, 'config')->getValue($unconfigured); + expect($config->requiredCapabilities)->toBe([RendererSessionConfig::SPRITE_SOURCE_RECT, RendererSessionConfig::TILE_BATCHES]); + characterMenuWriteTheme($this->root, ['schema' => 'ichiloto.menu/1']); + $configured = $registry->require('gpui')->createRuntime($this->root); + $config = new ReflectionProperty($configured, 'config')->getValue($configured); + expect($config->requiredCapabilities)->toContain(...MenuPresentationCatalog::CAPABILITIES) + ->toContain(RendererSessionConfig::WINDOW_ACTIVATION) + ->and(is_file($this->root . '/Data/Presentation/battle.php'))->toBeFalse(); + $this->runtime = characterMenuRuntime($this->root, $config->requiredCapabilities); + $this->game->useRendererRuntime($this->runtime); + expect($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class); + foreach (MenuPresentationCatalog::CAPABILITIES as $capability) { expect($this->runtime->supports($capability))->toBeTrue(); } +}); + +it('diagnoses unsupported capabilities and invalid configured assets without changing the owner', function (string $failure) { + $data = characterMenuTheme(false); + if ($failure === 'asset') { $data['portraits']['actor.two'] = 'missing.png'; } + characterMenuWriteTheme($this->root, $data); + $this->runtime = characterMenuRuntime($this->root, $failure === 'capability' ? [] : MenuPresentationCatalog::CAPABILITIES); + $this->game->useRendererRuntime($this->runtime); + $mode = $this->state->currentMode(); + expect($this->scene->getPresentationCanvas())->toBeNull()->and($this->scene->getPresentationCanvas())->toBeNull() + ->and($this->state->currentMode())->toBe($mode)->and($this->state->character)->toBe($this->actor); + $log = file_get_contents($this->root . '/logs/error.log'); + expect($log)->toContain('Menu presentation degraded to terminal') + ->and(substr_count($log, 'Menu presentation degraded'))->toBe(1); +})->with(['asset', 'capability']); + +it('retains terminal source data and full description while native overflow diagnoses instead of crashing', function () { + characterMenuWriteTheme($this->root, characterMenuTheme(false)); + $this->runtime = characterMenuRuntime($this->root, MenuPresentationCatalog::CAPABILITIES); + $this->game->useRendererRuntime($this->runtime); + $description = "First\nSecond\n" . str_repeat('Full long description ', 300); + $this->state->equipmentInfoPanel->setText($description); + expect($this->state->equipmentInfoPanel->getPresentationText())->toBe($description) + ->and($this->state->equipmentInfoPanel->getContent())->toBe(['First', 'Second']) + ->and($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class) + ->and($this->state->menuInfoText->lastPage->source)->toBe($description) + ->and($this->state->menuInfoText->lastPage->lines)->toBe(['First', 'Second']); + $this->state->equipmentInfoPanel->setText('Fits again'); + expect($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class); + $status = new StatusViewState(new SceneStateContext($this->scene)); + $status->character = $this->actor; + $status->enter(); + $terminal = TerminalText::stripAnsi(implode("\n", Console::getBuffer())); + expect($terminal)->toContain('First Actor', 'Current EXP:', 'To Next Level:', 'Owner Staff', 'AP:'); +}); + +it('omits an unconfigured portrait frame rather than substituting the panel artwork', function () { + $data = characterMenuTheme(true); + unset($data['frames']['portrait']); + $frame = CharacterMenuPresentation::status($this->actor, new MenuPresentationCatalog($this->root, $data)); + expect(array_filter($frame->images, fn($image) => $image->asset === 'portrait.png'))->toHaveCount(1) + ->and(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'portrait-frame-')))->toBeEmpty() + ->and(array_filter($frame->images, fn($image) => $image->asset === 'state.png'))->not->toBeEmpty(); +}); + +it('reads replacement portrait and frame dimensions from the current PNG without changing actor identity', function () { + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme(true)); + $first = CharacterMenuPresentation::status($this->actor, $theme); + characterMenuPng($this->root . '/portrait.png', 21, 89); + characterMenuPng($this->root . '/state.png', 5, 7); + $second = CharacterMenuPresentation::status($this->actor, $theme); + expect(characterMenuText($second))->toBe(characterMenuText($first)); + $portraits = array_values(array_filter($second->images, fn($image) => $image->asset === 'portrait.png')); + expect($portraits)->toHaveCount(1)->and($portraits[0]->sourceRect->width)->toBe(21) + ->and($portraits[0]->sourceRect->height)->toBe(89)->and($this->actor->actorId)->toBe('actor.one'); +}); + +it('retains Equip toggle Optimize Clear and actor cycling in the existing controllers under either theme', function (bool $alternative) { + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme($alternative)); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::ENTER]], 'back' => ['keys' => [KeyCode::ESCAPE]]]); + $press = function (KeyCode $key): void { + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $this->state->execute(); + }; + $this->state->canvas($theme); + $press(KeyCode::ENTER); + expect($this->state->currentMode())->toBeInstanceOf(EquipmentSlotSelectionMode::class); + $press(KeyCode::ENTER); + expect($this->state->currentMode())->toBeInstanceOf(EquipmentSelectionMode::class); + $this->state->canvas($theme); + $press(KeyCode::ENTER); + expect($this->actor->equipment[0]->equipment)->toBeNull(); + $press(KeyCode::ENTER); + $mode = $this->state->currentMode(); + $mode->selectNext(); + $this->state->canvas($theme); + $press(KeyCode::ENTER); + expect($this->actor->equipment[0]->equipment->id)->toBe($this->candidate->id); + $commands = $this->state->equipmentMenu->getItems()->toArray(); + $commands[1]->execute(); + expect($this->actor->equipment[0]->equipment->id)->toBe($this->candidate->id); + $commands[2]->execute(); + expect(array_all($this->actor->equipment, fn($slot) => $slot->equipment === null))->toBeTrue() + ->and($this->alerts->messages)->toBe(['Equipped Other Staff on First Actor', 'Equipment optimized!', 'Equipment cleared!']); + $this->state->cycle(1); + expect($this->state->character)->toBe($this->other)->and($this->state->equipmentMenu->activeIndex)->toBe(0); + $this->state->cycle(-1); + expect($this->state->character)->toBe($this->actor); + $status = new StatusViewState(new SceneStateContext($this->scene)); + $status->character = $this->actor; + $status->enter(); + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $status); + foreach ([KeyCode::TAB, KeyCode::SHIFT_TAB] as $index => $key) { + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $status->execute(); + expect($status->character)->toBe($index === 0 ? $this->other : $this->actor); + expect(characterMenuText(CharacterMenuPresentation::status($status->character, $theme))['status-name']) + ->toBe([$status->character->name]); + } +})->with([false, true]); + +it('leaves native terminal frames available when the catalog is absent or the state does not implement Canvas', function () { + $this->runtime = characterMenuRuntime($this->root, []); + $this->game->useRendererRuntime($this->runtime); + expect($this->scene->getPresentationCanvas())->toBeNull()->and(is_dir($this->root . '/logs'))->toBeFalse(); + $state = new class(new SceneStateContext($this->scene)) extends \Ichiloto\Engine\Scenes\Game\States\GameSceneState { + public function execute(?\Ichiloto\Engine\Scenes\SceneStateContext $context = null): void {} + }; + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $state); + expect($this->scene->getPresentationCanvas())->toBeNull(); +}); + +it('rejects malformed configured themes while terminal mode never evaluates them', function (array $data) { + characterMenuWriteTheme($this->root, $data); + expect($this->scene->getPresentationCanvas())->toBeNull()->and(is_dir($this->root . '/logs'))->toBeFalse() + ->and(fn() => MenuPresentationCatalog::load($this->root))->toThrow(InvalidArgumentException::class); +})->with([ + [['schema' => 'unknown']], + [['schema' => 'ichiloto.menu/1', 'sourceWidth' => 24]], + [['schema' => 'ichiloto.menu/1', 'colors' => ['text' => [1, 2, 256]]]], + [['schema' => 'ichiloto.menu/1', 'rowArtwork' => ['callback' => 'anything']]], + [['schema' => 'ichiloto.menu/1', 'cursor' => 12]], + [['schema' => 'ichiloto.menu/1', 'frames' => ['panel' => ['asset' => '../outside.png']]]], +]); + +it('uses the existing explicit handshake diagnostic when a renderer cannot acknowledge requested menu capabilities', function () { + characterMenuWriteTheme($this->root, ['schema' => 'ichiloto.menu/1']); + $transport = new FakeRendererTransport(); + $transport->batches = [[RendererEvent::fromJson('{"type":"ready","protocol":2,"capabilities":[]}')]]; + $this->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $this->root, + requiredCapabilities: MenuPresentationCatalog::requestedCapabilities($this->root)), $transport); + expect(fn() => $this->runtime->start('Fixture', 135, 36)) + ->toThrow(\Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException::class, 'required capabilities'); +}); + +it('refreshes both menu footers from live semantic rebindings without consuming input or changing owners', function (bool $alternative) { + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme($alternative)); + $mode = $this->state->currentMode(); + $source = new FakeInputSource(KeyCode::TAB); + InputManager::setInputSource($source); + foreach ([KeyCode::F1, KeyCode::PAGE_DOWN] as $next) { + $bindings = ['character_next' => ['keys' => [$next]], + 'character_previous' => ['keys' => [KeyCode::PAGE_UP, KeyCode::HOME]], 'back' => ['keys' => [KeyCode::END]]]; + InputManager::setBindings($bindings); + $effective = InputManager::getBindings(); + foreach (['equipment' => $this->state->canvas($theme), + 'status' => CharacterMenuPresentation::status($this->actor, $theme)] as $id => $canvas) { + $hint = array_column($canvas->textLayers, null, 'id')[$id . '-hints']; + expect(implode('', array_column($hint->runs, 'text')))->toBe(' ' . ControlHint::keyboard($next)->label . ' : Next Page Up : Prev End : Back'); + } + expect(InputManager::getBindings())->toBe($effective)->and($source->keys)->toBe([KeyCode::TAB]) + ->and($this->state->currentMode())->toBe($mode)->and($this->state->character)->toBe($this->actor); + } +})->with([false, true]); + +it('consults the shared display provider for each Equipment and Status redraw without changing actions', function () { + $provider = new class implements ActionHintProvider { + public function __construct(public string $family = 'profile-one') {} + public function controlForAction(string $action): ?ControlHint { return new ControlHint($this->family, $action, $this->family); } + }; + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme(false)); + $mode = $this->state->currentMode(); + $bindings = InputManager::getBindings(); + $source = new FakeInputSource(KeyCode::TAB); + InputManager::setInputSource($source); + ActionHints::useProvider($provider); + foreach (['profile-one', 'profile-two'] as $family) { + $provider->family = $family; + foreach (['equipment' => $this->state->canvas($theme), + 'status' => CharacterMenuPresentation::status($this->actor, $theme)] as $id => $frame) { + $hint = array_column($frame->textLayers, null, 'id')[$id . '-hints']; + expect(implode('', array_column($hint->runs, 'text')))->toBe(" $family : Next $family : Prev $family : Back"); + } + expect($this->state->currentMode())->toBe($mode)->and($this->state->character)->toBe($this->actor) + ->and(InputManager::getBindings())->toBe($bindings)->and($source->keys)->toBe([KeyCode::TAB]); + } + InputManager::handleInput(); + $this->state->execute(); + expect($this->state->character)->toBe($this->other); +}); + +it('shows the existing cycling aliases for missing or empty bindings without inventing a Back key', function (array $bindings) { + InputManager::setBindings($bindings); + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme(false)); + foreach (['equipment' => $this->state->canvas($theme), + 'status' => CharacterMenuPresentation::status($this->actor, $theme)] as $id => $canvas) { + $hint = array_column($canvas->textLayers, null, 'id')[$id . '-hints']; + expect(implode('', array_column($hint->runs, 'text')))->toBe(' Tab : Next Shift+Tab : Prev Unbound: Back'); + } + foreach ([KeyCode::TAB, KeyCode::SHIFT_TAB] as $index => $key) { + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $this->state->execute(); + expect($this->state->character)->toBe($index === 0 ? $this->other : $this->actor); + } +})->with([ + [[]], + [['character_next' => ['keys' => []], 'character_previous' => ['keys' => []], 'back' => ['keys' => []]]], +]); + +it('reserves wrapped footer height separately from descriptions and body records in both themes', function (bool $alternative) { + $labels = str_repeat('Control ', 5) . 'A'; + ActionHints::useProvider(new class($labels) implements ActionHintProvider { + public function __construct(private string $label) {} + public function controlForAction(string $action): ?ControlHint { return new ControlHint('profile', $action, $this->label); } + }); + $theme = new MenuPresentationCatalog($this->root, characterMenuTheme($alternative)); + foreach (['', str_repeat('Complete description ', 10)] as $description) { + $this->state->equipmentInfoPanel->setText($description); + foreach (['equipment' => $this->state->canvas($theme), + 'status' => CharacterMenuPresentation::status($this->actor, $theme)] as $id => $canvas) { + $layers = array_column($canvas->textLayers, null, 'id'); + $hint = $layers[$id . '-hints']; + $footer = array_values(array_filter([...$canvas->textLayers, ...$canvas->images], + fn($layer) => $layer->id === $id . '-info' || str_starts_with($layer->id, $id . '-info-'))); + $footerTop = min(array_map(fn($layer) => $layer->clipRect->y, $footer)); + expect($hint->grid->rows)->toBeGreaterThan(1) + ->and(implode('', array_column($hint->runs, 'text')))->toContain(' ' . $labels . ' : Next', ' ' . $labels . ' : Prev', ' ' . $labels . ' : Back') + ->and($hint->y)->toBeGreaterThanOrEqual($footerTop + $theme->metrics->panelPadding) + ->and($hint->y + $hint->grid->rows * $hint->grid->cellHeight)->toBeLessThanOrEqual(710 - $theme->metrics->panelPadding); + foreach ($layers as $layer) { + if (str_starts_with($layer->id, $id . '-stats-') || str_starts_with($layer->id, $id . '-slots-')) { + expect($layer->clipRect->y + $layer->clipRect->height)->toBeLessThanOrEqual($footerTop); + } + } + if ($id === 'equipment' && $description !== '') { + $prose = $layers['equipment-description']; + expect(implode('', array_column($prose->runs, 'text')))->toBe($description) + ->and($prose->y)->toBeGreaterThanOrEqual($footerTop + $theme->metrics->panelPadding) + ->and($prose->y + $prose->grid->rows * $prose->grid->cellHeight + $theme->metrics->sectionGap)->toBeLessThanOrEqual($hint->y); + } elseif ($id === 'equipment') { + expect($layers)->not->toHaveKey('equipment-description'); + } + } + } +})->with([false, true]); diff --git a/tests/Unit/CinematicGraphicalPresentationTest.php b/tests/Unit/CinematicGraphicalPresentationTest.php new file mode 100644 index 00000000..090fb6d2 --- /dev/null +++ b/tests/Unit/CinematicGraphicalPresentationTest.php @@ -0,0 +1,143 @@ +consoleBefore = new ReflectionClass(Console::class)->getStaticProperties(); + $this->cursorBefore = new ReflectionClass(Cursor::class)->getStaticProperties(); + foreach (['frameDepth' => 0, 'isRecomposing' => false, 'terminalHandedBack' => false, + 'output' => null, 'terminalOutputStream' => null] as $name => $value) { + new ReflectionProperty(Console::class, $name)->setValue(null, $value); + } + ob_start(); + Console::syncDimensions(24, 8); + Console::setLayerTracking(true); + $this->scene = new ReflectionClass(GameScene::class)->newInstanceWithoutConstructor(); + $this->camera = new Camera($this->scene, 24, 8, worldSpace: array_fill(0, 30, str_repeat('.', 60))); + new ReflectionProperty(GameScene::class, 'camera')->setValue($this->scene, $this->camera); + $this->stage = new CinematicStageManager($this->scene); + $this->presentation = new CinematicPresentationManager($this->scene); + $this->projector = new GraphicalSpriteProjector(); +}); + +afterEach(function () { + ob_end_clean(); + foreach ([Console::class => $this->consoleBefore, Cursor::class => $this->cursorBefore] as $class => $state) { + foreach ($state as $name => $value) { + new ReflectionProperty($class, $name)->setValue(null, $value); + } + } +}); + +it('projects staged sheets with independent identities and PHP-owned route animation', function () { + $entry = ['id' => 'one', 'sprite' => '@', 'x' => 7, 'y' => 4, 'sprites2d' => spriteSheetData()]; + $one = $this->stage->add($entry); + $two = $this->stage->add(array_replace($entry, ['id' => 'two', 'x' => 9])); + $this->stage->move('one', Vector2::right()); + $this->stage->advanceGraphicalAnimation(0.08); + expect($one->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(256) + ->and($two->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(0); + $this->stage->move('one', Vector2::right()); + $this->stage->advanceGraphicalAnimation(0.08); + expect($one->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(512); + $this->camera->moveTo(3, 2); + $sprite = $this->projector->project($one, $this->camera); + expect([$sprite->id, $sprite->x, $sprite->y, $sprite->asset])->toBe(['staged:one', 6, 2, 'Graphics/east.png']) + ->and($two->getGraphicalSpriteId())->toBe('staged:two'); + $copy = $one->getGraphicalSpriteWorldPosition(); + $copy->x = 999; + expect($one->position->x)->toBe(9.0); + $this->stage->move('one', Vector2::up(), faceOnly: true); + expect($one->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(0) + ->and($one->getGraphicalSpriteDefinition()->asset)->toBe('Graphics/north.png'); + $this->stage->move('one', Vector2::up()); + $this->stage->advanceGraphicalAnimation(0.16); + expect($one->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(0); +}); + +it('keeps terminal fallbacks intact and masks only the graphical actor provenance', function () { + $one = $this->stage->add(['id' => 'one', 'sprite' => '@', 'x' => 7, 'y' => 4, + 'sprites2d' => ['asset' => 'pose.png', 'width' => 56, 'height' => 56, 'layer' => 100, + 'sourceRect' => ['x' => 256, 'y' => 0, 'width' => 256, 'height' => 256]]]); + $legacy = $this->stage->add(['id' => 'legacy', 'sprite' => 'L', 'x' => 8, 'y' => 4]); + Console::recomposeFrame(function (): void { + Console::write(str_repeat('.', 24), 0, 4); + $this->stage->render(); + }); + $terminal = Console::snapshot(); + $graphical = Console::snapshot([$one->getGraphicalSpriteId()]); + expect(Console::charAt(7, 4))->toBe('@')->and(Console::charAt(8, 4))->toBe('L') + ->and($graphical->rows[4][7])->toBe('.')->and($graphical->rows[4][8])->toBe('L') + ->and($this->projector->project($legacy, $this->camera))->toBeNull() + ->and($one->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(256) + ->and(Console::snapshot())->toEqual($terminal); + $this->stage->hide('one'); + expect($this->projector->project($one, $this->camera))->toBeNull(); + $this->stage->show('one'); + expect($this->projector->project($one, $this->camera)->id)->toBe('staged:one'); + $this->stage->remove('one'); + expect($this->stage->all())->toBe([$legacy]); + $this->stage->clear(); + expect($this->stage->all())->toBe([]); +}); + +it('emits opaque cinematic overlays and covers above world sprites and clears them in the next snapshot', function () { + $actor = $this->stage->add(['id' => 'one', 'sprite' => '@', 'x' => 7, 'y' => 4, + 'sprites2d' => ['asset' => 'pose.png', 'width' => 56, 'height' => 56, 'layer' => 999]]); + $transport = new FakeRendererTransport(); + $output = new RendererPresentation(new RendererClient($transport), new RendererGridConfig(24, 8)); + $this->presentation->showOverlay('narration', 'A test line.'); + $this->presentation->hideField('#'); + $render = function () use ($actor, $output): void { + Console::recomposeFrame(function (): void { + $this->stage->render(); + $this->presentation->render(); + }); + $output->present(Console::presentationSnapshot([$actor->getGraphicalSpriteId()]), + [$this->projector->project($actor, $this->camera)]); + }; + $render(); + $frame = $transport->sent[0]->payload; + expect($frame['sprites'])->toHaveCount(1) + ->and(array_column($frame['textLayers'], 'layer'))->toContain(1020, 3000) + ->and(Console::charAt(7, 4))->toBe('#'); + $this->presentation->clear(); + $render(); + expect(array_column($transport->sent[1]->payload['textLayers'], 'layer'))->not->toContain(1020, 3000) + ->and($transport->sent[1]->payload['sprites'])->toBe($frame['sprites']); +}); + +it('validates staged graphics at authoring boundaries without requiring an asset on disk', function () { + $definition = CinematicDefinition::fromArrays(['id' => 'sheet-cast', 'name' => 'Sheet cast', + 'cast' => [['id' => 'one', 'sprite' => '@', 'sprites2d' => spriteSheetData()]]], [['type' => 'wait', 'seconds' => 1]]); + expect($definition->cast[0]['sprites2d'])->toBe(spriteSheetData()); +}); + +it('rejects malformed staged graphics with an actionable cast path', function ($graphics) { + expect(fn() => CinematicDefinition::fromArrays(['id' => 'invalid-cast', 'name' => 'Invalid cast', + 'cast' => [['id' => 'one', 'sprite' => '@', 'sprites2d' => $graphics]]], [])) + ->toThrow(InvalidArgumentException::class, 'cast[1]'); +})->with([ + 'wrong type' => ['image.png'], + 'missing fields' => [['asset' => 'pose.png']], + 'bad path' => [['asset' => '../pose.png', 'width' => 56, 'height' => 56]], + 'reserved UI layer' => [['asset' => 'pose.png', 'width' => 56, 'height' => 56, 'layer' => 1000]], + 'bad crop' => [['asset' => 'pose.png', 'width' => 56, 'height' => 56, + 'sourceRect' => ['x' => -1, 'y' => 0, 'width' => 256, 'height' => 256]]], +]); diff --git a/tests/Unit/CinematicSubjectOwnershipTest.php b/tests/Unit/CinematicSubjectOwnershipTest.php new file mode 100644 index 00000000..d14f36bd --- /dev/null +++ b/tests/Unit/CinematicSubjectOwnershipTest.php @@ -0,0 +1,763 @@ +onLoad)?->__invoke($filename, $player); + return $this; + } + public function canMoveTo(int $x, int $y, ?CollisionType &$collisionType = null): bool + { + if ($this->blocked) { + $collisionType = CollisionType::SOLID; + } + return !$this->blocked && ($this->passable === null || ($this->passable)($x, $y)); + } + public function scrollMap(Player $player, Vector2 $moveDirection): bool { return false; } +} + +final class SubjectOwnershipPlayer extends Player +{ + protected function renderLocationHUDWindow(): void {} + public function renderEventCues(): void {} + protected function handleCollision(?CollisionType $collisionType): void {} +} + +final class SubjectOwnershipPresentation implements EventPresentationInterface +{ + public int $resets = 0; + public function beginText(string $text, string $name = ''): void {} + public function beginChoice(string $prompt, array $options, string $title = ''): void {} + public function update(): void {} + public function render(): void {} + public function isComplete(): bool { return false; } + public function choiceResult(): ?int { return null; } + public function reset(): void { $this->resets++; } +} + +final class SubjectOwnershipScene extends GameScene +{ + public SubjectOwnershipPresentation $testPresentation; + public array $restoredTiles = []; + private Game $testGame; + + public function __construct() + { + [$this->testGame] = makeSceneAudioGame(); + $this->sceneManager = makeBareScene(\Ichiloto\Engine\Scenes\SceneManager::class); + $this->sceneManager->currentScene = $this; + $this->gameState = new GameState(); + $this->party = new Party(); + $this->uiManager = new class extends UIManager { + public function __construct() {} + public function render(): void {} + public function suspend(): void {} + public function resume(): void {} + }; + $this->uiManager->locationHUDWindow = new class extends LocationHUDWindow { + public function __construct() {} + public function updateDetails(Vector2 $position, MovementHeading $heading): void {} + public function refreshLayout(): void {} + }; + $this->currentMapId = 'map-a'; + $this->mapManager = new SubjectOwnershipMap(); + $this->camera = new Camera($this, 24, 8, worldSpace: array_fill(0, 30, str_repeat('.', 60))); + $this->npcManager = new NpcManager($this); + $this->cinematicStage = new CinematicStageManager($this); + $this->cinematicPresentation = new CinematicPresentationManager($this); + $this->cinematicController = new CinematicController($this); + $this->testPresentation = new SubjectOwnershipPresentation(); + $this->eventInterpreter = new EventInterpreter($this, $this->testPresentation); + $this->player = new SubjectOwnershipPlayer($this, 'hero', new Vector2(3, 4), new Rect(0, 0, 1, 1), ['v'], + MovementHeading::SOUTH, graphicalSprites: DirectionalGraphicalSpriteSet::fromArray(spriteSheetData())); + new ReflectionProperty(Player::class, 'isActive')->setValue($this->player, true); + $this->npcManager->configure([['id' => 'guide', 'name' => 'Guide', 'sprite' => 'N', 'x' => 7, 'y' => 4, + 'movement' => 'wander', 'conditions' => [['type' => 'switch', 'name' => 'departed', 'value' => false]], + 'sprites' => ['east' => 'E', 'west' => 'W', 'south' => 'S', 'north' => 'N']]]); + $this->camera->attach($this->player); + } + + public function getGame(): Game { return $this->testGame; } + public function onEventSessionFinished(EventExecutionSession $session, bool $completed): void + { + $this->requestFieldPresentationReconciliation(); + } + public function renderBackgroundTile(int $x, int $y): void { $this->restoredTiles[] = [$x, $y]; } + public function installField(): void + { + $this->fieldState = new class(new SceneStateContext($this)) extends FieldState { + public function resume(): void {} + public function renderTheField(bool $forceFullRepaint = false): void + { + Console::recomposeFrame(function (): void { + $this->getGameScene()->npcManager->render(); + $this->getGameScene()->cinematicStage->render(); + $this->getGameScene()->player->render(); + }); + } + }; + $this->state = $this->fieldState; + } + public function changeMapIdentity(string $id): void { $this->currentMapId = $id; } + public function finishSceneStopSetup(): void + { + $this->eventManager = EventManager::getInstance($this->testGame); + $this->modalEventHandler = static function (): void {}; + $this->camera = new class($this, 24, 8) extends Camera { + public function stop(): void {} + }; + } + public function failOnFinalizerWrite(): void + { + $this->eventInterpreter = new class($this, $this->testPresentation) extends EventInterpreter { + protected function execute(EventExecutionSession $session, EventExecutionLane $lane, array $command): EventCommandResult + { + if ($session->isFinalizing && ($command['name'] ?? '') === 'inject-failure') { + throw new RuntimeException('Injected finalizer boundary failure.'); + } + return parent::execute($session, $lane, $command); + } + }; + } +} + +function subjectOwnershipCast(array $extra = []): array +{ + return array_replace(['id' => 'pose', 'sprite' => '@', + 'subject' => ['kind' => 'npc', 'id' => 'guide'], 'sprites2d' => spriteSheetData()], $extra); +} + +function subjectOwnershipDefinition(array $commands = [], array $data = []): CinematicDefinition +{ + return CinematicDefinition::fromArrays(array_replace(['id' => 'ownership', 'name' => 'Ownership', + 'cast' => [subjectOwnershipCast(['suppress' => [['kind' => 'player']]])]], $data), + $commands ?: [['type' => 'wait', 'seconds' => 10]]); +} + +beforeEach(function () { + $this->staticBefore = []; + foreach ([Console::class, Cursor::class, EventManager::class, ConfigStore::class] as $class) { + $this->staticBefore[$class] = new ReflectionClass($class)->getStaticProperties(); + } + foreach (['frameDepth' => 0, 'isRecomposing' => false, 'terminalHandedBack' => false, + 'output' => null, 'terminalOutputStream' => null] as $name => $value) { + new ReflectionProperty(Console::class, $name)->setValue(null, $value); + } + putSceneAudioConfig(['ui' => ['hud' => ['location' => false]]]); + ob_start(); + Console::syncDimensions(24, 8); + Console::setLayerTracking(true); + $this->scene = new SubjectOwnershipScene(); + $this->stage = $this->scene->cinematicStage; + $this->npc = $this->scene->npcManager->findById('guide'); + $this->player = $this->scene->player; +}); + +afterEach(function () { + ob_end_clean(); + foreach ($this->staticBefore as $class => $state) { + foreach ($state as $name => $value) { + new ReflectionProperty($class, $name)->setValue(null, $value); + } + } +}); + +it('suppresses paired ordinary art and direct redraws without hiding collision or authored wandering', function () { + $this->scene->camera->moveTo(0, 0); + $actor = $this->stage->add(subjectOwnershipCast(['suppress' => [['kind' => 'player']]])); + Console::recomposeFrame(function (): void { + Console::write(str_repeat('.', 24), 0, 4); + $this->scene->npcManager->render(); + $this->player->render(); + $this->stage->render(); + }); + expect(Console::charAt(7, 4))->toBe('@')->and(Console::charAt(3, 4))->toBe('.') + ->and($this->player->getGraphicalSpriteDefinition())->toBeNull() + ->and($this->scene->npcManager->visibleNpcs())->toBe([$this->npc]) + ->and($this->scene->npcManager->npcAt(7, 4))->toBe($this->npc) + ->and($this->npc->wanders)->toBeTrue(); + $this->scene->npcManager->faceNpc('guide', Vector2::right()); + $this->player->face(Vector2::left(), $this->scene->camera); + $this->player->renderPlayer(); + $this->player->erase(); + expect(Console::charAt(7, 4))->toBe('@')->and($this->scene->restoredTiles)->toBe([]); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + expect($actor->position->x)->toBe(8.0)->and($actor->facing)->toBe(MovementHeading::EAST) + ->and(Console::charAt(8, 4))->not->toBe('E')->and($this->scene->restoredTiles)->toBe([]); + $this->stage->advanceGraphicalAnimation(0.08); + expect($actor->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(256); + $this->scene->npcManager->faceNpc('guide', Vector2::right()); + expect($actor->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(0); + $this->scene->installField(); + expect(iterator_to_array($this->scene->getGraphicalSpriteProviders()))->toBe([$actor]); +}); + +it('animates real player routes independently of its ordinary graphical provider', function () { + $actor = $this->stage->add(subjectOwnershipCast(['subject' => ['kind' => 'player']])); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + $this->stage->advanceGraphicalAnimation(0.08); + expect($actor->getGraphicalSpriteDefinition()->asset)->toBe('Graphics/east.png') + ->and($actor->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(256) + ->and($actor->position->x)->toBe(4.0); + $this->scene->mapManager->blocked = true; + $this->player->tryMove(Vector2::right(), $this->scene->camera); + expect($actor->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(0) + ->and($actor->position->x)->toBe(4.0); + expect(fn() => $this->stage->move('pose', Vector2::right()))->toThrow(RuntimeException::class, 'real subject'); + $copy = $actor->position; + $copy->x = 99; + expect($this->player->position->x)->toBe(4.0); +}); + +it('recomposes ownership before the first cinematic yield rather than retaining old terminal subjects', function () { + $this->scene->installField(); + $this->scene->camera->moveTo(0, 0); + $this->scene->npcManager->render(); + $this->player->render(); + expect(Console::charAt(7, 4))->toBe('N')->and(Console::charAt(3, 4))->toBe('v'); + $this->scene->startCinematic(subjectOwnershipDefinition()); + expect(Console::charAt(7, 4))->toBe('@')->and(Console::charAt(3, 4))->not->toBe('v'); +}); + +it('replaces sheets with cropped poses without resetting the subject or entry snapshot', function () { + $old = $this->stage->add(subjectOwnershipCast()); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $pose = $this->stage->add(subjectOwnershipCast(['replace' => true, 'sprite' => 'P', + 'sprites2d' => ['asset' => 'pose.png', 'width' => 56, 'height' => 56, + 'sourceRect' => ['x' => 128, 'y' => 0, 'width' => 128, 'height' => 128]]])); + expect($pose->subject)->toBe($old->subject)->and($old->getGraphicalSpriteDefinition())->toBeNull() + ->and($pose->position->x)->toBe(8.0)->and($pose->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(128) + ->and($pose->getGraphicalSpriteId())->toBe($old->getGraphicalSpriteId()); + $this->stage->clear(); + expect($this->npc->position->x)->toBe(7.0)->and($this->npc->heading)->toBe(MovementHeading::SOUTH) + ->and($this->npc->sprite)->toBe('N')->and($pose->isVisible)->toBeFalse(); +}); + +it('releases a replaced paired participant while retaining its failure recovery', function () { + $this->stage->add(subjectOwnershipCast(['suppress' => [['kind' => 'player']]])); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + $this->stage->add(subjectOwnershipCast(['replace' => true])); + expect($this->stage->suppresses($this->player))->toBeFalse() + ->and($this->player->getGraphicalSpriteDefinition())->not->toBeNull(); + $this->stage->clear(); + expect($this->player->position->x)->toBe(3.0); +}); + +it('keeps hidden takeover hidden and removed visuals released without discarding recovery', function () { + $actor = $this->stage->add(subjectOwnershipCast()); + $this->stage->hide('pose'); + expect($actor->isVisible)->toBeFalse()->and($this->stage->suppresses($this->npc))->toBeTrue(); + $this->stage->show('pose'); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->stage->remove('pose'); + expect($actor->isVisible)->toBeFalse()->and($this->stage->suppresses($this->npc))->toBeFalse(); + $this->stage->clear(); + expect($this->npc->position->x)->toBe(7.0); +}); + +it('never resurrects an ineligible real NPC or a stale paired visual', function () { + $actor = $this->stage->add(subjectOwnershipCast(['suppress' => [['kind' => 'player']]])); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->gameState->setSwitch('departed', true); + expect($actor->isVisible)->toBeFalse()->and($this->stage->suppresses($this->player))->toBeFalse() + ->and($this->scene->npcManager->visibleNpcs())->toBe([]); + $this->stage->clear(); + expect($this->npc->position->x)->toBe(8.0)->and($this->scene->gameState->getSwitch('departed'))->toBeTrue(); + Console::recomposeFrame(function (): void { + $this->scene->npcManager->faceNpc('guide', Vector2::left()); + $this->scene->npcManager->render(); + }); + expect(Console::charAt(8, 4))->not->toBe('W'); +}); + +it('keeps failed replacement and competing owner acquisition atomic', function () { + $actor = $this->stage->add(subjectOwnershipCast()); + expect(fn() => $this->stage->add(subjectOwnershipCast(['id' => 'other']))) + ->toThrow(RuntimeException::class, 'already owned'); + expect(fn() => $this->stage->add(subjectOwnershipCast(['replace' => true, 'suppress' => [['kind' => 'npc', 'id' => 'absent']]]))) + ->toThrow(RuntimeException::class, 'unavailable'); + expect($this->stage->all())->toBe([$actor])->and($actor->isVisible)->toBeTrue(); +}); + +it('reserves ownership across temporary eligibility changes rather than creating future duplicates', function () { + $paired = $this->stage->add(subjectOwnershipCast(['suppress' => [['kind' => 'player']]])); + $this->scene->gameState->setSwitch('departed', true); + expect($paired->isVisible)->toBeFalse(); + expect(fn() => $this->stage->add(subjectOwnershipCast(['id' => 'other', 'subject' => ['kind' => 'player']]))) + ->toThrow(RuntimeException::class, 'already owned'); + $this->scene->gameState->setSwitch('departed', false); + expect($this->stage->all())->toBe([$paired])->and($paired->isVisible)->toBeTrue(); +}); + +it('restores failure transforms but retains committed transforms and story outcomes', function () { + $session = $this->scene->startCinematic(subjectOwnershipDefinition()); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + $this->stage->commitSubjectTransforms($this->npc); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->gameState->setSwitch('outcome', true); + $this->scene->camera->detach(); + $this->scene->eventInterpreter->failActiveSession('controlled failure'); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->scene->hasUnstableEventSession())->toBeFalse() + ->and($this->scene->cinematicController->active())->toBeNull() + ->and($this->npc->position->x)->toBe(8.0)->and($this->player->position->x)->toBe(3.0) + ->and($this->player->heading)->toBe(MovementHeading::SOUTH) + ->and($this->scene->camera->captureState()->followsPlayer)->toBeTrue() + ->and($this->scene->gameState->getSwitch('outcome'))->toBeTrue() + ->and($this->scene->hasStoryEvent('cinematic:ownership:completed'))->toBeFalse() + ->and($this->stage->all())->toBe([]); +}); + +it('keeps intentional transforms on successful completion and rejects stale callbacks on re-entry', function () { + $first = $this->scene->startCinematic(subjectOwnershipDefinition()); + $old = $this->stage->require('pose'); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + $this->scene->eventInterpreter->update(10); + expect($first->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($this->npc->position->x)->toBe(8.0)->and($this->player->position->x)->toBe(4.0); + $second = $this->scene->startCinematic(subjectOwnershipDefinition()); + $new = $this->stage->require('pose'); + $this->scene->cinematicController->onEventSessionFailed($this->scene, $first); + expect(fn() => $this->scene->cinematicController->onEventSessionCompleted($this->scene, $first)) + ->toThrow(RuntimeException::class, 'identity'); + $old->show(); + expect($old->isVisible)->toBeFalse()->and($new->isVisible)->toBeTrue() + ->and($this->scene->eventInterpreter->activeSession())->toBe($second); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->eventInterpreter->failActiveSession('second failure'); + expect($this->npc->position->x)->toBe(8.0); +}); + +it('cleans up partially acquired cast when start fails before a session is created', function () { + $definition = subjectOwnershipDefinition(data: ['presentation' => ['initial' => 'hidden'], + 'cast' => [subjectOwnershipCast(), subjectOwnershipCast(['id' => 'missing', 'subject' => ['kind' => 'npc', 'id' => 'absent']])]]); + expect(fn() => $this->scene->startCinematic($definition))->toThrow(RuntimeException::class, 'unavailable'); + expect($this->stage->all())->toBe([])->and($this->stage->suppresses($this->npc))->toBeFalse() + ->and($this->scene->cinematicController->active())->toBeNull() + ->and($this->scene->hasUnstableEventSession())->toBeFalse() + ->and($this->scene->cinematicPresentation->hasTransitionCover())->toBeFalse() + ->and($this->scene->camera->captureState()->followsPlayer)->toBeTrue(); +}); + +it('never applies old-map recovery to another NPC with the same id or the transferred player', function () { + $session = $this->scene->startCinematic(subjectOwnershipDefinition()); + $old = $this->stage->require('pose'); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->changeMapIdentity('map-b'); + $this->scene->npcManager->configure([['id' => 'guide', 'name' => 'Other', 'sprite' => 'B', 'x' => 18, 'y' => 4]]); + $newNpc = $this->scene->npcManager->findById('guide'); + $this->player->position->x = 15; + expect($old->isVisible)->toBeFalse()->and($this->stage->suppresses($newNpc))->toBeFalse(); + $this->scene->eventInterpreter->failActiveSession('failure after transfer'); + expect($session->status)->toBe(EventExecutionStatus::FAILED)->and($newNpc->position->x)->toBe(18.0) + ->and($this->player->position->x)->toBe(15.0)->and($this->npc->position->x)->toBe(8.0); +}); + +it('restores and releases map-local leases before transfer and makes held references inert', function () { + $actor = $this->stage->add(subjectOwnershipCast(['suppress' => [['kind' => 'player']]])); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->stage->clear(); + $this->scene->changeMapIdentity('map-b'); + $this->player->position->x = 15; + $actor->subject->restore(); + expect($this->npc->position->x)->toBe(7.0)->and($this->player->position->x)->toBe(15.0) + ->and($actor->isVisible)->toBeFalse(); +}); + +it('clears the source-map lease before the real transfer path assigns destination coordinates', function () { + $this->scene->startCinematic(subjectOwnershipDefinition()); + $old = $this->stage->require('pose'); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->mapManager->onLoad = function (string $filename, Player $player): void { + expect($filename)->toBe('map-b')->and($player->position->x)->toBe(18.0) + ->and($this->npc->position->x)->toBe(7.0)->and($this->stage->all())->toBe([]); + $this->scene->npcManager->configure([['id' => 'guide', 'name' => 'Other', 'sprite' => 'B', 'x' => 20, 'y' => 4]]); + }; + $this->scene->transferPlayer(new Location('map-b', new Vector2(18, 4), null), useConfiguredTransition: false); + $this->scene->eventInterpreter->failActiveSession('after real transfer'); + expect($this->player->position->x)->toBe(18.0) + ->and($this->scene->npcManager->findById('guide')->position->x)->toBe(20.0) + ->and($old->isVisible)->toBeFalse(); +}); + +it('invalidates old leases even when the same map and NPC id are reloaded', function () { + $old = $this->stage->add(subjectOwnershipCast()); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->mapManager->onLoad = function (): void { + $this->scene->npcManager->configure([['id' => 'guide', 'name' => 'Reloaded', 'sprite' => 'R', 'x' => 20, 'y' => 4]]); + }; + $this->scene->loadMap('map-a', $this->player); + $new = $this->stage->add(subjectOwnershipCast()); + $old->subject->restore(); + expect($new->position->x)->toBe(20.0)->and($old->isVisible)->toBeFalse() + ->and($old->subject)->not->toBe($new->subject)->and($this->npc->position->x)->toBe(7.0); +}); + +it('retains a detached cinematic camera and subject identity through suspend resume and resize', function () { + $this->scene->installField(); + $session = $this->scene->startCinematic(subjectOwnershipDefinition()); + $actor = $this->stage->require('pose'); + $this->scene->camera->detach(); + $this->scene->camera->moveTo(3, 2); + $this->scene->suspend(); + $this->scene->resume(); + $this->scene->onScreenResize(20, 7); + expect($this->stage->require('pose'))->toBe($actor) + ->and($this->scene->eventInterpreter->activeSession())->toBe($session) + ->and($this->scene->camera->followsPlayer)->toBeFalse() + ->and($this->scene->camera->position->x)->toBe(3.0) + ->and($this->scene->camera->position->y)->toBe(2.0) + ->and($this->stage->suppresses($this->player))->toBeTrue(); +}); + +it('routes the same real subject under reduced motion without an independent visual route', function (bool $reduced) { + putSceneAudioConfig(['accessibility' => ['reducedMotion' => $reduced]]); + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'secondsPerStep' => 0.1, + 'steps' => [['direction' => 'right', 'count' => 3]]], + ['type' => 'wait', 'seconds' => 10], + ])); + $actor = $this->stage->require('pose'); + for ($tick = 0; $tick < 4; $tick++) { + $this->scene->eventInterpreter->update(0.1); + } + expect($actor->position->x)->toBe(10.0)->and($this->npc->position->x)->toBe(10.0) + ->and($actor->facing)->toBe(MovementHeading::EAST) + ->and($this->scene->eventInterpreter->activeSession())->toBe($session); + $this->scene->eventInterpreter->failActiveSession('restore route'); + expect($this->npc->position->x)->toBe(7.0); +})->with([false, true]); + +it('walks authored waypoints and retraces the actual entry with its original facing', function (bool $reduced) { + putSceneAudioConfig(['accessibility' => ['reducedMotion' => $reduced]]); + $this->scene->mapManager->passable = static fn(int $x, int $y): bool => $x !== 4 || $y !== 4; + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'remember' => 'approach', 'secondsPerStep' => 0.1, + 'waypoints' => [['x' => 5, 'y' => 4], ['y' => 6]]], + ['type' => 'move_route', 'retrace' => 'approach', 'secondsPerStep' => 0.1], + ], ['cast' => []])); + $positions = [[3, 4]]; + for ($tick = 0; $tick < 50 && $this->scene->hasUnstableEventSession(); $tick++) { + $this->scene->eventInterpreter->update(0.1); + $position = [intval($this->player->position->x), intval($this->player->position->y)]; + if ($position !== $positions[array_key_last($positions)]) { + $positions[] = $position; + } + } + expect($session->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($this->player->position->x)->toBe(3.0)->and($this->player->position->y)->toBe(4.0) + ->and($this->player->heading)->toBe(MovementHeading::SOUTH); + if (!$reduced) { + $outbound = [[3, 4], [3, 3], [4, 3], [5, 3], [5, 4], [5, 5], [5, 6]]; + expect($positions)->toBe([...$outbound, ...array_slice(array_reverse($outbound), 1)]); + } +})->with([false, true]); + +it('records a zero-length approach and returns without inventing a movement unit', function () { + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'remember' => 'here', 'waypoints' => [['x' => 3, 'y' => 4]]], + ['type' => 'move_route', 'retrace' => 'here'], + ], ['cast' => []])); + for ($tick = 0; $tick < 4; $tick++) { + $this->scene->eventInterpreter->update(1); + } + expect($session->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($this->player->position->x)->toBe(3.0)->and($this->player->position->y)->toBe(4.0); +}); + +it('fails a newly blocked step and restores a real subject even without replacement art', function (bool $reduced) { + putSceneAudioConfig(['accessibility' => ['reducedMotion' => $reduced]]); + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'remember' => 'approach', 'waypoints' => [['x' => 6]], 'secondsPerStep' => 0.1], + ], ['cast' => []])); + if (!$reduced) { + $this->scene->eventInterpreter->update(0.1); + expect($this->player->position->x)->toBe(4.0); + } + $this->scene->mapManager->blocked = true; + $this->scene->eventInterpreter->update(0.1); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($session->failureMessage)->toContain('was blocked') + ->and($this->player->position->x)->toBe(3.0)->and($this->player->heading)->toBe(MovementHeading::SOUTH) + ->and($this->scene->hasUnstableEventSession())->toBeFalse(); +})->with([false, true]); + +it('rejects two simultaneous movement owners of one real subject', function () { + $route = ['type' => 'move_route', 'secondsPerStep' => 1, 'steps' => [['direction' => 'right', 'count' => 3]]]; + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'parallel', 'lanes' => [['id' => 'first', 'commands' => [$route]], + ['id' => 'second', 'commands' => [$route]]]], + ], ['cast' => []])); + $this->scene->eventInterpreter->update(0.1); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($session->failureMessage)->toContain('Only one movement route') + ->and($this->player->position->x)->toBe(3.0); +}); + +it('rejects missing duplicate and incomplete remembered routes', function (string $case) { + $this->scene->startCinematic(subjectOwnershipDefinition(data: ['cast' => []])); + $session = $this->scene->eventInterpreter->activeSession(); + if ($case !== 'missing') { + $route = new MovementRouteRunner($this->scene, ['remember' => 'entry', + 'steps' => [['direction' => 'right']]], $session); + } + $command = $case === 'duplicate' + ? ['remember' => 'entry', 'steps' => [['direction' => 'right']]] : ['retrace' => 'entry']; + expect(fn() => new MovementRouteRunner($this->scene, $command, $session))->toThrow(RuntimeException::class); +})->with(['missing', 'duplicate', 'incomplete']); + +it('refuses stale displaced wrong-subject and consumed return paths', function (string $case) { + $this->scene->startCinematic(subjectOwnershipDefinition(data: ['cast' => []])); + $session = $this->scene->eventInterpreter->activeSession(); + $route = new MovementRouteRunner($this->scene, ['remember' => 'entry', + 'steps' => [['direction' => 'right']]], $session); + $route->update(0.1); + $command = ['retrace' => 'entry']; + match ($case) { + 'map' => $this->scene->changeMapIdentity('map-b'), + 'generation' => $this->stage->clear(false), + 'displaced' => $this->player->position->x = 9, + 'subject' => $command += ['subject' => 'npc', 'npcId' => 'guide'], + 'consumed' => (new MovementRouteRunner($this->scene, $command, $session))->cancel(), + }; + expect(fn() => new MovementRouteRunner($this->scene, $command, $session))->toThrow(RuntimeException::class); +})->with(['map', 'generation', 'displaced', 'subject', 'consumed']); + +it('pins an in-flight NPC route to its original object rather than reusing a stable id', function () { + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'waypoints' => [['x' => 10]]], + ], ['cast' => []])); + $this->scene->npcManager->configure([['id' => 'guide', 'name' => 'Replacement', 'sprite' => 'R', 'x' => 20, 'y' => 4]]); + $this->scene->eventInterpreter->update(1); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->scene->npcManager->findById('guide')->position->x)->toBe(20.0); +}); + +it('treats the player as an occupied tile when planning an NPC waypoint leg', function () { + $this->player->position->x = 8; + $steps = MovementRoutePlanner::plan($this->scene, $this->npc->position, new Vector2(9, 4), true); + expect($steps)->toBe([['direction' => 'up'], ['direction' => 'right'], ['direction' => 'right'], ['direction' => 'down']]); +}); + +it('identifies unavailable movement subjects and their map', function (array $subject, string $label) { + expect(fn() => new MovementRouteRunner($this->scene, [ + ...$subject, 'steps' => [['direction' => 'right']], + ]))->toThrow(RuntimeException::class, sprintf( + 'Movement-route %s is not available on map "map-a".', $label, + )); +})->with([ + 'missing NPC' => [['subject' => 'npc', 'npcId' => 'missing-npc'], 'NPC "missing-npc"'], + 'missing staged actor' => [['subject' => 'staged_actor', 'actorId' => 'missing-stage'], 'staged actor "missing-stage"'], +]); + +it('does not let repeated cancellation release a later movement owner', function () { + $this->scene->startCinematic(subjectOwnershipDefinition(data: ['cast' => []])); + $session = $this->scene->eventInterpreter->activeSession(); + $command = ['steps' => [['direction' => 'right']]]; + $first = new MovementRouteRunner($this->scene, $command, $session); + $first->update(1); + $second = new MovementRouteRunner($this->scene, $command, $session); + $first->cancel(); + expect(fn() => new MovementRouteRunner($this->scene, $command, $session))->toThrow(RuntimeException::class); + $second->cancel(); +}); + +it('restores a directionless entry pose after walking home', function () { + $this->player->restoreFieldTransform(clone $this->player->position, MovementHeading::NONE, ['@']); + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'remember' => 'approach', 'steps' => [['direction' => 'right']]], + ['type' => 'move_route', 'retrace' => 'approach'], + ], ['cast' => []])); + for ($tick = 0; $tick < 4; $tick++) { + $this->scene->eventInterpreter->update(1); + } + expect($session->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($this->player->heading)->toBe(MovementHeading::NONE)->and($this->player->sprite)->toBe(['@']); +}); + +it('validates both waypoint endpoints before flattening map coordinates', function (float $x, float $y) { + $origin = new Vector2(); + $origin->x = $x; + $origin->y = $y; + expect(fn() => MovementRoutePlanner::plan($this->scene, $origin, new Vector2(0, 1), false)) + ->toThrow(RuntimeException::class); +})->with([[60.0, 0.0], [-1.0, 1.0], [1.5, 1.0], [INF, 1.0], [1.0, NAN]]); + +it('bounds waypoint search work on a large unreachable map', function () { + $this->scene->camera->worldSpaceWidth = 260; + $this->scene->camera->worldSpaceHeight = 260; + $this->scene->mapManager->passable = static fn(int $x, int $y): bool => $x !== 259 || $y !== 259; + expect(fn() => MovementRoutePlanner::plan($this->scene, new Vector2(0, 0), new Vector2(259, 259), false)) + ->toThrow(RuntimeException::class, 'cell budget'); +}); + +it('does not reuse route history in a fresh cinematic session', function () { + $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'remember' => 'approach', 'steps' => [['direction' => 'right']]], + ], ['cast' => []])); + $this->scene->eventInterpreter->update(1); + $session = $this->scene->startCinematic(subjectOwnershipDefinition([ + ['type' => 'move_route', 'retrace' => 'approach'], + ], ['cast' => []])); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($session->failureMessage)->toContain('was not recorded in this session'); +}); + +it('rejects malformed route extensions in both authoring and runtime', function (array $command) { + $command = ['type' => 'move_route', ...$command]; + expect(fn() => CinematicScriptValidator::validate([$command]))->toThrow(InvalidArgumentException::class) + ->and(fn() => new MovementRouteRunner($this->scene, $command))->toThrow(RuntimeException::class); +})->with([ + [['waypoints' => []]], [['waypoints' => [['x' => 2.5]]]], [['waypoints' => [['x' => -1]]]], + [['waypoints' => [['z' => 3]]]], [['waypoints' => [['x' => 3]], 'steps' => [['direction' => 'up']]]], + [['retrace' => 'entry', 'remember' => 'other']], [['retrace' => '../entry']], + [['subject' => 'staged_actor', 'actorId' => 'pose', 'waypoints' => [['x' => 1]]]], + [['waypoints' => [['x' => 3]], 'secondsPerStep' => INF]], + [['waypoints' => [['x' => 3]], 'speed' => NAN]], + [['steps' => [['direction' => 'up', 'seconds' => INF]]]], +]); + +it('restores transforms and camera and releases event input at scene shutdown', function () { + $this->scene->finishSceneStopSetup(); + $this->scene->camera->attach($this->player); + $session = $this->scene->startCinematic(subjectOwnershipDefinition()); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->scene->camera->detach(); + $this->scene->stop(); + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->scene->hasUnstableEventSession())->toBeFalse() + ->and($this->scene->camera->captureState()->followsPlayer)->toBeTrue() + ->and($this->npc->position->x)->toBe(7.0)->and($this->stage->all())->toBe([]); + $this->scene->stop(); +}); + +it('keeps the restoration and explicit commit seam independent of finalizer interpretation', function () { + $this->stage->add(subjectOwnershipCast(['suppress' => [['kind' => 'player']]])); + $this->player->position->x = 9; + $this->npc->position->x = 12; + $this->stage->restoreSubjectTransforms(); + expect($this->player->position->x)->toBe(3.0)->and($this->npc->position->x)->toBe(7.0); + $this->player->position->x = 20; + $this->stage->commitSubjectTransforms($this->player); + $this->stage->clear(); + expect($this->player->position->x)->toBe(20.0); +}); + +it('restores temporary transforms before direct interpreter skip and preserves the authored finalizer position', function () { + $session = $this->scene->startCinematic(subjectOwnershipDefinition(data: ['skip' => ['policy' => 'authored'], + 'finalizer' => [['type' => 'move_player', 'x' => 19, 'y' => 6], + ['type' => 'set_variable', 'name' => 'count', 'value' => 1], ['type' => 'camera', 'operation' => 'attach']]])); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + expect($this->scene->eventInterpreter->skipActiveCinematic())->toBeTrue() + ->and($session->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($this->npc->position->x)->toBe(7.0)->and($this->player->position->x)->toBe(19.0) + ->and($this->player->position->y)->toBe(6.0) + ->and($this->scene->gameState->getVariable('count'))->toBe(1) + ->and($this->scene->hasStoryEvent('cinematic:ownership:completed'))->toBeTrue() + ->and($this->scene->hasUnstableEventSession())->toBeFalse() + ->and($this->scene->skipCinematic())->toBeFalse(); +}); + +it('preserves finalizer moves and story writes when a later finalizer command fails', function (bool $skip) { + $this->scene->failOnFinalizerWrite(); + $session = $this->scene->startCinematic(subjectOwnershipDefinition(data: ['skip' => ['policy' => 'authored'], + 'finalizer' => [['type' => 'move_player', 'x' => 19, 'y' => 6], + ['type' => 'set_switch', 'name' => 'ratified', 'value' => true], + ['type' => 'set_switch', 'name' => 'inject-failure', 'value' => true]]])); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + $this->player->tryMove(Vector2::right(), $this->scene->camera); + if ($skip) { + expect($this->scene->skipCinematic())->toBeFalse(); + } else { + $this->scene->eventInterpreter->update(10); + } + expect($session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->player->position->x)->toBe(19.0)->and($this->player->position->y)->toBe(6.0) + ->and($this->npc->position->x)->toBe(7.0) + ->and($this->scene->gameState->getSwitch('ratified'))->toBeTrue() + ->and($this->scene->hasStoryEvent('cinematic:ownership:completed'))->toBeFalse() + ->and($this->stage->all())->toBe([])->and($this->scene->hasUnstableEventSession())->toBeFalse(); +})->with([false, true]); + +it('does not restore or release subjects when skipping is forbidden', function () { + $this->scene->startCinematic(subjectOwnershipDefinition()); + $this->scene->npcManager->moveNpcById('guide', Vector2::right()); + expect($this->scene->skipCinematic())->toBeFalse()->and($this->npc->position->x)->toBe(8.0) + ->and($this->stage->suppresses($this->npc))->toBeTrue(); +}); + +it('provides terminal-only bound fallback and camera-relative graphical projection', function () { + $actor = $this->stage->add(subjectOwnershipCast()); + $this->scene->camera->detach(); + $this->scene->camera->moveTo(3, 2); + $projected = new GraphicalSpriteProjector()->project($actor, $this->scene->camera); + expect([$projected->x, $projected->y])->toBe([4, 2]); + $fallback = subjectOwnershipCast(['replace' => true]); + unset($fallback['sprites2d']); + $actor = $this->stage->add($fallback); + Console::recomposeFrame(fn() => $this->stage->render()); + expect($actor->getGraphicalSpriteDefinition())->toBeNull()->and(Console::charAt(4, 2))->toBe('@'); +}); + +it('validates bound ownership fields before runtime', function (array $invalid) { + expect(fn() => subjectOwnershipDefinition(data: ['cast' => [array_replace(subjectOwnershipCast(), $invalid)]])) + ->toThrow(InvalidArgumentException::class, 'cast[1]/subject'); +})->with([ + 'independent coordinates' => [['x' => 7]], + 'independent facing' => [['facing' => 'East']], + 'second collision entity' => [['collision' => true]], + 'unstable npc identity' => [['subject' => ['kind' => 'npc']]], + 'non-real anchor' => [['subject' => ['kind' => 'staged_actor', 'id' => 'other']]], + 'bad suppression list' => [['suppress' => ['kind' => 'player']]], + 'bad replacement flag' => [['replace' => 'yes']], +]); diff --git a/tests/Unit/ConfigMenuPresentationTest.php b/tests/Unit/ConfigMenuPresentationTest.php new file mode 100644 index 00000000..d750c7b2 --- /dev/null +++ b/tests/Unit/ConfigMenuPresentationTest.php @@ -0,0 +1,423 @@ +options; } + public function persist(): void + { + $this->writes++; + if ($this->failure !== null) { throw new RuntimeException($this->failure); } + } +} + +function configPresentationPng(string $path, int $width, int $height): void +{ + $chunk = static fn(string $type, string $bytes) => pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAD\xBE\xCF\xFF", $width), $height))) . $chunk('IEND', '')); +} + +function configPresentationTheme(bool $art = false): array +{ + $data = ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]; + if (!$art) { return $data; } + $surface = ['asset' => 'surface.png', 'cuts' => [2, 2, 2, 2]]; + return [...$data, 'colors' => ['text' => [30, 40, 50], 'panel' => [230, 240, 210], 'accent' => [140, 40, 10]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 34, 'panelPadding' => 16, 'sectionGap' => 8], + 'rowMetrics' => ['padding' => 24, 'cursorWidth' => 10, 'cursorHeight' => 20, 'cursorTravel' => 3, 'cursorPeriod' => 2], + 'frames' => ['panel' => $surface, 'slider.track' => $surface, 'slider.thumb' => ['asset' => 'thumb.png'], + 'scroll.track' => $surface, 'scroll.thumb' => $surface], + 'rowArtwork' => ['selected' => $surface, 'focus' => $surface], + 'icons' => ['navigation.previous' => 'arrow.png', 'navigation.next' => 'arrow.png', + 'navigation.up' => 'arrow.png', 'navigation.down' => 'arrow.png', 'decoration.divider' => 'arrow.png']]; +} + +function configPresentationText(PresentationCanvas $frame): array +{ + $text = []; + foreach ($frame->textLayers as $layer) { + $runs = array_filter($layer->runs, fn($run) => $run->foreground !== null); + if ($runs !== []) { $text[$layer->id] = implode('', array_column($runs, 'text')); } + } + return $text; +} + +function configPresentationKey(ConfigMenu $menu, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $menu->update(); +} + +/** Text batching may combine IDs; verify the painted run and its actual coordinates. */ +function getConfigTextPosition(PresentationCanvas $frame, string $text, ?float $y = null): array +{ + foreach ($frame->textLayers as $layer) { + foreach ($layer->runs as $run) { + $top = $layer->y + $run->row * $layer->grid->cellHeight; + if ($run->text === $text && $run->foreground !== null && ($y === null || abs($top - $y) <= 1)) { + return ['x' => $layer->x + $run->column * $layer->grid->cellWidth, 'y' => $top, + 'width' => mb_strlen($text) * $layer->grid->cellWidth, 'height' => $layer->grid->cellHeight, + 'layer' => $layer->layer, 'color' => $run->foreground]; + } + } + } + throw new RuntimeException('Expected visible Config text: ' . $text); +} + +beforeEach(function () { + $this->statics = []; + foreach ([Console::class, InputManager::class, ConfigStore::class, AudioManager::class, ActionHints::class] as $class) { + $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-config-menu-' . bin2hex(random_bytes(5)); + mkdir($this->root); + foreach (['surface', 'arrow', 'thumb'] as $file) { configPresentationPng($this->root . '/' . $file . '.png', 71, 39); } + $this->config = new ConfigPresentationMemoryConfig(['audio' => ['master_volume' => 75, 'music' => false, 'sfx' => false]]); + ConfigStore::put(ProjectConfig::class, $this->config); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::ENTER]], 'cancel' => ['keys' => [KeyCode::ESCAPE]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]]]); + $this->manager = new MainMenuSettingsManager(); + $this->backs = 0; + $this->menu = new ConfigMenu($this->manager, + new ConfigSelectionWindow(new Rect(0, 0, 80, 20), $this->manager), + new ConfigDetailPanel(new Rect(0, 20, 80, 8)), function () { $this->backs++; }); + ob_start(); + $this->menu->enter(); +}); + +afterEach(function () { + ob_end_clean(); + foreach ($this->statics as $class => $values) { + foreach ($values as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + foreach (glob($this->root . '/*') as $path) { unlink($path); } + rmdir($this->root); +}); + +it('projects every live setting value and full description through both themes at finite viewport sizes', function (bool $art, int $width, int $height) { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme($art)); + $settings = $this->menu->selection->getSettings(); + expect($settings)->toHaveCount(11); + foreach ($settings as $index => $setting) { + $frame = ConfigMenuPresentation::compose($this->menu, $theme, width: $width, height: $height); + $text = configPresentationText($frame); + $layers = array_column($frame->textLayers, null, 'id'); + $label = getConfigTextPosition($frame, $setting->label); + $value = getConfigTextPosition($frame, $this->manager->getCurrentChoiceLabel($setting), $label['y']); + $page = $this->menu->menuInfoText->lastPage; + $description = getConfigTextPosition($frame, $page->lines[0]); + foreach ($page->lines as $line) { getConfigTextPosition($frame, $line); } + expect(implode('', $page->lines))->toBe($setting->description); + $cancel = getConfigTextPosition($frame, 'Cancel'); + expect($this->menu->menuInfoText->lastPage->source)->toBe($setting->description) + ->and(count($frame->textLayers))->toBeLessThanOrEqual(64) + ->and(implode(' ', array_keys($layers)))->not->toContain('config-hints', 'cancel-cursor', 'cancel-separator'); + $ids = [...array_column($frame->textLayers, 'id'), ...array_column($frame->images, 'id')]; + expect(count($ids))->toBe(count(array_unique($ids))); + foreach ($frame->images as $image) { $image->destination->assertWithin($width, $height); } + $host = \Ichiloto\Engine\UI\Presentation\MenuLayout::getBounds($width, $height); + $buttonWidth = \Ichiloto\Engine\UI\Presentation\MenuLayout::getButtonWidth($theme, 'Cancel', $host->width - 2 * $theme->metrics->panelPadding); + expect($cancel['x'] + $cancel['width'] / 2)->toBe($host->x + $host->width - $theme->metrics->panelPadding - $buttonWidth / 2); + $treatments = array_filter([...$frame->textLayers, ...$frame->images], + fn($piece) => str_contains($piece->id, 'config-setting-' . $index . '-selected')); + foreach ($treatments as $piece) { expect($value['layer'])->toBeGreaterThan($piece->layer); } + expect($value['y'] + $value['height'])->toBeLessThanOrEqual($description['y']); + $this->menu->selection->selectNext(); + } + expect($this->config->writes)->toBe(0); +})->with([false, true])->with([[1350, 720], [960, 540], [736, 414]]); + +it('keeps adjustment clamping wrapping persistence and semantic cancel in the existing controller', function () { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::X]], 'cancel' => ['keys' => [KeyCode::Q]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]]]); + $this->config->set('audio.master_volume', 100); + configPresentationKey($this->menu, KeyCode::X); + expect($this->config->get('audio.master_volume'))->toBe(100)->and($this->config->writes)->toBe(1); + $this->config->set('audio.master_volume', 0); + configPresentationKey($this->menu, KeyCode::LEFT); + expect($this->config->get('audio.master_volume'))->toBe(0); + configPresentationKey($this->menu, KeyCode::RIGHT); + expect($this->config->get('audio.master_volume'))->toBe(5) + ->and(getConfigTextPosition(ConfigMenuPresentation::compose($this->menu, $theme), 'Volume set to 5%.')['color']) + ->toBe($theme->colors['disabled']); + configPresentationKey($this->menu, KeyCode::DOWN); + expect($this->menu->getStatusMessage())->toBeNull(); + configPresentationKey($this->menu, KeyCode::LEFT); + expect($this->config->get('audio.music'))->toBeTrue(); + configPresentationKey($this->menu, KeyCode::X); + expect($this->config->get('audio.music'))->toBeFalse(); + configPresentationKey($this->menu, KeyCode::Q); + expect($this->backs)->toBe(1)->and($this->config->writes)->toBe(5); +}); + +it('pages the entire live persistence failure without resizing or inventing rollback', function () { + $this->config->failure = str_repeat('A complete persistence detail. ', 6); + configPresentationKey($this->menu, KeyCode::RIGHT); + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + foreach ([$this->config->failure, str_repeat('Long full error ', 150)] as $failure) { + if ($failure !== $this->config->failure) { + $this->config->failure = $failure; + configPresentationKey($this->menu, KeyCode::RIGHT); + } + $seen = ''; + $footer = null; + do { + $frame = ConfigMenuPresentation::compose($this->menu, $theme); + $page = $this->menu->menuInfoText->lastPage; + $cancel = getConfigTextPosition($frame, 'Cancel'); + $footer ??= $cancel; + expect($cancel)->toEqual($footer)->and($page->rows)->toBe(2); + foreach ($page->lines as $line) { + $position = getConfigTextPosition($frame, $line); + expect($position['y'] + $position['height'])->toBeLessThanOrEqual($cancel['y']); + if ($line !== $this->menu->selection->getActiveSetting()->description) { + expect($position['color'])->toBe($theme->colors['decrease']); + } + $seen .= $line; + } + $this->menu->menuInfoText->advance(); + } while ($page->nextOffset !== 0); + expect($seen)->toBe($this->menu->selection->getActiveSetting()->description . 'Could not save settings: ' . $failure); + } + expect($this->config->get('audio.master_volume'))->toBe(85)->and($this->menu->hasStatusError())->toBeTrue(); +}); + +it('wraps long live labels values and descriptions without cropping any selected record', function () { + $setting = new GameSetting('music', str_repeat('Long setting label ', 4), str_repeat('Full description ', 12), + [str_repeat('Long current choice ', 4) => false, 'Other' => true]); + $this->menu->selection->setSettings([$setting]); + $frame = ConfigMenuPresentation::compose($this->menu, new MenuPresentationCatalog($this->root, configPresentationTheme())); + $text = configPresentationText($frame); + expect($text['config-setting-0-text'])->toBe($setting->label) + ->and($text['config-value-0'])->toBe(array_key_first($setting->choices)) + ->and($text['config-description'])->toBe($setting->description); + $layers = array_column($frame->textLayers, null, 'id'); + expect($layers['config-setting-0-text']->clipRect->height)->toBeGreaterThan(40) + ->and($layers['config-value-0']->bounds->y + $layers['config-value-0']->bounds->height) + ->toBeLessThanOrEqual($layers['config-setting-0-text']->clipRect->y + $layers['config-setting-0-text']->clipRect->height); +}); + +it('loads the four optional surfaces and exact control roles using current replaceable image facts', function () { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme(true)); + $first = ConfigMenuPresentation::compose($this->menu, $theme, width: 960, height: 540); + $thumb = array_values(array_filter($first->images, fn($image) => $image->id === 'config-level-0-thumb-1-1'))[0]; + expect($thumb->destination->width / $thumb->destination->height)->toEqualWithDelta(71 / 39, 0.0001); + configPresentationPng($this->root . '/thumb.png', 13, 83); + $frame = ConfigMenuPresentation::compose($this->menu, $theme, width: 960, height: 540); + $thumb = array_values(array_filter($frame->images, fn($image) => $image->id === 'config-level-0-thumb-1-1'))[0]; + expect($thumb->destination->width / $thumb->destination->height)->toEqualWithDelta(13 / 83, 0.0001) + ->and($thumb->destination->height)->toBeLessThanOrEqual($theme->metrics->cellHeight); + foreach (['config-level-0-track', 'config-scroll-track', 'config-scroll-thumb'] as $id) { + expect(array_filter($frame->images, fn($image) => str_starts_with($image->id, $id)))->not->toBeEmpty(); + } + $sliced = configPresentationTheme(true); + $sliced['frames']['slider.thumb']['cuts'] = [2, 2, 2, 2]; + $frame = ConfigMenuPresentation::compose($this->menu, new MenuPresentationCatalog($this->root, $sliced)); + expect(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'config-level-0-thumb-')))->toHaveCount(9); + $data = configPresentationTheme(); + $data['frames']['slider.thumb'] = ['asset' => 'absent.png']; + expect(fn() => new MenuPresentationCatalog($this->root, $data))->toThrow(RuntimeException::class); +}); + +it('uses chevrons for controllable values without borrowing comparison or unknown art', function () { + $data = configPresentationTheme(); + $data['icons'] = ['unknown' => 'arrow.png', 'comparison.previous' => 'arrow.png', 'comparison.next' => 'arrow.png']; + $frame = ConfigMenuPresentation::compose($this->menu, new MenuPresentationCatalog($this->root, $data)); + expect($frame->images)->toBeEmpty() + ->and(configPresentationText($frame)['config-previous-0'])->toBe("\u{2039}") + ->and(configPresentationText($frame)['config-next-0'])->toBe("\u{203A}"); + $small = ConfigMenuPresentation::compose($this->menu, new MenuPresentationCatalog($this->root, $data), width: 960, height: 540); + expect(configPresentationText($small)['config-scroll-up'])->toBe("\u{2227}") + ->and(configPresentationText($small)['config-scroll-down'])->toBe("\u{2228}"); +}); + +it('keeps slider fill inside the track and centers the independent thumb at every limit', function (float $ratio, bool $art) { + $data = configPresentationTheme($art); + $data['metrics'] = [...($data['metrics'] ?? []), 'sliderTrackHeight' => 10, 'sliderFillHeight' => 2, 'sliderThumbSize' => 22]; + if ($art) { configPresentationPng($this->root . '/thumb.png', 24, 24); } + $theme = new MenuPresentationCatalog($this->root, $data); + $controls = new \Ichiloto\Engine\UI\Presentation\MenuControls($theme); + $box = new CanvasRectangle(50, 50, 240, 30); + $controls->renderLevel('level', $box, $ratio, $theme->colors['increase']); + $frame = $controls->finish(400, 200); + $text = array_column($frame->textLayers, null, 'id'); + $track = new CanvasRectangle(61, 60, 218, 10); + if ($art) { + $images = array_column($frame->images, null, 'id'); + $thumb = $images['level-thumb-1-1']->destination; + foreach (array_filter($frame->images, fn($image) => str_starts_with($image->id, 'level-track-')) as $image) { + expect($image->clipRect)->toEqual($track); + } + } else { + $thumb = $text['level-thumb']->clipRect; + expect($text['level-track']->clipRect)->toEqual($track); + } + expect($thumb)->toEqual(new CanvasRectangle(50 + 218 * $ratio, 54, 22, 22)); + if ($ratio === 0.0) { expect($text)->not->toHaveKey('level-fill'); } + else { + $fill = $text['level-fill']->clipRect; + expect($fill)->toEqual(new CanvasRectangle(61, 64, 218 * $ratio, 2)) + ->and($fill->x + $fill->width)->toEqualWithDelta($thumb->x + $thumb->width / 2, 0.0001); + $thumbLayer = $art ? $images['level-thumb-1-1']->layer : $text['level-thumb']->layer; + expect($thumbLayer)->toBeGreaterThan($text['level-fill']->layer); + } + $frame->toArray(); +})->with([0.0, 0.5, 1.0])->with([false, true]); + +it('rejects invalid slider geometry without changing settings', function () { + foreach ([['sliderFillHeight' => 0], ['sliderTrackHeight' => 1], ['sliderThumbSize' => 7], ['sliderThumbSize' => 65]] as $metrics) { + expect(fn() => new MenuPresentationCatalog($this->root, [...configPresentationTheme(), 'metrics' => $metrics])) + ->toThrow(InvalidArgumentException::class); + } + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + foreach ([-0.1, 1.1, NAN, INF] as $ratio) { + $controls = new \Ichiloto\Engine\UI\Presentation\MenuControls($theme); + expect(fn() => $controls->renderLevel('level', new CanvasRectangle(0, 0, 100, 24), $ratio, $theme->colors['accent'])) + ->toThrow(InvalidArgumentException::class); + } + expect($this->config->writes)->toBe(0); +}); + +it('shows remapped optional hints without changing current actions or adding a button cursor', function () { + $data = configPresentationTheme(); + $data['showInputHints'] = true; + $theme = new MenuPresentationCatalog($this->root, $data); + InputManager::setBinding('cancel', [KeyCode::Q]); + $frame = ConfigMenuPresentation::compose($this->menu, $theme); + expect(implode(' ', configPresentationText($frame)))->toContain('Q', 'Cancel', 'Enter', 'Next'); + expect(array_filter([...$frame->textLayers, ...$frame->images], fn($piece) => str_contains($piece->id, 'cancel-cursor')))->toBeEmpty(); + expect($this->config->writes)->toBe(0); +}); + +it('handles empty and extended catalogs without fake setting records', function () { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + $this->menu->selection->setSettings([]); + $text = configPresentationText(ConfigMenuPresentation::compose($this->menu, $theme)); + expect($text['config-settings-range'])->toBe('0 / 0')->and($text['config-description'])->toBe('No settings available.') + ->and(array_filter(array_keys($text), fn($id) => str_starts_with($id, 'config-setting-')))->toBeEmpty(); + $entries = []; + for ($i = 0; $i < 80; $i++) { $entries[] = new GameSetting('music', 'Authored ' . $i, 'Live ' . $i, ['Off' => false, 'On' => true]); } + $this->menu->selection->setSettings($entries); + for ($i = 0; $i < 79; $i++) { $this->menu->selection->selectNext(); } + $text = configPresentationText(ConfigMenuPresentation::compose($this->menu, $theme)); + expect($text['config-setting-79-text'])->toBe('Authored 79')->and($text['config-description'])->toBe('Live 79') + ->and($text['config-settings-range'])->toEndWith('80 / 80'); +}); + +it('keeps default range and rows output unchanged while allowing a caller owned range rectangle', function () { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + $viewport = new CanvasRectangle(20, 80, 300, 104); + $header = new CanvasRectangle(320, 20, 300, 24); + $default = new MenuCanvas($theme); + $explicit = new MenuCanvas($theme); + $custom = new MenuCanvas($theme); + expect($default->visibleRange('test', [40, 40, 40, 40], $viewport, 2))->toBe([1, 2]) + ->and($explicit->visibleRange('test', [40, 40, 40, 40], $viewport, 2, null))->toBe([1, 2]) + ->and($custom->visibleRange('test', [40, 40, 40, 40], $viewport, 2, $header))->toBe([1, 2]) + ->and($default->finish()->toArray())->toBe($explicit->finish()->toArray()); + $layer = array_column($custom->finish()->textLayers, null, 'id')['test-range']; + expect($layer->clipRect)->toBe($header)->and($layer->runs[0]->text)->toBe('2-3 / 4') + ->and($layer->runs[0]->column)->toBe(23); + $rows = new MenuCanvas($theme); + $rows->rows('test', array_map(fn($i) => new MenuRow((string)$i, 'Entry ' . $i), range(0, 3)), new MenuRowLayout($viewport), 2); + $range = array_column($rows->finish()->textLayers, null, 'id')['test-range']; + expect($range)->toEqual(array_column($default->finish()->textLayers, null, 'id')['test-range']) + ->and($range->clipRect)->toEqual(new CanvasRectangle(20, 160, 300, 24)); +}); + +it('serializes nondivisible and prime canvas fills with exact disjoint clipped coverage', function (int $width, int $height) { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + $view = new MenuCanvas($theme, $width, $height); + $panel = new CanvasRectangle(23.5, 19.5, $width - 33, $height - 25); + $view->surface('fractional-panel', $panel, 'panel', 9); + $frame = $view->finish(); + $wire = json_decode(json_encode($frame->toArray(), JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + expect($wire['width'])->toBe($width)->and($wire['height'])->toBe($height) + ->and(count($wire['textLayers']))->toBeLessThanOrEqual(8); + foreach ($frame->textLayers as $layer) { + $layer->bounds->assertWithin($width, $height); + $layer->paintBounds->assertWithin($width, $height); + } + foreach (['menu-background' => new CanvasRectangle(0, 0, $width, $height), 'fractional-panel' => $panel] as $id => $expected) { + $pieces = array_values(array_filter($frame->textLayers, fn($layer) => str_starts_with($layer->id, $id))); + expect(count($pieces))->toBeLessThanOrEqual(4) + ->and(array_sum(array_map(fn($piece) => $piece->clipRect->width * $piece->clipRect->height, $pieces))) + ->toBe($expected->width * $expected->height); + foreach ($pieces as $i => $piece) { + $a = $piece->clipRect; + expect($a->x)->toBeGreaterThanOrEqual($expected->x)->and($a->y)->toBeGreaterThanOrEqual($expected->y) + ->and($a->x + $a->width)->toBeLessThanOrEqual($expected->x + $expected->width) + ->and($a->y + $a->height)->toBeLessThanOrEqual($expected->y + $expected->height); + foreach (array_slice($pieces, $i + 1) as $other) { + $b = $other->clipRect; + expect(max(0, min($a->x + $a->width, $b->x + $b->width) - max($a->x, $b->x)) + * max(0, min($a->y + $a->height, $b->y + $b->height) - max($a->y, $b->y)))->toEqual(0.0); + } + } + foreach ([-0.001, 0.0, 0.25, 0.5, 0.75, 0.99999, 1.001] as $fx) { + foreach ([-0.001, 0.0, 0.25, 0.5, 0.75, 0.99999, 1.001] as $fy) { + $x = $expected->x + $expected->width * $fx; + $y = $expected->y + $expected->height * $fy; + $hits = 0; + foreach ($pieces as $layer) { + $clip = $layer->clipRect; + if ($x < $clip->x || $x >= $clip->x + $clip->width || $y < $clip->y || $y >= $clip->y + $clip->height) { continue; } + $column = (int)floor(($x - $layer->x) / $layer->grid->cellWidth); + $row = (int)floor(($y - $layer->y) / $layer->grid->cellHeight); + expect($layer->runs[$row]->text[$column])->toBe(' ') + ->and($layer->runs[$row]->background)->toBe($theme->colors[$id === 'menu-background' ? 'background' : 'panel']); + $hits++; + } + expect($hits)->toBe($fx >= 0 && $fx < 1 && $fy >= 0 && $fy < 1 ? 1 : 0); + } + } + } + $overlay = MenuCanvas::overlay(new PresentationCanvas($width, $height), $frame, $theme); + expect(array_filter($overlay->textLayers, fn($layer) => str_starts_with($layer->id, 'menu-background')))->toBeEmpty(); +})->with([[736, 414], [503, 307]]); + +it('preserves the valid standard canvas fill wire exactly', function () { + $theme = new MenuPresentationCatalog($this->root, configPresentationTheme()); + $frame = new MenuCanvas($theme)->finish(); + $expected = new \Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasTextLayer('menu-background', 0, 0, 0, + new \Ichiloto\Engine\Rendering\Transport\RendererGridConfig(6, 3, 225, 240), + array_map(fn($row) => new \Ichiloto\Engine\Rendering\Presentation\PresentationTextRun($row, 0, ' ', + background: $theme->colors['background']), range(0, 2)), new CanvasRectangle(0, 0, 1350, 720)); + expect($frame->toArray())->toBe(new PresentationCanvas(1350, 720, textLayers: [$expected])->toArray()); +}); diff --git a/tests/Unit/ConsoleLiveLayerRemovalTest.php b/tests/Unit/ConsoleLiveLayerRemovalTest.php new file mode 100644 index 00000000..39a370ac --- /dev/null +++ b/tests/Unit/ConsoleLiveLayerRemovalTest.php @@ -0,0 +1,116 @@ +before = new ReflectionClass(Console::class)->getStaticProperties(); + foreach (['overlays' => [], 'terminalHandedBack' => false, 'terminalOutputEnabled' => true, + 'usingAlternateScreen' => false, 'terminalOutputStream' => null, 'output' => null, + 'frameDepth' => 0, 'frameRows' => [], 'isRecomposing' => false] as $key => $value) { + new ReflectionProperty(Console::class, $key)->setValue(null, $value); + } + ob_start(); + Console::syncDimensions(40, 8); + Console::clear(); +}); + +afterEach(function () { + ob_end_clean(); + foreach ($this->before as $key => $value) { new ReflectionProperty(Console::class, $key)->setValue(null, $value); } +}); + +it('removes live ownership in terminal and native modes retaining styled base other owners and overlays', function (bool $tracking, bool $output) { + Console::setLayerTracking($tracking); + Console::setTerminalOutputEnabled($output); + Console::write("\e[32mbase\e[0m", 0, 0); + $base = Console::presentationSnapshot(); + Console::withLayer('config', fn() => Console::write('CONFIG', 0, 0), 1000); + Console::withLayer('other', fn() => Console::write('!', 3, 0), 3000); + Console::replaceOverlay('notice', ['TOP'], 0, 0, 2000); + ob_clean(); + Console::removeLayer('config'); + expect(Console::snapshot()->rows[0])->toStartWith('TOP!'); + expect(array_column(Console::presentationSnapshot()->textLayers, 'id'))->not->toContain('config')->toContain('other', 'notice'); + Console::removeLayer('other'); + Console::removeOverlay('notice'); + expect(Console::presentationSnapshot())->toEqual($base); + ob_clean(); + Console::removeLayer('config'); + expect(ob_get_contents())->toBe(''); +})->with([false, true])->with([false, true]); + +it('restores complete wide underlays and keeps newer world writes when ownership is released', function () { + Console::write("\e[34m界\e[0m!", 0, 0); + $base = Console::getBuffer(); + Console::withLayer('config', fn() => Console::write('x', 1, 0), 1000); + Console::removeLayer('config'); + expect(Console::getBuffer())->toBe($base); + Console::withLayer('config', fn() => Console::write('x', 0, 0), 1000); + Console::write('N', 1, 0); + Console::removeLayer('config'); + expect(Console::snapshot()->rows[0])->toStartWith(' N!'); + expect(liveLayerRows())->toBe(Console::snapshot()->rows); +}); + +function liveLayerRows(): array +{ + $snapshot = Console::presentationSnapshot(); + $rows = array_fill(0, $snapshot->height, array_fill(0, $snapshot->width, ' ')); + foreach ($snapshot->textLayers as $layer) { + foreach ($layer->runs as $run) { + foreach (mb_str_split($run->text) as $offset => $char) { $rows[$run->row][$run->column + $offset] = $char; } + } + } + return array_map(fn($row) => implode('', $row), $rows); +} + +it('retains a wide underlay until the last overlapping live owner is removed', function () { + Console::write('界!', 0, 0); + Console::withLayer('first', fn() => Console::write('x', 0, 0), 1000); + Console::withLayer('second', fn() => Console::write('y', 1, 0), 2000); + Console::removeLayer('first'); + expect(Console::snapshot()->rows[0])->toStartWith(' y!')->and(liveLayerRows())->toBe(Console::snapshot()->rows); + Console::removeLayer('second'); + expect(Console::snapshot()->rows[0])->toStartWith('界 !')->and(liveLayerRows())->toBe(Console::snapshot()->rows); +}); + +it('disposes without output including after handback and inside another frame', function (bool $handedBack) { + Console::write('base', 0, 0); + Console::withLayer('config', fn() => Console::write('over', 0, 0), 1000); + new ReflectionProperty(Console::class, 'terminalHandedBack')->setValue(null, $handedBack); + ob_clean(); + Console::removeLayer('config', repaint: false); + expect(ob_get_contents())->toBe('')->and(Console::snapshot()->rows[0])->toStartWith('base'); + new ReflectionProperty(Console::class, 'terminalHandedBack')->setValue(null, false); + Console::beginFrame(); + Console::write('new', 5, 0); + Console::withLayer('config', fn() => Console::write('over', 0, 0), 1000); + Console::removeLayer('config'); + expect(Console::isComposing())->toBeTrue(); + Console::endFrame(); + expect(Console::snapshot()->rows[0])->toStartWith('base new'); +})->with([false, true]); + +it('restores state on emission failure and respects outer recomposition rollback', function () { + Console::write('base', 0, 0); + Console::withLayer('config', fn() => Console::write('over', 0, 0), 1000); + $before = Console::presentationSnapshot(); + $output = new class extends ConsoleOutput { + public function __construct() {} + public function getStream() { throw new RuntimeException('failed sink'); } + }; + new ReflectionProperty(Console::class, 'output')->setValue(null, $output); + expect(fn() => Console::removeLayer('config'))->toThrow(RuntimeException::class, 'failed sink'); + expect(Console::presentationSnapshot())->toEqual($before)->and(Console::isComposing())->toBeFalse(); + new ReflectionProperty(Console::class, 'output')->setValue(null, null); + expect(fn() => Console::recomposeFrame(function (): void { + Console::withLayer('temporary', fn() => Console::write('temp', 2, 2)); + Console::removeLayer('temporary'); + throw new RuntimeException('failed compose'); + }))->toThrow(RuntimeException::class, 'failed compose'); + expect(Console::presentationSnapshot())->toEqual($before); + Console::recomposeFrame(fn() => Console::write('new scene', 0, 0)); + Console::removeLayer('config'); + expect(Console::snapshot()->rows[0])->toStartWith('new scene'); +}); diff --git a/tests/Unit/ControlsMenuPresentationTest.php b/tests/Unit/ControlsMenuPresentationTest.php new file mode 100644 index 00000000..265feb23 --- /dev/null +++ b/tests/Unit/ControlsMenuPresentationTest.php @@ -0,0 +1,271 @@ +options['initial'] ?? []; } + protected function getFilename(): string { return 'input.php'; } + public function persist(): void + { + if ($this->fail) { throw new RuntimeException('Fixture write failure.'); } + $this->written[] = $this->all(); + } +} + +class ControlsPresentationOwner extends ControlsMenuState +{ + public function boot(): void + { + $this->bindings = new InputBindings(); + $this->reloadRows(); + $this->initializeUI(); + $this->refreshUI(); + } + public function select(int $index): void { $this->activeIndex = $index; } + public function terminalRows(): array { return $this->listPanel->getContent(); } +} + +function controlsPresentationText(PresentationCanvas $canvas): string +{ + return implode("\n", array_map(fn($layer) => implode('', array_column( + array_filter($layer->runs, fn($run) => $run->foreground !== null), 'text')), $canvas->textLayers)); +} + +function controlsPresentationKey(ControlsMenuState $state, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $state->execute(); +} + +beforeEach(function () { + $this->saved = []; + foreach ([InputManager::class, ActionHints::class, Console::class, ConfigStore::class, Debug::class] as $class) { + $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-controls-' . bin2hex(random_bytes(5)); + mkdir($this->root); + $chunk = fn($type, $bytes) => pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($this->root . '/glyph.png', "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', 3, 5, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAA\xCC\xEE\xFF", 3), 5))) . $chunk('IEND', '')); + Debug::configure(['log_directory' => $this->root . '/logs']); + ActionHints::useProvider(null); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + $this->bindings = ['confirm' => ['keys' => [KeyCode::SPACE, KeyCode::ENTER], 'description' => 'Confirm a selection.'], + 'cancel' => ['keys' => [KeyCode::C, KeyCode::c, KeyCode::ESCAPE], 'description' => 'Cancel the current selection.'], + 'back' => ['keys' => [KeyCode::ESCAPE], 'description' => 'Return.'], + 'up' => ['keys' => [KeyCode::UP], 'description' => 'Navigate up.'], + 'down' => ['keys' => [KeyCode::DOWN], 'description' => 'Navigate down.']]; + for ($i = 0; $i < 10; $i++) { $this->bindings['command_' . $i] = ['keys' => [], 'description' => 'Complete command description ' . $i]; } + InputManager::setBindings($this->bindings); + $this->effectiveBindings = InputManager::getBindings(); + new ReflectionProperty(InputManager::class, 'defaultConfig')->setValue(null, $this->effectiveBindings); + $this->config = new ControlsPresentationConfig(['initial' => $this->bindings]); + ConfigStore::put(InputConfig::class, $this->config); + $this->owner = new ControlsPresentationOwner(new SceneStateContext(makeBareScene(GameScene::class))); + $this->owner->boot(); +}); + +afterEach(function () { + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +it('keeps every binding reachable in both themed sizes without helper strips', function (bool $alternate, int $width, int $height) { + $data = ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]; + if ($alternate) { + $data['colors'] = ['text' => [30, 20, 10], 'panel' => [240, 230, 220], 'selected' => [195, 165, 120]]; + $data['metrics'] = ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 32, 'panelPadding' => 16, 'sectionGap' => 8]; + } + $theme = new MenuPresentationCatalog($this->root, $data); + $before = InputManager::getBindings(); + foreach ($this->owner->getPresentationContent()->rows as $index => $row) { + $this->owner->select($index); + $frame = ControlsMenuPresentation::compose($this->owner->getPresentationContent(), $theme, width: $width, height: $height); + $text = controlsPresentationText($frame); + expect($text)->toContain(ucfirst(str_replace('_', ' ', $row['action'])), $row['description'], 'Keyboard bindings: ' . $row['keys']); + expect(count($frame->textLayers))->toBeLessThanOrEqual(64); + foreach ($frame->textLayers as $layer) { $layer->bounds->assertWithin($width, $height); } + } + expect(InputManager::getBindings())->toBe($before)->and($this->config->written)->toBeEmpty() + ->and(array_column($this->owner->getPresentationContent()->rows, 'action'))->not->toContain('back'); +})->with([false, true])->with([[1350,720], [1100,720], [960,540]]); + +it('uses provider identity live while preserving all keyboard aliases', function () { + $this->owner->select(1); + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, + 'icons' => ['input.gamepad.xbox.face_east' => 'glyph.png']]); + $keyboard = $this->owner->getPresentationContent(); + expect($keyboard->rows[1]['control']->label)->toBe('Escape')->and($keyboard->rows[1]['keys'])->toBe('C, c, ESCAPE'); + ActionHints::useProvider(new class implements ActionHintProvider { + public function controlForAction(string $action): ?ControlHint + { return $action === 'cancel' ? new ControlHint('gamepad.xbox', 'face_east', 'B') : null; } + }); + $frame = ControlsMenuPresentation::compose($this->owner->getPresentationContent(), $theme); + expect(array_column($frame->images, 'asset'))->toContain('glyph.png') + ->and(controlsPresentationText($frame))->toContain('Keyboard bindings: C, c, ESCAPE', 'Unbound'); + ActionHints::useProvider(null); + expect($this->owner->getPresentationContent()->rows[1]['control']->family)->toBe('keyboard') + ->and($keyboard->rows[1]['control']->label)->toBe('Escape'); +}); + +it('keeps readable controls without corresponding artwork and rejects insufficient glyph space', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, 'icons' => ['unknown' => 'glyph.png']]); + $control = new ControlHint('device.alternate', 'primary', 'Primary'); + $frame = ControlGlyphPresentation::compose(300, 100, 'control', $control, $theme, new CanvasRectangle(0, 0, 200, 40)); + expect($frame->images)->toBeEmpty()->and(controlsPresentationText($frame))->toBe(' Primary ') + ->and($frame->textLayers[0]->runs[0]->background)->not->toBeNull(); + expect(fn() => ControlGlyphPresentation::compose(300, 100, 'control', $control, $theme, new CanvasRectangle(0, 0, 10, 40))) + ->toThrow(RuntimeException::class); +}); + +it('consumes the opening and cancel edges without changing a binding', function () { + controlsPresentationKey($this->owner, KeyCode::ENTER); + expect($this->owner->getPresentationContent()->listening)->toBeTrue()->and(InputManager::getPressedKeyCode())->toBeNull(); + $this->owner->execute(); + expect($this->owner->getPresentationContent()->listening)->toBeTrue(); + controlsPresentationKey($this->owner, KeyCode::ESCAPE); + expect($this->owner->getPresentationContent()->listening)->toBeFalse() + ->and($this->owner->getPresentationContent()->status)->toBe('Rebinding cancelled.') + ->and(InputManager::getPressedKeyCode())->toBeNull()->and(InputManager::getBindings())->toBe($this->effectiveBindings) + ->and($this->config->written)->toBeEmpty(); +}); + +it('does not also navigate when a semantic confirmation shares a directional key', function () { + InputManager::setBinding('confirm', [KeyCode::DOWN]); + controlsPresentationKey($this->owner, KeyCode::DOWN); + expect($this->owner->getPresentationContent()->index)->toBe(0) + ->and($this->owner->getPresentationContent()->listening)->toBeTrue() + ->and(InputManager::getPressedKeyCode())->toBeNull(); +}); + +it('applies and persists a binding once and refreshes the native and terminal lookup', function () { + controlsPresentationKey($this->owner, KeyCode::ENTER); + controlsPresentationKey($this->owner, KeyCode::K); + $this->owner->execute(); + expect(InputManager::getBindings()['confirm']['keys'])->toBe([KeyCode::K]) + ->and($this->owner->getPresentationContent()->rows[0]['control']->label)->toBe('K') + ->and($this->owner->getPresentationContent()->status)->toBe('Confirm is now bound to K.') + ->and($this->config->written)->toHaveCount(1)->and(implode('', $this->owner->terminalRows()))->toContain('K'); +}); + +it('reports a session-only rebind honestly when persistence fails', function () { + $this->config->fail = true; + controlsPresentationKey($this->owner, KeyCode::ENTER); + controlsPresentationKey($this->owner, KeyCode::K); + expect(InputManager::getBindings()['confirm']['keys'])->toBe([KeyCode::K]) + ->and($this->owner->getPresentationContent()->status)->toContain('for this session; the file could not be written.') + ->and($this->owner->getPresentationContent()->listening)->toBeFalse(); +}); + +it('restores defaults using the retained shortcut and consumes the edge', function (bool $fail) { + $this->config->fail = $fail; + InputManager::setBinding('confirm', [KeyCode::K]); + InputManager::setBinding('info', [KeyCode::F2]); + controlsPresentationKey($this->owner, KeyCode::R); + expect(InputManager::getBindings())->toBe($this->effectiveBindings)->and(InputManager::getPressedKeyCode())->toBeNull() + ->and($this->owner->getPresentationContent()->status)->toBe($fail + ? 'Controls restored for this session; the file could not be written.' : 'Controls restored to the defaults.'); +})->with([false, true]); + +it('discovers fallback Info without inventing a confirmation binding for an empty project lookup', function () { + InputManager::setBindings([]); + $this->owner->boot(); + controlsPresentationKey($this->owner, KeyCode::ENTER); + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1']); + $frame = ControlsMenuPresentation::compose($this->owner->getPresentationContent(), $theme); + expect(controlsPresentationText($frame))->toContain('Info', 'Read the next Info page; wrap to the first.', 'Keyboard bindings: i, I') + ->and($this->owner->getPresentationContent()->listening)->toBeFalse() + ->and(array_column($this->owner->getPresentationContent()->rows, 'action'))->toBe(['info']); +}); + +it('scrolls the full owner list in terminal and native without losing the active action', function () { + $bindings = $this->bindings; + for ($i = 10; $i < 50; $i++) { $bindings['command_' . $i] = ['keys' => [], 'description' => 'Action ' . $i]; } + InputManager::setBindings($bindings); + $this->owner->boot(); + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]); + for ($i = 0; $i < 53; $i++) { controlsPresentationKey($this->owner, KeyCode::DOWN); } + expect($this->owner->getPresentationContent()->index)->toBe(53) + ->and(implode('', $this->owner->terminalRows()))->toContain('Command 49'); + $frame = ControlsMenuPresentation::compose($this->owner->getPresentationContent(), $theme); + expect(controlsPresentationText($frame))->toContain('Command 49', '/ 55'); + controlsPresentationKey($this->owner, KeyCode::DOWN); + expect($this->owner->getPresentationContent()->index)->toBe(54) + ->and($this->owner->getPresentationContent()->rows[54]['action'])->toBe('info'); + controlsPresentationKey($this->owner, KeyCode::DOWN); + expect($this->owner->getPresentationContent()->index)->toBe(0); +}); + +it('exposes the missing Info action in both Controls views and rebinds it through the current owner', function (bool $conflict) { + if ($conflict) { + $bindings = $this->bindings; + $bindings['command_0']['keys'] = [KeyCode::i, KeyCode::I]; + InputManager::setBindings($bindings); + $this->owner->boot(); + } + $content = $this->owner->getPresentationContent(); + $index = array_search('info', array_column($content->rows, 'action'), true); + expect($index)->toBeInt(); + $this->owner->select($index); + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]); + $frame = ControlsMenuPresentation::compose($this->owner->getPresentationContent(), $theme); + expect(controlsPresentationText($frame))->toContain('Info', 'Read the next Info page; wrap to the first.', + 'Keyboard bindings: ' . ($conflict ? 'Unbound' : 'i, I')) + ->and(implode('', $this->owner->terminalRows()))->toContain('Info', $conflict ? 'Unbound' : 'i, I') + ->and($this->config->written)->toBeEmpty()->and($this->config->all())->not->toHaveKey('info'); + controlsPresentationKey($this->owner, KeyCode::ENTER); + controlsPresentationKey($this->owner, KeyCode::F2); + expect($this->owner->getPresentationContent()->rows[$index]['keys'])->toBe('F2') + ->and(implode('', $this->owner->terminalRows()))->toContain('F2') + ->and($this->config->written)->toHaveCount(1) + ->and(InputManager::getBindings()['command_0']['keys'])->toBe($conflict ? [KeyCode::i, KeyCode::I] : []); +})->with([false, true]); + +it('keeps the list cursor decorative and respects reduced motion', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'cursor' => 'glyph.png']); + $snapshot = $this->owner->getPresentationContent(); + $a = ControlsMenuPresentation::compose($snapshot, $theme, 0); + $b = ControlsMenuPresentation::compose($snapshot, $theme, 0.6); + $cursor = fn($frame) => array_values(array_filter($frame->images, fn($image) => str_contains($image->id, '-cursor-')))[0]; + expect($cursor($a)->destination->x)->not->toBe($cursor($b)->destination->x) + ->and($this->owner->getPresentationContent())->toEqual($snapshot)->and($this->config->written)->toBeEmpty(); + $settings = new SceneAudioConfigStub(['accessibility' => ['reducedMotion' => true]]); + ConfigStore::put(ProjectConfig::class, $settings); + $c = ControlsMenuPresentation::compose($snapshot, $theme, 0.6); + expect($cursor($c)->destination)->toEqual($cursor($a)->destination); +}); diff --git a/tests/Unit/CreditsPresentationTest.php b/tests/Unit/CreditsPresentationTest.php new file mode 100644 index 00000000..27e3689b --- /dev/null +++ b/tests/Unit/CreditsPresentationTest.php @@ -0,0 +1,145 @@ +fitContentToWidth(); + $this->positionForOpen(); + $this->show(); + $this->render(); + } + public function advancePage(): void { $this->submit(); } + public function dismiss(): void { $this->cancel(); } + protected function playInteractionSound(\Ichiloto\Engine\Audio\Enumerations\SystemSound $sound): void {} +} + +beforeEach(function () { + $this->statics = []; + foreach ([Console::class, ConfigStore::class, EventManager::class] as $class) { + $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + Console::setTerminalOutputEnabled(false); + foreach (['width' => 80, 'height' => 24, 'buffer' => []] as $key => $value) { + new ReflectionProperty(Console::class, $key)->setValue(null, $value); + } + new ReflectionProperty(EventManager::class, 'instance')->setValue(null, null); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 80, 'height' => 24])); +}); + +afterEach(function () { + foreach ($this->statics as $class => $properties) { + foreach ($properties as $key => $value) { new ReflectionProperty($class, $key)->setValue(null, $value); } + } +}); + +it('preserves authored order and wraps every credit into bounded pages', function () { + $content = new CreditsContent([ + ['title' => 'Design', 'lines' => ['Andrew Masiye', 'Second contributor']], + ['title' => 'Thanks', 'lines' => ['Players everywhere']], + false, ['title' => 'Empty', 'lines' => [false]], + ]); + expect(array_column($content->getLines(40), 'text'))->toBe([ + 'Design', 'Andrew Masiye', 'Second contributor', '', 'Thanks', 'Players everywhere', + ]); + $pages = $content->getPages(10, 3); + foreach ($pages as $page) { + expect(count(explode("\n", $page)))->toBeLessThanOrEqual(3); + foreach (explode("\n", $page) as $line) { expect(TerminalText::displayWidth($line))->toBeLessThanOrEqual(10); } + } + expect(implode('', array_map(static fn(string $page) => str_replace(["\n", ' '], '', $page), $pages))) + ->toBe('DesignAndrewMasiyeSecondcontributorThanksPlayerseverywhere'); +}); + +it('advances by update time without catching up pauses or long frame stalls', function () { + $playback = new CreditsPlayback(100, 20); + $playback->advance(0); + $playback->advance(0.05); + expect($playback->offset)->toBe(1.0); + $playback->advance(30, true); + $playback->advance(90); + expect($playback->offset)->toBe(1.0); + $playback->advance(190); + expect($playback->offset)->toBe(3.0); + for ($i = 1; $i <= 100; $i++) { $playback->advance(190 + $i * 0.1); } + expect($playback->finished)->toBeTrue()->and($playback->offset)->toBe(100.0); + expect(fn() => $playback->advance(0))->toThrow(InvalidArgumentException::class); +}); + +it('clips a complete credit roll independently of rendering and theme', function (array $data) { + $theme = new MenuPresentationCatalog(sys_get_temp_dir(), ['schema' => 'ichiloto.menu/1', ...$data]); + $content = new CreditsContent([['title' => 'Made by', 'lines' => array_map(static fn(int $i) => 'Contributor ' . $i, range(1, 80))]]); + $playback = CreditsMenuPresentation::createPlayback($content, $theme); + $viewport = CreditsMenuPresentation::getViewport($theme); + $playback->advance(0); + $seen = []; + for ($tick = 1; !$playback->finished; $tick++) { + $playback->advance($tick / 10); + if ($tick % 10 !== 0 && !$playback->finished) { continue; } + $frame = CreditsMenuPresentation::compose($content, $playback, $theme); + expect(CreditsMenuPresentation::compose($content, $playback, $theme)->toArray())->toBe($frame->toArray()); + expect(count($frame->textLayers))->toBeLessThanOrEqual(64); + foreach ($frame->textLayers as $layer) { + expect($layer->id)->not->toContain('cursor'); + if ($layer->id !== 'credits-roll') { continue; } + expect($layer->clipRect)->toEqual($viewport); + foreach ($layer->runs as $run) { + $seen[$run->text] = true; + $center = $layer->x + ($run->column + mb_strlen($run->text) / 2) * $layer->grid->cellWidth; + expect(abs($center - 675))->toBeLessThanOrEqual($theme->metrics->cellWidth / 2); + } + } + } + foreach (['Made by', ...$content->sections[0]['lines']] as $line) { expect($seen[$line] ?? false)->toBeTrue(); } +})->with([ + [[]], + [['colors' => ['text' => [30, 20, 10], 'panel' => [240, 230, 200]], 'metrics' => ['cellWidth' => 8, 'cellHeight' => 16, 'rowHeight' => 32]]], +]); + +it('centers the terminal alert and its prose and dismisses without visiting remaining pages', function () { + $game = new CreditsPresentationGame(); + $modal = new CreditsAlertProbe($game, new CreditsContent([['title' => 'Design', 'lines' => ['Andrew Masiye', ...array_fill(0, 25, 'Contributor')]]])); + $modal->prepareAndRender(); + $box = $modal->getPresentationBounds(); + expect($box->getX())->toBe(intdiv(80 - $box->getWidth(), 2)) + ->and($box->getY())->toBe(intdiv(24 - $box->getHeight(), 2)) + ->and($box->getHeight())->toBeLessThanOrEqual(24) + ->and($modal->activeButton)->toBe('Next'); + $rows = array_map(TerminalText::stripAnsi(...), Console::getBuffer()); + expect(mb_strpos($rows[$box->getY() + 2], 'Andrew Masiye')) + ->toBe($box->getX() + 1 + intdiv($box->getWidth() - 2 - mb_strlen('Andrew Masiye'), 2)); + $modal->advancePage(); + $modal->render(); + expect($modal->isShowing())->toBeTrue()->and($modal->message)->not->toContain('Design'); + $modal->dismiss(); + expect($modal->isShowing())->toBeFalse(); +}); + +it('keeps a short credit alert to one centered confirmation', function () { + $game = new CreditsPresentationGame(); + $modal = new CreditsAlertProbe($game, new CreditsContent([['title' => 'Design', 'lines' => ['Andrew Masiye']]])); + $modal->prepareAndRender(); + expect($modal->activeButton)->toBe('OK')->and($modal->getModalPresentation()->singleConfirmation)->toBeTrue(); + $modal->advancePage(); + expect($modal->isShowing())->toBeFalse(); +}); diff --git a/tests/Unit/DialoguePresentationCatalogTest.php b/tests/Unit/DialoguePresentationCatalogTest.php new file mode 100644 index 00000000..f7232567 --- /dev/null +++ b/tests/Unit/DialoguePresentationCatalogTest.php @@ -0,0 +1,18 @@ +actors)->toBe([]) + ->and(DialoguePresentationCatalog::FILE)->toBe('Data/Presentation/dialogue.php'); +}); + +it('retains supplied actors without allowing catalogue mutation', function () { + $actors = ['example-actor' => ['portrait' => 'Graphics/Portraits/Example.png']]; + $catalogue = new DialoguePresentationCatalog($actors); + + expect($catalogue->actors)->toBe($actors) + ->and(fn() => $catalogue->actors = [])->toThrow(Error::class); +}); diff --git a/tests/Unit/EquipmentOptimizationBootstrapTest.php b/tests/Unit/EquipmentOptimizationBootstrapTest.php new file mode 100644 index 00000000..d2365a3d --- /dev/null +++ b/tests/Unit/EquipmentOptimizationBootstrapTest.php @@ -0,0 +1,229 @@ +options = []; + } + + public function initializeProjectConfiguration(): void + { + new ReflectionMethod(Game::class, 'initializeConfigStore')->invoke($this); + } + + public function __destruct() + { + } +} + +function writeOptimizationBootstrapProject(string $root, string $name): string +{ + $project = $root . '/' . $name; + mkdir($project . '/assets/Data', 0777, true); + file_put_contents($project . '/ichiloto.json', '{"id":"optimization-bootstrap-fixture"}'); + foreach (['config.php', 'input.php', 'assets/Data/system.php', 'assets/Data/enemies.php'] as $path) { + file_put_contents($project . '/' . $path, 'setValue($character, new CharacterRole($character, $roleName)); + $party = new Party(); + $party->addMember($character); + $items = ConfigStore::get(ItemStore::class); + assert($items instanceof ItemStore); + $party->inventory->addItems(...$items->instantiate('equipment.physical'), ...$items->instantiate('equipment.arcane')); + $character->optimizeEquipment($party->inventory, $party); + + return $character; +} + +beforeEach(function () { + $this->originalDirectory = getcwd(); + $this->originalConfig = new ReflectionProperty(ConfigStore::class, 'store')->getValue(); + $this->originalElements = new ReflectionProperty(ElementRegistry::class, 'elements')->getValue(); + $this->originalPolicy = new ReflectionProperty(EquipmentOptimizationPolicyRegistry::class, 'policy')->getValue(); + $this->root = sys_get_temp_dir() . '/' . uniqid('ichiloto-optimization-bootstrap-', true); + $this->project = writeOptimizationBootstrapProject($this->root, 'first'); + $this->game = new EquipmentOptimizationBootstrapGame(); + chdir($this->project); +}); + +afterEach(function () { + chdir($this->originalDirectory); + new ReflectionProperty(ConfigStore::class, 'store')->setValue(null, $this->originalConfig); + new ReflectionProperty(ElementRegistry::class, 'elements')->setValue(null, $this->originalElements); + EquipmentOptimizationPolicyRegistry::configure($this->originalPolicy); + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->root); +}); + +it('loads the Editor policy file through Game bootstrap for normal character optimization', function () { + writeOptimizationBootstrapPolicy($this->project, [ + 'statWeights' => ['attack' => 1, 'magicAttack' => 1], + 'roleStatWeights' => [ + ' Striker ' => ['attack' => 3, 'magicAttack' => 0], + ' Mystic ' => ['attack' => 0, 'magicAttack' => 3], + ], + ]); + $this->game->initializeProjectConfiguration(); + + expect(EquipmentOptimizationPolicyRegistry::current())->toBeInstanceOf(DeclaredEquipmentOptimizationPolicy::class) + ->and(optimizeBootstrapCharacter('striker')->equipment[0]->equipment?->id)->toBe('equipment.physical') + ->and(optimizeBootstrapCharacter('MYSTIC')->equipment[0]->equipment?->id)->toBe('equipment.arcane'); +}); + +it('preserves all nine declaration fields and the existing weight precedence', function () { + writeOptimizationBootstrapPolicy($this->project, [ + 'statWeights' => ['attack' => 1, 'accuracy' => 2, 'critical' => -1], + 'roleStatWeights' => ['mystic' => ['attack' => 3]], + 'slotStatWeights' => ['weapon' => ['attack' => 4]], + 'roleSlotStatWeights' => [' MYSTIC ' => [' WEAPON ' => [' ATTACK ' => 5]]], + 'elementOutcomeWeights' => [' Offence:Fire ' => 7, 'defence:*:absorb' => 8], + 'specialPropertyWeights' => [' Counter ' => 9], + 'excludedDefinitionIds' => [' equipment.excluded '], + 'excludedAvailabilities' => [' STORY-ONLY '], + 'excludedAcquisitionPolicies' => [' Unique '], + ]); + $this->game->initializeProjectConfiguration(); + $character = optimizeBootstrapCharacter('mystic'); + $policy = EquipmentOptimizationPolicyRegistry::current(); + $weapon = new Weapon('Scored', '', '/', 10, + parameterChanges: new ParameterChanges(attack: 2), + elementAffinities: ['Ice' => -1.0], element: 'Fire', id: 'equipment.scored', + accuracyModifier: 3, criticalModifier: 2, specialProperty: ['type' => 'counter'], + ); + $score = $policy->score($character, $character->equipment[0], $weapon); + + expect($score?->value)->toBe(38) + ->and($score?->components)->toBe([ + 'attack' => 10, 'accuracy' => 6, 'critical' => -2, + 'element:offence:fire' => 7, 'element:defence:*:absorb' => 8, 'special:counter' => 9, + ]); + foreach ([ + new Weapon('Excluded ID', '', '/', 10, id: 'equipment.excluded'), + new Weapon('Excluded availability', '', '/', 10, availability: 'story-only'), + new Weapon('Excluded acquisition', '', '/', 10, acquisitionPolicy: 'unique'), + ] as $excluded) { + expect($policy->score($character, $character->equipment[0], $excluded))->toBeNull(); + } +}); + +it('resets stale policy when the next project omits the declaration', function () { + writeOptimizationBootstrapPolicy($this->project, ['statWeights' => ['magicAttack' => 3]]); + $this->game->initializeProjectConfiguration(); + expect(optimizeBootstrapCharacter('mystic')->equipment[0]->equipment?->id)->toBe('equipment.arcane'); + + chdir(writeOptimizationBootstrapProject($this->root, 'second')); + $this->game->initializeProjectConfiguration(); + + expect(EquipmentOptimizationPolicyRegistry::current())->toBeInstanceOf(LegacyEqualWeightEquipmentOptimizationPolicy::class) + ->and(optimizeBootstrapCharacter('mystic')->equipment[0]->equipment?->id)->toBe('equipment.physical'); +}); + +it('reloads an explicitly empty declaration as a zero-weight policy rather than legacy', function () { + writeOptimizationBootstrapPolicy($this->project, ['statWeights' => ['attack' => 3]]); + $this->game->initializeProjectConfiguration(); + expect(optimizeBootstrapCharacter('mystic')->equipment[0]->equipment?->id)->toBe('equipment.physical'); + + writeOptimizationBootstrapPolicy($this->project, []); + $this->game->initializeProjectConfiguration(); + $character = optimizeBootstrapCharacter('mystic'); + $weapon = $character->equipment[0]->equipment; + expect(EquipmentOptimizationPolicyRegistry::current())->toBeInstanceOf(DeclaredEquipmentOptimizationPolicy::class) + ->and($weapon?->id)->toBe('equipment.arcane'); + expect(EquipmentOptimizationPolicyRegistry::current()->score($character, $character->equipment[0], $weapon)?->value)->toBe(0); +}); + +it('fails bootstrap visibly on malformed declarations instead of retaining the prior policy', function (string $source, string $message) { + writeOptimizationBootstrapPolicy($this->project, ['statWeights' => ['attack' => 3]]); + $this->game->initializeProjectConfiguration(); + $filename = $this->project . '/assets/Data/equipment-optimization.php'; + file_put_contents($filename, $source); + + try { + $this->game->initializeProjectConfiguration(); + $this->fail('Malformed declared policy must reject bootstrap.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toContain($filename)->toContain($message) + ->and($exception->getPrevious())->toBeInstanceOf(Throwable::class); + } + expect(EquipmentOptimizationPolicyRegistry::current())->toBeInstanceOf(LegacyEqualWeightEquipmentOptimizationPolicy::class); +})->with([ + 'scalar declaration' => [' [' [' [' 1]];', 'Unknown equipment optimization field'], + 'unknown field' => [' []];', 'Unknown equipment optimization field: roleWeights'], + 'null field' => [' null];', 'statWeights must be an array'], + 'scalar field' => [' 1];', 'roleStatWeights must be an array'], + 'unknown stat' => [' ["damage" => 1]];', 'Unknown base equipment optimization key'], + 'string weight' => [' ["attack" => "3"]];', 'require integer values'], + 'float weight' => [' ["attack" => 3.0]];', 'require integer values'], + 'boolean weight' => [' ["attack" => true]];', 'require integer values'], + 'positional stat' => [' [3]];', 'names must be non-empty strings'], + 'empty role' => [' [" " => []]];', 'names must be non-empty strings'], + 'positional role' => [' [[]]];', 'names must be non-empty strings'], + 'scalar role weights' => [' ["mystic" => 1]];', 'weights must be arrays'], + 'scalar slot weights' => [' ["weapon" => 1]];', 'weights must be arrays'], + 'scalar role-slot map' => [' ["mystic" => 1]];', 'weights must be arrays'], + 'positional role-slot role' => [' [["weapon" => []]]];', 'names must be non-empty strings'], + 'positional role-slot slot' => [' ["mystic" => [[]]]];', 'names must be non-empty strings'], + 'scalar role-slot weights' => [' ["mystic" => ["weapon" => 1]]];', 'weights must be arrays'], + 'positional element outcome' => [' [3]];', 'names must be non-empty strings'], + 'empty special name' => [' [" " => 3]];', 'names must be non-empty strings'], + 'string special weight' => [' ["counter" => "3"]];', 'integer values'], + 'non-list exclusion' => [' ["id" => "equipment.example"]];', 'exclusions must be a list'], + 'numeric excluded ID' => [' [1]];', 'names must be non-empty strings'], + 'empty excluded availability' => [' [" "]];', 'names must be non-empty strings'], + 'array excluded acquisition' => [' [[]]];', 'names must be non-empty strings'], + 'declaration throws' => [' ['project . '/assets/Data/equipment-optimization.php'); + + expect(fn() => $this->game->initializeProjectConfiguration()) + ->toThrow(RuntimeException::class, 'must be a readable PHP file'); +}); diff --git a/tests/Unit/EventInterpreterContinuationTest.php b/tests/Unit/EventInterpreterContinuationTest.php index faf4687a..0b46d3b6 100644 --- a/tests/Unit/EventInterpreterContinuationTest.php +++ b/tests/Unit/EventInterpreterContinuationTest.php @@ -365,6 +365,7 @@ class EventTestGameScene extends GameScene public function __construct(public EventTestSceneManager $testSceneManager = new EventTestSceneManager()) { $this->sceneManager = $testSceneManager; + $this->sceneManager->currentScene = $this; $this->gameState = new GameState(); $this->hasDeferredAutoSave = false; $this->currentMapId = 'map-a'; @@ -795,7 +796,7 @@ function makeCinematicTriggerProject( expect($session?->status)->toBe(EventExecutionStatus::YIELDED); }); -it('centres title card copy while leaving narration copy left aligned', function () { +it('centres title cards while left aligning narration inside its top centred box', function () { [$scene] = makeEventRuntime(); $scene->installCinematicRuntime(); $console = new ReflectionClass(Console::class); @@ -805,6 +806,9 @@ function makeCinematicTriggerProject( ['width', 20], ['height', 10], ['buffer', []], + ['frameDepth', 0], + ['frameRows', []], + ['terminalHandedBack', false], ] as [$property, $value]) { $consoleProperty = $console->getProperty($property); $previousConsoleState[$property] = $consoleProperty->getValue(); @@ -819,7 +823,7 @@ function makeCinematicTriggerProject( $titleRows = array_map(TerminalText::stripAnsi(...), Console::getBuffer()); ob_start(); - $scene->cinematicPresentation?->showOverlay('narration', 'Dawn Route', 'SKY CARAVAN'); + $scene->cinematicPresentation?->showOverlay('narration', 'Dawn Route continues', 'SKY CARAVAN'); $scene->cinematicPresentation?->render(); ob_end_clean(); $narrationRows = array_map(TerminalText::stripAnsi(...), Console::getBuffer()); @@ -831,8 +835,9 @@ function makeCinematicTriggerProject( expect($titleRows[4] ?? '')->toBe('| SKY CARAVAN |') ->and($titleRows[5] ?? '')->toBe('| Dawn Route |') - ->and($narrationRows[5] ?? '')->toBe('| SKY CARAVAN |') - ->and($narrationRows[6] ?? '')->toBe('| Dawn Route |'); + ->and($narrationRows[1] ?? '')->toBe('| ' . str_pad('SKY CARAVAN', 16) . ' |') + ->and($narrationRows[2] ?? '')->toBe('| ' . str_pad('Dawn Route', 16) . ' |') + ->and($narrationRows[3] ?? '')->toBe('| ' . str_pad('continues', 16) . ' |'); }); it('resumes dialogue and choices on later ticks', function () { @@ -1406,6 +1411,8 @@ function makeCinematicTriggerProject( 'troop' => 'Technical Troop', 'resultVariable' => 'last_battle_result', 'defeatPolicy' => 'continue', + 'battleArena' => 'arena.training-yard', + 'firstStrike' => 'party', ], ['type' => 'set_switch', 'name' => 'after_battle', 'value' => true], ]); @@ -1415,6 +1422,8 @@ function makeCinematicTriggerProject( ->and($scene->testSceneManager->battleCount)->toBe(1) ->and($scene->testSceneManager->lastTroop?->name)->toBe('Technical Troop') ->and($scene->testSceneManager->lastBattleSettings['event_defeat_policy'])->toBe('continue') + ->and($scene->testSceneManager->lastBattleSettings['battleArena'])->toBe('arena.training-yard') + ->and($scene->testSceneManager->lastBattleSettings['firstStrike'])->toBe('party') ->and($scene->gameState->getSwitch('after_battle'))->toBeFalse(); $scene->resumeEventAfterBattle(new BattleResult('Victory', [])); @@ -1433,6 +1442,7 @@ function makeCinematicTriggerProject( 'troop' => 'Technical Troop', 'resultVariable' => 'scripted_defeat_result', 'defeatPolicy' => 'continue', + 'firstStrike' => null, ], ['type' => 'set_switch', 'name' => 'continued_after_defeat', 'value' => true], ]); @@ -1440,6 +1450,8 @@ function makeCinematicTriggerProject( $defeatInterpreter->update(0.016); expect($defeatSession?->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($defeatScene->testSceneManager->lastBattleSettings)->not->toHaveKey('battleArena') + ->and($defeatScene->testSceneManager->lastBattleSettings['firstStrike'])->toBe('normal') ->and($defeatScene->gameState->getVariable('scripted_defeat_result'))->toBe('defeat') ->and($defeatScene->gameState->getSwitch('continued_after_defeat'))->toBeTrue(); } finally { @@ -1448,6 +1460,47 @@ function makeCinematicTriggerProject( } }); +it('rejects an invalid scripted opening before launching a battle', function () { + [$scene, $interpreter] = makeEventRuntime(); + $session = $interpreter->run([['type' => 'start_battle', 'troop' => 'Technical Troop', 'firstStrike' => 'typo']]); + expect($session?->status)->toBe(EventExecutionStatus::FAILED) + ->and($session?->failureMessage)->toContain('firstStrike must be') + ->and($scene->testSceneManager->battleCount)->toBe(0); +}); + +it('forwards map encounter arenas and clears their binding when maps change', function () { + $root = sys_get_temp_dir() . '/encounter-arena-' . uniqid(); + mkdir($root . '/assets/Data', 0777, true); + file_put_contents($root . '/assets/Data/troops.php', ' "Technical Troop", "enemies" => []]];'); + $previousDirectory = getcwd(); + chdir($root); + ConfigStore::put(EnemyStore::class, (new ReflectionClass(EnemyStore::class))->newInstanceWithoutConstructor()); + try { + [$scene] = makeEventRuntime(); + $manager = new class($scene) extends Ichiloto\Engine\Field\EncounterManager { + public function trigger(): void { $this->startEncounter(); } + }; + $map = ['troops' => ['Technical Troop' => 1], 'rate' => 12]; + $manager->configure($map + ['battleArena' => 'arena.road']); + $manager->trigger(); + expect($scene->testSceneManager->lastBattleSettings)->toBe(['battleArena' => 'arena.road']); + $manager->configure($map + ['battleArena' => 'arena.yard']); + $manager->trigger(); + expect($scene->testSceneManager->lastBattleSettings['battleArena'])->toBe('arena.yard'); + $manager->configure(null); + $manager->trigger(); + expect($scene->testSceneManager->battleCount)->toBe(2); + $manager->configure($map); + $manager->trigger(); + expect($scene->testSceneManager->lastBattleSettings)->not->toHaveKey('battleArena') + ->and($scene->testSceneManager->battleCount)->toBe(3); + } finally { + ConfigStore::remove(EnemyStore::class); + chdir($previousDirectory); + unlink($root . '/assets/Data/troops.php'); rmdir($root . '/assets/Data'); rmdir($root . '/assets'); rmdir($root); + } +}); + it('runs the complete technical continuation route and saves its stable completion through IED1', function () { $root = sys_get_temp_dir() . '/event-api-route-' . uniqid(); mkdir($root . '/assets/Data', 0o777, true); @@ -2288,6 +2341,36 @@ function makeCinematicTriggerProject( ->and($scene->hasUnstableEventSession())->toBeFalse(); }); +it('releases graphical cast on cinematic completion failure and legal skip before same-id re-entry', function (string $outcome) { + [$scene, $interpreter] = makeEventRuntime(); + $scene->installPlayer(new EventTestPlayer(new Vector2(1, 1))); + $scene->installCinematicRuntime(); + $data = ['id' => 'graphical-lifecycle', 'name' => 'Graphical Lifecycle', + 'cast' => [['id' => 'runner', 'sprite' => '@', 'x' => 2, 'y' => 2, + 'sprites2d' => ['asset' => 'runner.png', 'width' => 56, 'height' => 56, 'layer' => 100]]], + 'skip' => ['policy' => 'authored'], 'finalizer' => [['type' => 'clear_presentation']]]; + $cinematic = CinematicDefinition::fromArrays($data, [ + ['type' => 'wait', 'seconds' => 0.1], + ...($outcome === 'failure' ? [['type' => 'camera', 'operation' => 'focus', + 'target' => ['kind' => 'staged_actor', 'id' => 'missing']]] : []), + ]); + $session = $scene->cinematicController->start($cinematic); + $first = $scene->cinematicStage->require('runner'); + expect($first->getGraphicalSpriteDefinition()->asset)->toBe('runner.png'); + if ($outcome === 'skip') { + expect($scene->cinematicController->skip())->toBeTrue(); + } else { + $interpreter->update(0.1); + } + expect($session->status)->toBe($outcome === 'failure' ? EventExecutionStatus::FAILED : EventExecutionStatus::COMPLETED) + ->and($scene->cinematicStage->all())->toBe([]) + ->and($scene->cinematicController->active())->toBeNull(); + $scene->cinematicController->start($cinematic); + expect($scene->cinematicStage->require('runner'))->not->toBe($first) + ->and($scene->cinematicStage->require('runner')->getGraphicalSpriteId())->toBe('staged:runner'); + $scene->cinematicController->skip(); +})->with(['complete', 'failure', 'skip']); + it('composes common events inside a cinematic lane and propagates nested failure context', function () { $root = sys_get_temp_dir() . '/cinematic-common-event-' . uniqid(); mkdir($root . '/assets/Events', 0o777, true); diff --git a/tests/Unit/ExperienceAwarderTest.php b/tests/Unit/ExperienceAwarderTest.php index 31b0d413..98262bdd 100644 --- a/tests/Unit/ExperienceAwarderTest.php +++ b/tests/Unit/ExperienceAwarderTest.php @@ -87,3 +87,32 @@ function progressionTestCharacter(string $name): Character $party->members->toArray(), ))->each->toBeTrue(); }); + +it('captures immutable progression facts before and after the award', function () { + $character = progressionTestCharacter('Snapshot'); + $thresholds = $character->getLevelExperienceThresholds(); + $result = ExperienceAwarder::award($character, $thresholds[4]); + $before = $result->before; + $after = $result->after; + $originalStats = $after->stats; + $character->addExperience($thresholds[5]); + $character->stats->attack = 999; + + expect($before->experience)->toBe(0) + ->and($before->level)->toBe(1) + ->and($after->experience)->toBe($thresholds[4]) + ->and($after->level)->toBe(4) + ->and($after->stats)->toBe($originalStats) + ->and($after->progressAt($thresholds[3]))->toMatchArray(['level' => 3, 'current' => 0, 'maximum' => false]) + ->and($after->progressAt($thresholds[4] - 1)['level'])->toBe(3) + ->and(array_column($result->learnedDetails, 'kind'))->toBe(['Ability', 'Magic']); +}); + +it('reports capped progression without a fictional next-level threshold', function () { + $character = progressionTestCharacter('Capped'); + $thresholds = $character->getLevelExperienceThresholds(); + $result = ExperienceAwarder::award($character, $thresholds[$character->maxLevel]); + expect($result->after->progressAt($result->after->experience))->toMatchArray([ + 'level' => $character->maxLevel, 'maximum' => true, 'needed' => 0, 'ratio' => 1.0, + ]); +}); diff --git a/tests/Unit/GameErrorHandlingTest.php b/tests/Unit/GameErrorHandlingTest.php index bd33d48a..04ea1946 100644 --- a/tests/Unit/GameErrorHandlingTest.php +++ b/tests/Unit/GameErrorHandlingTest.php @@ -45,3 +45,74 @@ protected function stop(): void {} rmdir($root); } })->with([['operator', 0], ['mask', 0], ['reported', 1]]); + +it('tears down scenes before platform resources on caught startup and loop errors without masking the crash', function (string $phase) { + $root = sys_get_temp_dir() . '/ichiloto-loop-teardown-' . bin2hex(random_bytes(8)); + mkdir($root); + $script = <<<'PHP' + require $argv[1]; + use Assegai\Collections\ItemList; + use Ichiloto\Engine\Audio\AudioManager; + use Ichiloto\Engine\Core\Game; + use Ichiloto\Engine\Events\Interfaces\ObserverInterface; + use Ichiloto\Engine\Events\Interfaces\StaticObserverInterface; + use Ichiloto\Engine\IO\Console\Console; + use Ichiloto\Engine\Scenes\AbstractScene; + use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; + use Ichiloto\Engine\Scenes\SceneManager; + + Console::setTerminalOutputEnabled(false); + $game = new class($argv[2]) extends Game { + public function __construct(private string $phase) { + $this->options = ['fps' => 1000]; + $this->observers = new ItemList(ObserverInterface::class); + $this->staticObservers = new ItemList(StaticObserverInterface::class); + $this->audioManager = new class extends AudioManager { + public function __construct() {} + public function shutdown(): void { echo "audio\n"; } + }; + $this->sceneManager = new ReflectionClass(SceneManager::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(SceneManager::class, 'scenes')->setValue($this->sceneManager, new ItemList(SceneInterface::class)); + foreach (['broken', 'current'] as $name) { + $scene = new class($name) extends AbstractScene { + public function __construct(private string $label) { $this->started = $label === 'broken'; } + public function stop(): void { + echo $this->label . "\n"; + if ($this->label === 'broken') { throw new RuntimeException('secondary teardown failure'); } + } + }; + $this->sceneManager->addScenes($scene); + $this->sceneManager->currentScene = $scene; + } + } + public function __destruct() {} + protected function start(): void { + $this->isRunning = true; + if ($this->phase === 'startup') { throw new RuntimeException('primary startup failure'); } + } + protected function handleInput(): void {} + protected function update(): void { throw new RuntimeException('primary loop failure'); } + }; + $game->run(); + echo 'unreachable'; + PHP; + try { + $process = proc_open([PHP_BINARY, '-r', $script, dirname(__DIR__, 2) . '/vendor/autoload.php', $phase], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $root); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(1) + ->and($stdout)->toBe("broken\ncurrent\naudio\n") + ->and($stderr)->toContain('The game crashed. Details were written to') + ->and(file_get_contents($root . '/logs/error.log')) + ->toContain('primary ' . ($phase === 'startup' ? 'startup' : 'loop') . ' failure', 'secondary teardown failure'); + } finally { + foreach (glob($root . '/logs/*') ?: [] as $file) { unlink($file); } + if (is_dir($root . '/logs')) { rmdir($root . '/logs'); } + rmdir($root); + } +})->with(['startup', 'loop']); diff --git a/tests/Unit/GameShutdownInputTest.php b/tests/Unit/GameShutdownInputTest.php new file mode 100644 index 00000000..e8e63298 --- /dev/null +++ b/tests/Unit/GameShutdownInputTest.php @@ -0,0 +1,384 @@ +polls > 4) { throw new RuntimeException('Stopped modal kept polling.'); } + ($this->onPoll)?->__invoke(); + return array_shift($this->keys); + } + public function reset(bool $drainBufferedInput = false): void + { + $this->resets++; + if ($this->throwOnReset) { throw new RuntimeException('input reset failed'); } + } +} + +/** Real loop/quit/cleanup without bootstrap, terminal modes, native services or saves. */ +final class ShutdownInputGame extends Game +{ + public int $renders = 0; + public int $updates = 0; + public function __construct() + { + $this->options = ['fps' => 1000]; + $this->observers = new ItemList(ObserverInterface::class); + $this->staticObservers = new ItemList(StaticObserverInterface::class); + $this->sceneManager = new ReflectionClass(SceneManager::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(SceneManager::class, 'game')->setValue($this->sceneManager, $this); + new ReflectionProperty(SceneManager::class, 'scenes')->setValue($this->sceneManager, new ItemList(SceneInterface::class)); + $this->audioManager = new class($this) extends RecordingAudioManager { + public int $shutdowns = 0; + public int $updates = 0; + public function shutdown(): void { $this->shutdowns++; } + public function update(): void { $this->updates++; } + }; + $this->notificationManager = new class extends NotificationManager { + public int $updates = 0; + public int $renders = 0; + public function __construct() {} + public function __destruct() {} + public function update(): void { $this->updates++; } + public function render(?int $x = null, ?int $y = null): void { $this->renders++; } + }; + } + public function __destruct() {} + protected function start(): void { $this->isRunning = true; } + protected function syncScreenSize(bool $resizeLogicalViewport = true): void {} + protected function render(): void { $this->renders++; } + protected function update(): void { $this->updates++; parent::update(); } + public function updateFrame(): void { $this->update(); } + public function pollFrame(): void { $this->handleInput(); } +} + +final class ShutdownInputField extends GameScene +{ + public int $musicRefreshes = 0; + public function __construct(ShutdownInputGame $game) + { + $this->sceneManager = $game->sceneManager; + $this->eventManager = EventManager::getInstance($game); + $this->uiManager = new ReflectionClass(UIManager::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(UIManager::class, 'uiElements')->setValue($this->uiManager, new ItemList(UIElementInterface::class)); + $this->camera = new class extends Camera { + public int $stops = 0; + public ?Closure $onUpdate = null; + public function __construct() {} + public function start(): void {} + public function suspend(): void {} + public function update(): void { ($this->onUpdate)?->__invoke(); } + public function stop(): void { $this->stops++; } + }; + $this->player = new class extends Player { + public int $interactions = 0; + public int $moves = 0; + public ?Closure $onInteract = null; + public function __construct() {} + public function interact(): void { $this->interactions++; ($this->onInteract)?->__invoke(); } + public function move(Vector2 $direction, Camera $camera): void { $this->moves++; } + public function reconcileActiveEventState(): void {} + }; + $this->npcManager = new class extends NpcManager { + public int $updates = 0; + public function __construct() {} + public function update(): void { $this->updates++; } + }; + $this->fieldState = new class(new SceneStateContext($this)) extends FieldState { + public int $renders = 0; + public int $resumes = 0; + public int $navigations = 0; + public function renderTheField(bool $forceFullRepaint = false): void { $this->renders++; } + public function resume(): void { $this->resumes++; $this->renderTheField(); } + protected function handleNavigation(GameScene $scene): void { $this->navigations++; parent::handleNavigation($scene); } + }; + $this->state = $this->fieldState; + $this->sceneStateContext = new SceneStateContext($this); + $game->sceneManager->addScenes($this); + $game->sceneManager->currentScene = $this; + $this->start(); + } + public function refreshFieldMusic(bool $force = false, bool $keepSilence = false): void { $this->musicRefreshes++; } +} + +function shutdownModalProbe(Game $game, string $kind): ConfirmModal|SelectModal +{ + if ($kind === 'select') { + return new class($game, 'Shutdown probe', ['First', 'Second'], 'Select') extends SelectModal { + public int $renders = 0; + public int $updates = 0; + public int $erases = 0; + public ?Closure $onUpdate = null; + public function render(?int $x = null, ?int $y = null): void { $this->renders++; } + public function erase(?int $x = null, ?int $y = null): void { $this->erases++; } + public function update(): void { $this->updates++; ($this->onUpdate)?->__invoke(); parent::update(); } + }; + } + + return new class($game, 'Shutdown probe', 'Confirm') extends ConfirmModal { + public int $renders = 0; + public int $updates = 0; + public int $erases = 0; + public ?Closure $onUpdate = null; + public function render(): void { $this->renders++; } + public function erase(): void { $this->erases++; } + public function update(): void { $this->updates++; ($this->onUpdate)?->__invoke(); parent::update(); } + }; +} + +beforeEach(function () { + $this->staticBefore = []; + foreach ([Console::class, Cursor::class, InputManager::class, EventManager::class, ModalManager::class, + ConfigStore::class, Timers::class, Debug::class, AudioManager::class] as $class) { + $this->staticBefore[$class] = new ReflectionClass($class)->getStaticProperties(); + } + new ReflectionProperty(EventManager::class, 'instance')->setValue(null, null); + new ReflectionProperty(ModalManager::class, 'instance')->setValue(null, null); + foreach (['frameDepth' => 0, 'isRecomposing' => false, 'terminalHandedBack' => false, + 'output' => null, 'terminalOutputStream' => null] as $name => $value) { + new ReflectionProperty(Console::class, $name)->setValue(null, $value); + } + $this->logDirectory = sys_get_temp_dir() . '/ichiloto-shutdown-input-' . bin2hex(random_bytes(8)); + Debug::configure(['log_directory' => $this->logDirectory]); + Timers::clear(); + Timers::setFrameTick(null); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 80, 'height' => 24])); + putSceneAudioConfig([]); + ob_start(); + Console::setTerminalOutputEnabled(false); + Console::syncDimensions(80, 24); + $this->game = new ShutdownInputGame(); + $this->scene = new ShutdownInputField($this->game); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + new ReflectionProperty(InputManager::class, 'eventManager')->setValue(null, EventManager::getInstance($this->game)); + InputManager::setBindings([ + 'quit' => ['keys' => [KeyCode::q]], 'confirm' => ['keys' => [KeyCode::ENTER]], + 'action' => ['keys' => [KeyCode::ENTER]], 'right' => ['keys' => [KeyCode::d]], + ]); + $this->input = new ShutdownInputProbe([KeyCode::q, KeyCode::ENTER]); + InputManager::setInputSource($this->input); +}); + +afterEach(function () { + $this->game->quit(); + ob_end_clean(); + foreach ($this->staticBefore as $class => $state) { + foreach ($state as $name => $value) { + new ReflectionProperty($class, $name)->setValue(null, $value); + } + } + foreach (glob($this->logDirectory . '/*') ?: [] as $file) { unlink($file); } + if (is_dir($this->logDirectory)) { rmdir($this->logDirectory); } +}); + +it('ends the field frame after the actual quit confirmation aliases the action key', function (string $sourceType) { + $stream = null; + if ($sourceType === 'terminal') { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, "q\r"); + rewind($stream); + InputManager::setInputSource(new TerminalInputSource($stream)); + } elseif ($sourceType === 'renderer') { + $transport = new FakeRendererTransport(); + $transport->batches[] = [ + RendererEvent::fromJson('{"protocol":1,"type":"key","key":"q"}'), + RendererEvent::fromJson('{"protocol":1,"type":"key","key":"enter"}'), + ]; + InputManager::setInputSource(new RendererInputSource(new RendererClient($transport))); + } + try { + InputManager::handleInput(); + $this->scene->update(); + expect($this->scene->player->interactions)->toBe(0) + ->and($this->scene->fieldState->navigations)->toBe(0) + ->and($this->scene->player->moves)->toBe(0) + ->and($this->scene->npcManager->updates)->toBe(0) + ->and($this->scene->musicRefreshes)->toBe(0) + ->and($this->scene->isStarted())->toBeFalse() + ->and($this->game->audioManager->shutdowns)->toBe(1) + ->and(InputManager::getPressedKeyCode())->toBeNull() + ->and(Input::isKeyUp(KeyCode::ENTER))->toBeFalse() + ->and(Input::getAxis(AxisName::HORIZONTAL))->toBe(0.0) + ->and(ModalManager::getInstance($this->game)->currentModal)->toBeNull(); + } finally { + if (is_resource($stream)) { fclose($stream); } + } +})->with(['scripted', 'terminal', 'renderer']); + +it('does not navigate wander or refresh field music after an interaction stops the scene', function () { + InputManager::setBinding('right', [KeyCode::ENTER]); + $this->input->keys = [KeyCode::ENTER]; + $this->scene->player->onInteract = $this->scene->stop(...); + InputManager::handleInput(); + $this->scene->update(); + expect($this->scene->player->interactions)->toBe(1) + ->and($this->scene->fieldState->navigations)->toBe(0) + ->and($this->scene->npcManager->updates)->toBe(0) + ->and($this->scene->musicRefreshes)->toBe(0); +}); + +it('does not execute a field state after an earlier scene update quits', function () { + $this->input->keys = [KeyCode::ENTER]; + InputManager::handleInput(); + $this->scene->camera->onUpdate = $this->game->quit(...); + $this->scene->update(); + expect($this->scene->player->interactions)->toBe(0) + ->and($this->scene->fieldState->navigations)->toBe(0) + ->and($this->scene->npcManager->updates)->toBe(0) + ->and($this->scene->musicRefreshes)->toBe(0); +}); + +it('clears cached input even if source reset fails and still completes scene and platform cleanup', function () { + $this->input->keys = [KeyCode::ENTER]; + InputManager::handleInput(); + $this->input->throwOnReset = true; + $this->game->quit(); + $this->game->quit(); + expect(InputManager::getPressedKeyCode())->toBeNull() + ->and(Input::isKeyUp(KeyCode::ENTER))->toBeFalse() + ->and($this->scene->camera->stops)->toBe(1) + ->and($this->game->audioManager->shutdowns)->toBe(1) + ->and($this->input->resets)->toBe(1) + ->and(file_get_contents($this->logDirectory . '/error.log'))->toContain('input reset failed'); +}); + +it('returns from the main loop without an update when keyboard dispatch quits', function () { + EventManager::getInstance($this->game)->addEventListener(EventType::KEYBOARD, $this->game->quit(...)); + $this->game->run(); + $this->game->pollFrame(); + expect($this->input->polls)->toBe(1)->and($this->game->updates)->toBe(0) + ->and($this->game->renders)->toBe(0)->and(InputManager::getPressedKeyCode())->toBeNull(); +}); + +it('preserves startup modals before the main loop has begun', function (string $kind) { + $this->input->keys = [KeyCode::ENTER]; + expect(new ReflectionProperty(Game::class, 'isRunning')->getValue($this->game))->toBeFalse() + ->and(shutdownModalProbe($this->game, $kind)->open())->toBe($kind === 'select' ? 0 : true) + ->and($this->input->polls)->toBe(1) + ->and($this->scene->isStarted())->toBeTrue(); +})->with(['confirm', 'select']); + +it('returns the normal close override result exactly once', function (string $kind) { + $this->input->keys = [KeyCode::ENTER]; + $modal = $kind === 'select' + ? new class($this->game, 'Choose', ['First']) extends SelectModal { + public int $closes = 0; + public function close(): int { $this->closes++; parent::close(); return 7; } + } + : new class($this->game, 'Confirm', 'Confirm') extends ConfirmModal { + public int $closes = 0; + public function close(): mixed { $this->closes++; parent::close(); return 'close-result'; } + }; + + expect($modal->open())->toBe($kind === 'select' ? 7 : 'close-result') + ->and($modal->closes)->toBe(1) + ->and($modal->isShowing())->toBeFalse(); +})->with(['confirm', 'select']); + +it('unwinds a modal without further polling updates rendering or scene resume after quit', function (string $phase, string $kind) { + $this->input->keys = [null]; + $closed = 0; + EventManager::getInstance($this->game)->addEventListener(EventType::MODAL, + function (ModalEvent $event) use (&$closed): void { + if ($event->modalEventType === ModalEventType::CLOSE) { $closed++; } + }); + $modal = shutdownModalProbe($this->game, $kind); + if ($phase === 'before open') { $this->game->quit(); } + if ($phase === 'input dispatch') { + $this->input->keys = [KeyCode::ENTER]; + EventManager::getInstance($this->game)->addEventListener(EventType::KEYBOARD, $this->game->quit(...)); + } + if ($phase === 'modal update') { $modal->onUpdate = $this->game->quit(...); } + if ($phase === 'update throws after quit') { + $modal->onUpdate = function (): void { + $this->game->quit(); + throw new LogicException('primary modal failure'); + }; + } + if ($phase === 'blocked frame') { + Timers::setFrameTick($this->game->quit(...)); + } + if ($phase === 'update throws after quit') { + expect($modal->open(...))->toThrow(LogicException::class, 'primary modal failure'); + } else { + expect($modal->open())->toBe($kind === 'select' ? -1 : null); + } + $modal->close(); + expect($modal->isShowing())->toBeFalse() + ->and($modal->renders)->toBe($phase === 'before open' ? 0 : 1) + ->and($modal->erases)->toBe(0) + ->and($modal->updates)->toBe(in_array($phase, ['before open', 'input dispatch'], true) ? 0 : 1) + ->and($this->input->polls)->toBe($phase === 'before open' ? 0 : 1) + ->and($this->scene->fieldState->resumes)->toBe(0) + ->and($this->scene->fieldState->renders)->toBe(0) + ->and($closed)->toBe(0); +})->with(['before open', 'input dispatch', 'modal update', 'blocked frame', 'update throws after quit']) + ->with(['confirm', 'select']); + +it('stops a blocked frame immediately when its update observer quits', function () { + $this->game->addObserver(new class($this->game) implements ObserverInterface { + public function __construct(private Game $game) {} + public function onNotify(object $entity, EventInterface $event): void + { + if ($event instanceof GameEvent && $event->getGameEventType() === GameEventType::UPDATE) { $this->game->quit(); } + } + }); + $timerCalls = 0; + Timers::after(0, function () use (&$timerCalls): void { $timerCalls++; }); + $this->game->tickWhileBlocked(); + expect($timerCalls)->toBe(0) + ->and($this->game->notificationManager->updates)->toBe(0) + ->and($this->game->notificationManager->renders)->toBe(0) + ->and($this->game->audioManager->updates)->toBe(0); +}); diff --git a/tests/Unit/GraphicalBattlePresentationTest.php b/tests/Unit/GraphicalBattlePresentationTest.php index e2f015c1..6ea85558 100644 --- a/tests/Unit/GraphicalBattlePresentationTest.php +++ b/tests/Unit/GraphicalBattlePresentationTest.php @@ -2,6 +2,7 @@ use Ichiloto\Engine\Battle\Actions\AttackAction; use Ichiloto\Engine\Battle\Presentation\BattleArenaDefinition; +use Ichiloto\Engine\Battle\Presentation\BattleCanvasLayout; use Ichiloto\Engine\Battle\Presentation\BattleCanvasUiAdapter; use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; use Ichiloto\Engine\Battle\Presentation\BattlerArtwork; @@ -10,6 +11,10 @@ use Ichiloto\Engine\Battle\Presentation\BattleHudSnapshot; use Ichiloto\Engine\Battle\Presentation\BattleUiSkin; use Ichiloto\Engine\Battle\Presentation\BattleTargetCursor; +use Ichiloto\Engine\Battle\BattleResult; +use Ichiloto\Engine\Battle\Presentation\BattleRewards; +use Ichiloto\Engine\Battle\Presentation\BattleResultsSkin; +use Ichiloto\Engine\Progression\ExperienceAwarder; use Ichiloto\Engine\Battle\Resolution\CombatRandomSource; use Ichiloto\Engine\Battle\Resolution\CombatResolver; use Ichiloto\Engine\Battle\Resolution\SeededCombatRandomSource; @@ -24,6 +29,7 @@ use Ichiloto\Engine\IO\Enumerations\Color; use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImage; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImagePreflight; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasNineSlice; use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasRectangle; use Ichiloto\Engine\Rendering\Presentation\PresentationColor; @@ -34,6 +40,7 @@ use Ichiloto\Engine\Scenes\Battle\BattleScene; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\PlaySettings; +use Ichiloto\Engine\Util\Debug; use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\Rendering\Runtime\RendererRuntime; use Ichiloto\Engine\Rendering\Runtime\RendererRuntimeConfig; @@ -78,19 +85,48 @@ function graphicalBattleFixture(bool $skinned = false, bool $directionalCursor = return [$battle, new BattlePresentationCatalog(['Twins' => $arena], ['Hero' => $art], ['Twin' => $art]), $hero, $enemies]; } -function graphicalBattleScene(BattleConfig $battle, ?GraphicalBattlePresentation $presentation): BattleScene +function graphicalBattleScene(BattleConfig $battle, ?GraphicalBattlePresentation $presentation, ?BattleCanvasLayout $ui = null): BattleScene { $scene = new ReflectionClass(BattleScene::class)->newInstanceWithoutConstructor(); new ReflectionProperty(BattleScene::class, 'config')->setValue($scene, $battle); new ReflectionProperty(BattleScene::class, 'graphicalPresentation')->setValue($scene, $presentation); + new ReflectionProperty(BattleScene::class, 'battleUiLayout')->setValue($scene, $ui); new ReflectionProperty(AbstractScene::class, 'camera')->setValue($scene, new Camera($scene, 135, 36)); $scene->ui = new BattleScreen($scene); return $scene; } +function graphicalResultsFixtureSkin(): BattleResultsSkin +{ + return new BattleResultsSkin(array_fill_keys(['panel', 'quiet', 'track', 'selector', 'portrait', 'exp', 'divider', 'button'], + new CanvasNineSlice('test-sprite.png', new SpriteSourceRect(0, 0, 32, 48))), + array_fill_keys(['text', 'muted', 'accent', 'positive', 'negative', 'ink'], PresentationColor::rgb(220, 220, 220))); +} + +function graphicalBattleConfigurationScene(?RendererRuntime $runtime): BattleScene +{ + $game = new class extends Ichiloto\Engine\Core\Game { + public function __construct() { + $this->sceneManager = new class extends Ichiloto\Engine\Scenes\SceneManager { + public function __construct() { $this->scenes = new Assegai\Collections\ItemList(Ichiloto\Engine\Scenes\Interfaces\SceneInterface::class); } + }; + } + public function __destruct() {} + }; + if ($runtime !== null) { $game->useRendererRuntime($runtime); } + return new class($game) extends BattleScene { + public function __construct(private Ichiloto\Engine\Core\Game $testGame) {} + public function getGame(): Ichiloto\Engine\Core\Game { return $this->testGame; } + // Configuration is under test, not the timed/input-driven transition. + public function setState(Ichiloto\Engine\Scenes\Battle\States\BattleSceneState $state): void { $this->state = $state; } + }; +} + beforeEach(function () { $this->statics = []; - foreach ([Console::class, ConfigStore::class, InputManager::class] as $class) { $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); } + foreach ([Console::class, ConfigStore::class, InputManager::class, Debug::class] as $class) { $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); } + $this->logRoot = sys_get_temp_dir() . '/ichiloto-battle-logs-' . bin2hex(random_bytes(5)); + Debug::configure(['log_directory' => $this->logRoot]); Console::setTerminalOutputEnabled(false); Console::syncDimensions(135, 36); Console::setLayerTracking(true); @@ -100,11 +136,86 @@ function graphicalBattleScene(BattleConfig $battle, ?GraphicalBattlePresentation }); afterEach(function () { + foreach (glob($this->logRoot . '/*') ?: [] as $file) { unlink($file); } + if (is_dir($this->logRoot)) { rmdir($this->logRoot); } foreach ($this->statics as $class => $properties) { foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } } }); +it('composes Results over the frozen real battlefield without changing awarded gameplay facts', function () { + [$battle, $catalog, $hero] = graphicalBattleFixture(true); + $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); + $scene = graphicalBattleScene($battle, $presentation); + new ReflectionProperty(BattleScene::class, 'resultsSkin')->setValue($scene, graphicalResultsFixtureSkin()); + $scene->result = new BattleResult('Victory', rewards: new BattleRewards(10000, 42, + [ExperienceAwarder::award($hero, 10000)])); + $awardedExperience = $hero->currentExp; + $scene->beginResults(); + $scene->resultsPlayback->update(3); + $frame = $scene->getPresentationCanvas(); + expect($scene->hasGraphicalResults())->toBeTrue() + ->and($frame->images[0])->toEqual($presentation->frame()->images[0]) + ->and(array_column($frame->textLayers, 'id'))->toContain('results-heading') + ->and(array_column($frame->textLayers, 'id'))->not->toContain('battle-ui-0', 'battle-pause'); + expect($scene->getPresentationCanvas()->toArray())->toBe($frame->toArray()); + $scene->endResults(); + expect($scene->hasGraphicalResults())->toBeFalse() + ->and($scene->resultsPlayback)->toBeNull() + ->and($hero->currentExp)->toBe($awardedExperience); + $scene->beginResults(); + expect($scene->resultsPlayback->currentStage()['kind'])->toBe('primary') + ->and($hero->currentExp)->toBe($awardedExperience); +}); + +it('keeps victory input presentation-only and finishes the exit without an extra confirmation', function () { + [$battle, $catalog, $hero] = graphicalBattleFixture(true); + $scene = graphicalBattleScene($battle, GraphicalBattlePresentation::prepare($battle, $catalog, $this->root)); + new ReflectionProperty(BattleScene::class, 'resultsSkin')->setValue($scene, graphicalResultsFixtureSkin()); + $scene->result = new BattleResult('Victory', rewards: new BattleRewards(1, 0, [ExperienceAwarder::award($hero, 1)])); + $context = new Ichiloto\Engine\Scenes\SceneStateContext($scene); + $victory = new class($context) extends Ichiloto\Engine\Scenes\Battle\States\BattleVictoryState { + protected function playVictoryMusic(): void {} + }; + $end = new class($context) extends Ichiloto\Engine\Scenes\Battle\States\BattleEndState { + public bool $entered = false; + public function enter(): void { $this->entered = true; } + }; + new ReflectionProperty(BattleScene::class, 'endState')->setValue($scene, $end); + $delta = new ReflectionProperty(Ichiloto\Engine\Core\Time::class, 'deltaTime'); + $oldDelta = $delta->getValue(); + new ReflectionProperty(InputManager::class, 'config')->setValue(null, + ['action' => ['keys' => [Ichiloto\Engine\IO\Enumerations\KeyCode::ENTER]]]); + try { + $scene->setState($victory); + $delta->setValue(null, 0.01); + $victory->execute(); + new ReflectionProperty(InputManager::class, 'keyPress')->setValue(null, Ichiloto\Engine\IO\Enumerations\KeyCode::ENTER); + new ReflectionProperty(InputManager::class, 'previousKeyPress')->setValue(null, null); + $victory->execute(); + expect($scene->resultsPlayback->isComplete())->toBeTrue()->and($end->entered)->toBeFalse(); + $victory->execute(); + expect($end->entered)->toBeFalse(); + new ReflectionProperty(InputManager::class, 'keyPress')->setValue(null, null); + $delta->setValue(null, 0.2); + $victory->execute(); + new ReflectionProperty(InputManager::class, 'keyPress')->setValue(null, Ichiloto\Engine\IO\Enumerations\KeyCode::ENTER); + $victory->execute(); + expect($scene->resultsPlayback->isExiting())->toBeTrue()->and($end->entered)->toBeFalse(); + new ReflectionProperty(InputManager::class, 'keyPress')->setValue(null, null); + $delta->setValue(null, 30.0); + $victory->resume(); + $victory->execute(); + expect($end->entered)->toBeFalse(); + $delta->setValue(null, 0.5); + $victory->execute(); + expect($end->entered)->toBeTrue()->and($scene->resultsPlayback)->toBeNull() + ->and($hero->currentExp)->toBe(2); + } finally { + $delta->setValue(null, $oldDelta); + } +}); + it('resolves pivots and contain sizes in independent fractional canvas units', function () { [$battle, $catalog] = graphicalBattleFixture(); $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); @@ -114,8 +225,167 @@ function graphicalBattleScene(BattleConfig $battle, ?GraphicalBattlePresentation expect($presentation->frame()->toArray())->toBe($canvas->toArray()); }); -it('keeps repeated enemy instance IDs through target reorder feedback and removal', function () { +it('selects an explicit encounter arena without leaking it into the next battle', function () { + [$fixture, $catalog] = graphicalBattleFixture(); + $legacy = $catalog->arenas['Twins']; + $yard = new BattleArenaDefinition(1350, 720, + new CanvasImage('yard', $legacy->background->asset, $legacy->background->destination), + $legacy->partySlots, $legacy->enemySlots); + $catalog = new BattlePresentationCatalog(['Twins' => $legacy, 'arena.yard' => $yard], $catalog->actors, $catalog->enemies); + $battle = new BattleConfig($fixture->party, $fixture->troop, settings: ['battleArena' => 'arena.yard']); + + expect(GraphicalBattlePresentation::prepare($battle, $catalog, $this->root)->arena)->toBe($yard) + ->and(GraphicalBattlePresentation::prepare($fixture, $catalog, $this->root)->arena)->toBe($legacy) + ->and($battle->entryRulesEvaluated())->toBeFalse(); + $restored = unserialize(serialize(new BattleConfig($fixture->party, new Troop('Twins'), settings: $battle->settings))); + expect($catalog->arenaFor($restored))->toBe($yard); + $unconfigured = new BattleConfig($fixture->party, new Troop('Deferred area')); + expect($catalog->arenaFor($unconfigured))->toBeNull(); +}); + +it('rejects explicit invalid or absent arena keys instead of falling back to troop art', function (mixed $key, string $error) { + [$fixture, $catalog] = graphicalBattleFixture(); + $battle = new BattleConfig($fixture->party, $fixture->troop, settings: ['battleArena' => $key]); + expect(fn() => GraphicalBattlePresentation::prepare($battle, $catalog, $this->root))->toThrow($error) + ->and($battle->entryRulesEvaluated())->toBeFalse(); +})->with([ + 'unknown' => ['arena.missing', RuntimeException::class], + 'empty' => ['', InvalidArgumentException::class], + 'controls' => ["arena\nyard", InvalidArgumentException::class], + 'array' => [[], InvalidArgumentException::class], + 'integer' => [42, InvalidArgumentException::class], +]); + +it('resolves troop formations inside the selected arena without changing location or shared state', function () { + [$fixture, $catalog] = graphicalBattleFixture(); + $base = $catalog->arenas['Twins']; + $slots = [new BattlerSlot(350, 400, 160, 230), new BattlerSlot(530, 470, 120, 100)]; + $road = new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + enemySlotsByTroop: ['troop.mixed' => $slots, 'Localized name' => array_reverse($slots)]); + $catalog = new BattlePresentationCatalog(['Twins' => $base, 'arena.road' => $road], $catalog->actors, $catalog->enemies); + $troop = new Troop('Localized name', $fixture->troop->members->toArray(), definitionId: 'troop.mixed'); + $battle = new BattleConfig($fixture->party, $troop, settings: ['battleArena' => 'arena.road']); + $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); + + expect($presentation->arena->enemySlots)->toBe($slots) + ->and($presentation->arena->background)->toBe($base->background) + ->and($presentation->arena->partySlots)->toBe($base->partySlots) + ->and($road->enemySlots)->toBe($base->enemySlots) + ->and($road->enemySlotsByTroop['troop.mixed'])->toBe($slots) + ->and($battle->entryRulesEvaluated())->toBeFalse(); + $images = $presentation->frame()->images; + foreach ($slots as $index => $slot) { + expect($images[$index + 2]->destination)->toEqual($slot->place($catalog->enemies['Twin'])); + } + expect($presentation->frame()->toArray())->toBe($presentation->frame()->toArray()); + $unchanged = new BattleConfig($fixture->party, $fixture->troop, settings: ['battleArena' => 'arena.road']); + expect($catalog->arenaFor($unchanged))->toBe($road) + ->and($catalog->arenaFor($fixture))->toBe($base) + ->and($catalog->arenaFor(new BattleConfig($fixture->party, $troop)))->toBeNull(); + + // Current catalog data, not a serialized arena, resolves a restored encounter. + $restored = unserialize(serialize(new BattleConfig($fixture->party, + new Troop('Renamed troop', definitionId: 'troop.mixed'), settings: $battle->settings))); + expect($catalog->arenaFor($restored)->enemySlots)->toBe($slots); +}); + +it('uses historical formation names only when the troop has no authored identity', function () { + [$fixture, $catalog] = graphicalBattleFixture(); + $base = $catalog->arenas['Twins']; + $slots = array_reverse($base->enemySlots); + $arena = new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + enemySlotsByTroop: ['Twins' => $slots]); + $catalog = new BattlePresentationCatalog(['Twins' => $arena, 'arena.road' => $arena], $catalog->actors, $catalog->enemies); + expect($catalog->arenaFor($fixture)->enemySlots)->toBe($slots); + $identified = new BattleConfig($fixture->party, new Troop('Twins', definitionId: 'troop.other'), + settings: ['battleArena' => 'arena.road']); + expect($catalog->arenaFor($identified))->toBe($arena); +}); + +it('preserves arena and inherited UI geometry when applying a troop formation', function (bool $ownSkin) { + [$fixture, $catalog] = graphicalBattleFixture(true); + $base = $catalog->arenas['Twins']; + $slots = array_reverse($base->enemySlots); + $ui = new BattleCanvasLayout(1350, 720, skin: $base->skin, feedbackArea: $base->feedbackArea); + $arena = new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + uiCellWidth: 9, uiCellHeight: 18, skin: $ownSkin ? $base->skin : null, + feedbackArea: $ownSkin ? $base->feedbackArea : null, enemySlotsByTroop: ['Twins' => $slots]); + $catalog = new BattlePresentationCatalog(['Twins' => $arena], $catalog->actors, $catalog->enemies, ui: $ui); + $resolved = GraphicalBattlePresentation::prepare($fixture, $catalog, $this->root)->arena; + expect($resolved->skin)->toBe($base->skin) + ->and($resolved->feedbackArea)->toBe($base->feedbackArea) + ->and($resolved->uiGrid)->toEqual($arena->uiGrid) + ->and($resolved->enemySlots)->toBe($slots) + ->and($resolved->background)->toBe($base->background) + ->and($arena->withDefaultUi($ui)->getForTroop('Twins')->enemySlots)->toBe($slots); +})->with([true, false]); + +it('rejects invalid troop formation metadata', function (array $formations) { + [$fixture, $catalog] = graphicalBattleFixture(); + $base = $catalog->arenas['Twins']; + expect(fn() => new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + enemySlotsByTroop: $formations))->toThrow(InvalidArgumentException::class); +})->with([ + 'numeric identity' => [[0 => []]], + 'empty identity' => [['' => []]], + 'control in identity' => [["troop\ninvalid" => []]], + 'not a slot list' => [['Twins' => 'invalid']], + 'sparse slot list' => [['Twins' => [1 => new BattlerSlot(350, 400, 100, 100)]]], + 'untyped slot' => [['Twins' => [['x' => 350, 'y' => 400]]]], + 'too many slots' => [['Twins' => array_fill(0, 65, new BattlerSlot(350, 400, 100, 100))]], +]); + +it('preflights the selected formation instead of silently reverting to default slots', function (array $slots, string $error) { + [$fixture, $catalog] = graphicalBattleFixture(); + $base = $catalog->arenas['Twins']; + $arena = new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + enemySlotsByTroop: ['Twins' => $slots]); + $catalog = new BattlePresentationCatalog(['Twins' => $arena], $catalog->actors, $catalog->enemies); + expect(fn() => GraphicalBattlePresentation::prepare($fixture, $catalog, $this->root))->toThrow($error) + ->and($fixture->entryRulesEvaluated())->toBeFalse(); +})->with([ + 'empty authored formation' => [[], RuntimeException::class], + 'missing member slot' => [[new BattlerSlot(350, 400, 100, 100)], RuntimeException::class], + 'out of canvas' => [[new BattlerSlot(1400, 400, 100, 100), new BattlerSlot(530, 470, 120, 100)], InvalidArgumentException::class], +]); + +it('detaches references in authored formation mappings', function () { + [$fixture, $catalog] = graphicalBattleFixture(); + $base = $catalog->arenas['Twins']; + $slot = new BattlerSlot(350, 400, 100, 100); + $slots = [&$slot]; + $formations = ['Twins' => &$slots]; + $arena = new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + enemySlotsByTroop: $formations); + $slot = new BattlerSlot(530, 470, 120, 100); + $slots = []; + expect($arena->getForTroop('Twins')->enemySlots)->toHaveCount(1) + ->and($arena->getForTroop('Twins')->enemySlots[0]->x)->toBe(350.0); +}); + +it('degrades an explicit arena without a catalog to the terminal presentation and never blocks combat', function (bool $native) { + [$fixture] = graphicalBattleFixture(); + $battle = new BattleConfig($fixture->party, $fixture->troop, settings: ['battleArena' => 'arena.yard']); + $runtime = $native ? new RendererRuntime( + new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), $this->root), new FakeRendererTransport()) : null; + $scene = graphicalBattleConfigurationScene($runtime); + // Presentation must never change whether combat happens: a missing + // catalog under an explicit arena logs and degrades, and terminal play + // never loads the catalog at all. + $scene->configure($battle); + expect($scene->config)->toBe($battle)->and($battle->entryRulesEvaluated())->toBeTrue() + ->and($scene->graphicalPresentation)->toBeNull() + ->and($scene->battleUiLayout)->toBeNull(); +})->with([true, false]); + +it('keeps repeated enemy instance IDs through target reorder feedback and removal', function (bool $override) { [$battle, $catalog, $hero, $enemies] = graphicalBattleFixture(); + if ($override) { + $base = $catalog->arenas['Twins']; + $arena = new BattleArenaDefinition(1350, 720, $base->background, $base->partySlots, $base->enemySlots, + enemySlotsByTroop: ['Twins' => array_reverse($base->enemySlots)]); + $catalog = new BattlePresentationCatalog(['Twins' => $arena], $catalog->actors, $catalog->enemies); + } $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); $scene = graphicalBattleScene($battle, $presentation); $field = $scene->ui->fieldWindow; @@ -138,7 +408,7 @@ function graphicalBattleScene(BattleConfig $battle, ?GraphicalBattlePresentation expect(array_map(fn($image) => $image->id, $after->images))->toBe([$ids[0], $ids[1], $ids[3]]) ->and($after->images[2]->destination)->toEqual($first->images[3]->destination) ->and($after->indicators)->toHaveCount(1); -}); +})->with([false, true]); it('selects the graphical field before constructing terminal battler output and clips UI to owned windows', function () { [$battle, $catalog] = graphicalBattleFixture(); @@ -169,6 +439,52 @@ function graphicalBattleScene(BattleConfig $battle, ?GraphicalBattlePresentation ->and($scene->getPresentationCanvas())->toBeNull(); }); +it('uses shared native controls without requiring encounter artwork and keeps the field below them', function () { + [$battle, $catalog] = graphicalBattleFixture(skinned: true); + $arena = $catalog->arenas['Twins']; + $layout = new BattleCanvasLayout(1350, 720, skin: $arena->skin, feedbackArea: $arena->feedbackArea); + $catalog = new BattlePresentationCatalog([], [], [], ui: $layout); + expect(GraphicalBattlePresentation::prepare($battle, $catalog, '/no-artwork-required'))->toBeNull() + ->and($catalog->requiredCapabilities())->toBe(['graphical_canvas', 'canvas_clip_opacity', 'canvas_glyph_effects']); + $scene = graphicalBattleScene($battle, null, $layout); + Console::clear(); + $scene->ui->renderField(); + $scene->ui->showControls(); + $scene->ui->showMessage('Shared action banner'); + Console::write('OLD FOOTER MUST NOT DRAW', 2, 32); + Console::withLayer('modal', fn() => Console::write('MODAL ABOVE HUD', 3, 33), 2000); + $frame = $scene->getPresentationCanvas(); + $layers = array_column($frame->textLayers, null, 'id'); + $field = $layers['battle-field']; + expect($scene->ui->usesGraphicalField())->toBeFalse() + ->and(implode('', array_column($field->runs, 'text')))->toContain('ASCII MUST NOT DRAW')->not->toContain('OLD FOOTER') + ->and($field->layer)->toBe(0) + ->and(array_all($frame->images, fn($image) => $image->layer > $field->layer))->toBeTrue() + ->and(array_any($frame->images, fn($image) => str_starts_with($image->id, 'hud-stats-')))->toBeTrue() + ->and(array_any($frame->textLayers, fn($layer) => $layer->layer >= 1500 + && str_contains(implode('', array_column($layer->runs, 'text')), 'MODAL ABOVE HUD')))->toBeTrue() + ->and(implode('', array_map(fn($layer) => implode('', array_column($layer->runs, 'text')), $frame->textLayers))) + ->not->toContain('OLD FOOTER MUST NOT DRAW')->toContain('Shared action banner'); + $scene->ui->hideMessage(); + $scene->ui->hideControls(); + expect($scene->getPresentationCanvas()->images)->toBe([]); +}); + +it('applies the project UI to an authored arena without replacing its art or explicit skin', function () { + [$battle, $plain] = graphicalBattleFixture(); + [, $skinned] = graphicalBattleFixture(skinned: true); + $arena = $skinned->arenas['Twins']; + $layout = new BattleCanvasLayout(1350, 720, skin: $arena->skin, feedbackArea: $arena->feedbackArea); + $inherited = new BattlePresentationCatalog($plain->arenas, $plain->actors, $plain->enemies, ui: $layout); + $presentation = GraphicalBattlePresentation::prepare($battle, $inherited, $this->root); + expect($presentation->arena->skin)->toBe($layout->skin) + ->and($presentation->arena->feedbackArea)->toBe($layout->feedbackArea) + ->and($presentation->frame()->images[0])->toBe($plain->arenas['Twins']->background) + ->and($arena->withDefaultUi($layout))->toBe($arena); + expect(fn() => new BattlePresentationCatalog([], [], [], ui: new BattleCanvasLayout(1350, 720))) + ->toThrow(InvalidArgumentException::class, 'requires a skin'); +}); + it('uses live skin controls without readmitting terminal cells and keeps markers independently owned', function () { [$battle, $catalog, $hero, $enemies] = graphicalBattleFixture(skinned: true); $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); @@ -334,8 +650,8 @@ public function nextInt(int $minimum, int $maximum): int { it('loads current optional metadata without persisting it in battle state', function () { $root = sys_get_temp_dir() . '/ichiloto-battle-catalog-' . bin2hex(random_bytes(5)); - mkdir($root . '/Data', 0777, true); - $file = $root . '/Data/battle-presentation.php'; + $file = $root . '/' . BattlePresentationCatalog::FILE; + mkdir(dirname($file), 0777, true); try { expect(BattlePresentationCatalog::load($root))->toBeNull(); copy(__DIR__ . '/../Fixtures/BattlePresentation/catalog.php', $file); @@ -347,7 +663,7 @@ public function nextInt(int $minimum, int $maximum): int { expect($battle->__serialize())->not->toHaveKey('graphicalPresentation')->not->toHaveKey('canvas'); expect(fn() => GraphicalBattlePresentation::prepare($battle, $first, $root))->toThrow(RuntimeException::class, 'readable PNG'); expect($battle->entryRulesEvaluated())->toBeFalse(); - } finally { unlink($file); rmdir($root . '/Data'); rmdir($root); } + } finally { unlink($file); rmdir(dirname($file)); rmdir($root . '/Data'); rmdir($root); } }); it('rejects malformed typed artwork and independent placement geometry', function (Closure $invalid) { @@ -361,32 +677,119 @@ public function nextInt(int $minimum, int $maximum): int { 'untyped catalog' => fn() => new BattlePresentationCatalog(['encounter' => []], [], []), ]); -it('rejects missing negotiated battle capabilities before scene configuration or entry effects', function () { +it('keeps battle configuration playable when graphical capabilities are unavailable', function () { $root = sys_get_temp_dir() . '/ichiloto-battle-startup-' . bin2hex(random_bytes(5)); - mkdir($root . '/Data', 0777, true); - copy(__DIR__ . '/../Fixtures/BattlePresentation/catalog.php', $root . '/Data/battle-presentation.php'); + $file = $root . '/' . BattlePresentationCatalog::FILE; + mkdir(dirname($file), 0777, true); + copy(__DIR__ . '/../Fixtures/BattlePresentation/catalog.php', $file); foreach (['hero', 'twin'] as $name) { copy($this->root . '/graphical-canvas/synthetic-143x181.png', $root . '/' . $name . '.png'); } copy($this->root . '/graphical-canvas/synthetic-320x180.png', $root . '/arena.png'); - $game = new class extends Ichiloto\Engine\Core\Game { - public function __construct() {} - public function __destruct() {} - }; - $game->useRendererRuntime(new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), $root), new FakeRendererTransport())); - $scene = new class($game) extends BattleScene { - public function __construct(private Ichiloto\Engine\Core\Game $testGame) {} - public function getGame(): Ichiloto\Engine\Core\Game { return $this->testGame; } - }; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), $root), new FakeRendererTransport()); + $scene = graphicalBattleConfigurationScene($runtime); try { [$battle] = graphicalBattleFixture(); - expect(fn() => $scene->configure($battle))->toThrow(RuntimeException::class, 'negotiated graphical_canvas'); - expect($battle->entryRulesEvaluated())->toBeFalse() - ->and($scene->config)->toBeNull()->and($scene->graphicalPresentation)->toBeNull(); + $scene->configure($battle); + expect($battle->entryRulesEvaluated())->toBeTrue() + ->and($scene->config)->toBe($battle)->and($scene->graphicalPresentation)->toBeNull() + ->and($scene->battleUiLayout)->toBeNull() + ->and(file_get_contents($this->logRoot . '/error.log'))->toContain('negotiated graphical_canvas'); } finally { - foreach (['hero.png', 'twin.png', 'arena.png', 'Data/battle-presentation.php'] as $file) { unlink($root . '/' . $file); } - rmdir($root . '/Data'); rmdir($root); + $runtime->shutdown(); + foreach (['hero.png', 'twin.png', 'arena.png', BattlePresentationCatalog::FILE] as $asset) { unlink($root . '/' . $asset); } + rmdir(dirname($file)); rmdir($root . '/Data'); rmdir($root); } }); +it('configures shared UI for consecutive encounters while terminal ignores optional assets', function (bool $native, bool $results) { + $root = sys_get_temp_dir() . '/ichiloto-shared-ui-' . bin2hex(random_bytes(5)); + $file = $root . '/' . BattlePresentationCatalog::FILE; + mkdir(dirname($file), 0777, true); + copy(__DIR__ . '/../Fixtures/BattlePresentation/shared-ui.php', $file); + if ($results) { + $code = <<<'PHP' + new CanvasNineSlice($id . '.png', new SpriteSourceRect(0, 0, 2048, 2048))]; +} +return new BattlePresentationCatalog([], [], [], ui: $catalog->ui, results: new BattleResultsSkin( + array_fill_keys(['panel', 'quiet', 'track', 'selector', 'portrait', 'exp', 'divider', 'button'], $catalog->ui->skin->textures['panel']), + array_fill_keys(['text', 'muted', 'accent', 'positive', 'negative', 'ink'], $catalog->ui->skin->colors['text']), $portraits)); +PHP; + file_put_contents($file, str_replace('__FIXTURE__', + var_export(__DIR__ . '/../Fixtures/BattlePresentation/shared-ui.php', true), $code)); + if ($native) { + // The catalog exceeds 64 MiB, but each event displays only one bust. Header checks only. + foreach (['Hero', 'Second', 'Third', 'Fourth'] as $id) { + file_put_contents($root . '/' . $id . '.png', "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', 2048, 2048)); + } + } + } + if ($native) { copy($this->root . '/test-sprite.png', $root . '/skin.png'); } + $runtime = null; + try { + if ($native) { + $transport = new FakeRendererTransport(); + $transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"ready","capabilities":["graphical_canvas","sprite_source_rect","canvas_clip_opacity","canvas_glyph_effects"]}')]; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), $root, + requiredCapabilities: ['graphical_canvas', 'sprite_source_rect', 'canvas_clip_opacity', 'canvas_glyph_effects']), $transport); + $runtime->start('Shared UI', 135, 36); + } + $scene = graphicalBattleConfigurationScene($runtime); + foreach (['Twins', 'Another encounter'] as $name) { + [$fixture] = graphicalBattleFixture(); + $battle = new BattleConfig($fixture->party, new Troop($name, $fixture->troop->members->toArray())); + $scene->configure($battle); + expect($scene->config)->toBe($battle) + ->and($battle->entryRulesEvaluated())->toBeTrue() + ->and($scene->graphicalPresentation)->toBeNull() + ->and($scene->battleUiLayout !== null)->toBe($native) + ->and($scene->resultsSkin !== null)->toBe($native && $results) + ->and($scene->getPresentationCanvas())->toBeNull(); // Start transition still owns its frame. + } + } finally { + $runtime?->shutdown(); + if ($native) { unlink($root . '/skin.png'); } + if ($native && $results) { + foreach (['Hero', 'Second', 'Third', 'Fourth'] as $id) { unlink($root . '/' . $id . '.png'); } + } + unlink($file); rmdir(dirname($file)); rmdir($root . '/Data'); rmdir($root); + } +})->with([true, false])->with([true, false]); + +it('logs unusable shared UI assets or capabilities and still starts combat', function (?string $missing) { + $root = sys_get_temp_dir() . '/ichiloto-shared-ui-invalid-' . bin2hex(random_bytes(5)); + $file = $root . '/' . BattlePresentationCatalog::FILE; + mkdir(dirname($file), 0777, true); + copy(__DIR__ . '/../Fixtures/BattlePresentation/shared-ui.php', $file); + if ($missing !== null) { copy($this->root . '/test-sprite.png', $root . '/skin.png'); } + $capabilities = array_values(array_diff(['graphical_canvas', 'sprite_source_rect', 'canvas_clip_opacity', 'canvas_glyph_effects'], [$missing])); + if ($missing === 'graphical_canvas') { $capabilities = ['sprite_source_rect']; } + $transport = new FakeRendererTransport(); + $transport->batches[] = [RendererEvent::fromJson(json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => $capabilities]))]; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), $root, + requiredCapabilities: $capabilities), $transport); + try { + $runtime->start('Invalid shared UI', 135, 36); + $scene = graphicalBattleConfigurationScene($runtime); + [$battle] = graphicalBattleFixture(); + $scene->configure($battle); + expect($scene->config)->toBe($battle)->and($scene->battleUiLayout)->toBeNull() + ->and($scene->graphicalPresentation)->toBeNull()->and($battle->entryRulesEvaluated())->toBeTrue() + ->and(file_get_contents($this->logRoot . '/error.log'))->toContain( + 'Graphical battle presentation degraded', $missing === null ? 'readable PNG' : 'negotiated ' . $missing); + } finally { + $runtime->shutdown(); + if ($missing !== null) { unlink($root . '/skin.png'); } + unlink($file); rmdir(dirname($file)); rmdir($root . '/Data'); rmdir($root); + } +})->with([null, 'graphical_canvas', 'sprite_source_rect', 'canvas_clip_opacity', 'canvas_glyph_effects']); + it('requires crop capability and retains party KO feedback without changing health', function () { [$battle, $catalog, $hero] = graphicalBattleFixture(); $art = new BattlerArtwork('graphical-canvas/synthetic-143x181.png', 143, 181, 71.5, 181, new SpriteSourceRect(0, 0, 143, 181)); @@ -430,3 +833,82 @@ public function getGame(): Ichiloto\Engine\Core\Game { return $this->testGame; } $scene->ui->fieldWindow->clearStatChangePopups(); expect(array_map(fn($run) => $run->text, $presentation->frame($scene->ui->fieldWindow, $ui)->textLayers[0]->runs))->toBe(['Hero']); })->with([['50', Color::LIGHT_RED], ['+50', Color::LIGHT_GREEN]]); + +it('reconciles authored battler metadata with the artwork on disk', function () { + // Fits: the authored instance passes through untouched. + $fits = new BattlerArtwork('a.png', 100, 120, 50, 120, new SpriteSourceRect(10, 10, 100, 120)); + expect($fits->clampedTo(200, 200))->toBe($fits); + + // The image shrank under the crop: crop and pivot clamp to what exists + // now, because artwork changes throughout development and the image on + // disk is this moment's truth. + $clamped = $fits->clampedTo(60, 70); + expect($clamped->sourceRect->toArray())->toBe(['x' => 10, 'y' => 10, 'width' => 50, 'height' => 60]) + ->and([$clamped->width, $clamped->height])->toBe([50, 60]) + ->and([$clamped->pivotX, $clamped->pivotY])->toBe([50.0, 60.0]); + + // The crop no longer exists at all: fall back to the whole image, so + // the developer always sees their art. + $fallback = $fits->clampedTo(10, 10); + expect($fallback->sourceRect->toArray())->toBe(['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10]) + ->and([$fallback->pivotX, $fallback->pivotY])->toBe([10.0, 10.0]); + + // Whole-image artwork (no authored crop) reconciles the same way. + $whole = new BattlerArtwork('a.png', 100, 120, 50, 100); + $shrunk = $whole->clampedTo(80, 90); + expect($shrunk->sourceRect->toArray())->toBe(['x' => 0, 'y' => 0, 'width' => 80, 'height' => 90]) + ->and([$shrunk->pivotX, $shrunk->pivotY])->toBe([50.0, 90.0]); +}); + +it('prepares a best-effort graphical battle when artwork changed under its authored crop', function () { + [$battle, $catalog] = graphicalBattleFixture(); + // The catalog still describes a 200x260 crop, but the PNG on disk is + // 143x181: the battle must prepare and render with the clamped crop. + $stale = new BattlerArtwork('graphical-canvas/synthetic-143x181.png', 200, 260, 100.0, 260.0, + new SpriteSourceRect(0, 0, 200, 260)); + $catalog = new BattlePresentationCatalog($catalog->arenas, ['Hero' => $stale], $catalog->enemies); + $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); + $hero = $presentation->frame()->images[1]; + expect(str_starts_with($hero->id, 'combatant-'))->toBeTrue() + ->and($hero->sourceRect->toArray())->toBe(['x' => 0, 'y' => 0, 'width' => 143, 'height' => 181]); +}); + +it('renders the whole image when the authored crop no longer exists in it', function () { + [$battle, $catalog] = graphicalBattleFixture(); + $gone = new BattlerArtwork('graphical-canvas/synthetic-143x181.png', 50, 50, 25, 50, + new SpriteSourceRect(150, 0, 50, 50)); + $catalog = new BattlePresentationCatalog($catalog->arenas, ['Hero' => $gone], $catalog->enemies); + $presentation = GraphicalBattlePresentation::prepare($battle, $catalog, $this->root); + $hero = $presentation->frame()->images[1]; + expect($hero->sourceRect->toArray())->toBe(['x' => 0, 'y' => 0, 'width' => 143, 'height' => 181]); +}); + +it('accepts replacement images at the same asset path without changing character identity or catalog', function () { + $root = sys_get_temp_dir() . '/ichiloto-replaceable-battler-' . bin2hex(random_bytes(5)); + mkdir($root); + copy($this->root . '/graphical-canvas/synthetic-320x180.png', $root . '/arena.png'); + copy($this->root . '/graphical-canvas/synthetic-143x181.png', $root . '/twin.png'); + [$battle, , $hero] = graphicalBattleFixture(); + $catalog = require __DIR__ . '/../Fixtures/BattlePresentation/catalog.php'; + $authored = $catalog->actors['Hero']; + $health = $hero->stats->currentHp; + try { + foreach (['graphical-canvas/synthetic-143x181.png', 'test-sprite.png', + 'graphical-canvas/synthetic-320x180.png'] as $replacement) { + copy($this->root . '/' . $replacement, $root . '/hero.png'); + $frame = GraphicalBattlePresentation::prepare($battle, $catalog, $root)->frame(); + CanvasImagePreflight::inspect($frame->images, $root); + $image = $frame->images[1]; + $image->destination->assertWithin($frame->width, $frame->height); + expect($image->asset)->toBe('hero.png') + ->and($image->id)->toBe('combatant-' . spl_object_id($hero)) + ->and($catalog->actors['Hero'])->toBe($authored) + ->and($hero->actorId)->toBe('Hero') + ->and($hero->stats->currentHp)->toBe($health) + ->and($battle->entryRulesEvaluated())->toBeFalse(); + } + } finally { + foreach (['hero.png', 'twin.png', 'arena.png'] as $asset) { unlink($root . '/' . $asset); } + rmdir($root); + } +}); diff --git a/tests/Unit/GraphicalBattleResultsTest.php b/tests/Unit/GraphicalBattleResultsTest.php new file mode 100644 index 00000000..77e76846 --- /dev/null +++ b/tests/Unit/GraphicalBattleResultsTest.php @@ -0,0 +1,396 @@ + 0, 2 => 100, 3 => 300]; + $name = $long ? str_repeat('Character Name ', 12) . $index : 'Hero ' . $index; + $before = new ProgressionSnapshot('actor-' . $index, $name, 1, 0, 3, $thresholds, ['maxHp' => 100]); + $after = new ProgressionSnapshot('actor-' . $index, $name, 2, 150, 3, $thresholds, ['maxHp' => 110]); + $progression[] = new BattleProgression(150, $before, $after, + [['name' => 'Cure', 'kind' => 'Magic', 'cost' => 7, 'description' => str_repeat('Healing light. ', $long ? 100 : 1)]]); + } + for ($i = 0; $i < $drops; $i++) { + $items[] = ['id' => 'item-' . $i, 'name' => ($long ? str_repeat('Long item ', 20) : 'Item ') . $i, + 'description' => '', 'quantity' => $i + 1, 'received' => $i]; + } + return new BattleRewards(150, 123, $progression, $items, + ['Battle time' => '00:43', 'Enemies defeated' => '2'], [['title' => 'Actual seal', 'description' => 'Actual supplied reward']]); +} + +function resultsBattlefield(): PresentationCanvas +{ + return new PresentationCanvas(1350, 720, [new CanvasImage('real-final-frame', 'arena.png', + new CanvasRectangle(0, 0, 1350, 720))]); +} + +function resultsFrameText(PresentationCanvas $frame): string +{ + return implode("\n", array_merge(...array_map(fn($layer) => array_column($layer->runs, 'text'), $frame->textLayers))); +} + +it('centers each confirmation label on its button without a list cursor', function () { + $p = new BattleResultsPlayback(new BattleRewards(0, 0, [])); + $p->update(0.5); + foreach (['Complete', 'Continue'] as $label) { + $p->update(0.33); + $frame = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + $text = array_column($frame->textLayers, null, 'id')['results-confirm']; + expect($text->runs[0]->text)->toBe($label) + ->and($text->bounds->x + $text->bounds->width / 2)->toBe(675.0) + ->and($text->bounds->y + $text->bounds->height / 2)->toBe(685.0) + ->and(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'results-selector-')))->toBe([]); + $p->confirm(); + } +}); + +it('fades the complete button assembly with an empty beat and a centered incoming label', function () { + $p = new BattleResultsPlayback(new BattleRewards(0, 0, [])); + $p->update(0.5); + $p->confirm(); + foreach ([[0.06, 'Complete', 0.5], [0.09, null, 0.0], [0.10, 'Continue', 0.5], [0.08, 'Continue', 1.0]] as [$delta, $label, $alpha]) { + $p->update($delta); + $frame = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + $images = array_filter($frame->images, fn($image) => str_starts_with($image->id, 'results-confirm-')); + expect(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'results-selector-')))->toBe([]); + $text = array_column($frame->textLayers, null, 'id')['results-confirm'] ?? null; + if ($label === null) { + expect($images)->toBe([])->and($text)->toBeNull(); + } else { + expect($images)->not->toBeEmpty(); + foreach ($images as $image) { expect($image->opacity)->toEqualWithDelta($alpha, 0.000001); } + expect($text->runs[0]->text)->toBe($label) + ->and($text->opacity)->toEqualWithDelta($alpha, 0.000001) + ->and($text->bounds->x + $text->bounds->width / 2)->toBe(675.0); + } + expect(resultsFrameText($frame))->toContain('VICTORY', 'REWARDS'); + } +}); + +it('keeps completed confirmation artwork steady without a cursor in either motion mode', function (bool $reducedMotion) { + $p = new BattleResultsPlayback(new BattleRewards(0, 0, []), $reducedMotion); + $p->update(10); + $field = resultsBattlefield(); + $skin = resultsSkinFixture(); + $first = GraphicalBattleResults::frame($field, $skin, $p); + $p->update(0.75); + $next = GraphicalBattleResults::frame($field, $skin, $p); + $selector = fn($frame) => array_values(array_filter($frame->images, + fn($image) => str_starts_with($image->id, 'results-selector-'))); + $button = fn($frame) => array_values(array_filter($frame->images, + fn($image) => str_starts_with($image->id, 'results-confirm-'))); + expect($selector($first))->toBe([])->and($selector($next))->toBe([]) + ->and($button($first))->not->toBeEmpty()->toEqual($button($next)) + ->and($first->toArray())->toBe($next->toArray()) + ->and($p->confirmation())->toBe(['label' => 'Continue', 'opacity' => 1.0, 'enabled' => true]); +})->with([false, true]); + +it('keeps the completed action label unchanged throughout the locked exit fade', function (string $kind) { + $award = resultsGraphicalFacts(1)->progression[0]; + $facts = match ($kind) { + 'primary' => new BattleRewards(0, 0, []), + 'level' => new BattleRewards(150, 0, [new BattleProgression(150, $award->before, $award->after)]), + 'ability' => new BattleRewards(150, 0, [$award]), + 'special' => new BattleRewards(0, 0, [], specialRewards: [['title' => 'Seal', 'description' => 'A keepsake.']]), + }; + $p = new BattleResultsPlayback($facts); + while ($p->currentStage()['kind'] !== $kind) { + $p->update(10); + $p->confirm(); + } + $p->update(10); + $field = resultsBattlefield(); + $skin = resultsSkinFixture(); + $before = array_column(GraphicalBattleResults::frame($field, $skin, $p)->textLayers, null, 'id')['results-confirm']; + expect($p->confirm())->toBeFalse()->and($p->isExiting())->toBeTrue(); + foreach ([0, 0.14, 0.14, 0.13] as $delta) { + $p->update($delta); + $frame = GraphicalBattleResults::frame($field, $skin, $p); + $label = array_column($frame->textLayers, null, 'id')['results-confirm']; + expect($label->runs[0]->text)->toBe('Continue') + ->and($label->bounds)->toEqual($before->bounds) + ->and($label->opacity)->toBe($p->opacity()) + ->and($p->confirm())->toBeFalse() + ->and($p->currentStage()['kind'])->toBe($kind) + ->and($p->rewards)->toBe($facts) + ->and(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'results-selector-')))->toBe([]); + } + $p->update(0.02); + expect($p->isFinished())->toBeTrue() + ->and(GraphicalBattleResults::frame($field, $skin, $p))->toBe($field); +})->with(['primary', 'level', 'ability', 'special']); + +it('keeps outside-panel hints readable and fits all nine level-up stats on one ordinary page', function () { + $stats = array_fill_keys(['maxHp', 'maxMp', 'attack', 'defence', 'magicAttack', 'magicDefence', 'speed', 'grace', 'evasion'], 5); + $before = new ProgressionSnapshot('hero', 'Hero', 1, 0, 3, [1 => 0, 2 => 100, 3 => 300], $stats); + $after = new ProgressionSnapshot('hero', 'Hero', 2, 150, 3, [1 => 0, 2 => 100, 3 => 300], $stats); + $playback = new BattleResultsPlayback(new BattleRewards(150, 0, [new BattleProgression(150, $before, $after)]), true); + $playback->confirm(); + $skin = resultsSkinFixture(); + $frame = GraphicalBattleResults::frame(resultsBattlefield(), $skin, $playback); + expect($playback->pageCount())->toBe(1)->and(resultsFrameText($frame))->toContain('Evasion', "1 \u{2192} 2", "5 \u{2192} 5") + ->not->toContain('->'); + $counter = array_column($frame->textLayers, null, 'id')['results-event-counter']; + expect($counter->runs[0]->background)->toBe($skin->colors['ink']); + + $overflow = new BattleResultsPlayback(resultsGraphicalFacts(drops: 12), true); + $frame = GraphicalBattleResults::frame(resultsBattlefield(), $skin, $overflow); + $pages = array_column($frame->textLayers, null, 'id')['results-pages']; + expect($pages->runs[0]->background)->toBe($skin->colors['ink']); +}); + +it('preserves the real battlefield and approved primary geometry with independent values and neutral portraits', function () { + $field = resultsBattlefield(); + $p = new BattleResultsPlayback(resultsGraphicalFacts(drops: 2), true); + $frame = GraphicalBattleResults::frame($field, resultsSkinFixture(), $p); + $images = array_column($frame->images, null, 'id'); + expect($frame->images[0])->toBe($field->images[0]) + ->and($images['results-party-1-1']->destination)->toEqual(new CanvasRectangle(52, 146, 754, 502)) + ->and($images['results-rewards-1-1']->destination)->toEqual(new CanvasRectangle(828, 146, 470, 324)) + ->and($images['results-summary-1-1']->destination)->toEqual(new CanvasRectangle(828, 484, 470, 164)) + ->and(resultsFrameText($frame))->toContain('EXP / MEMBER', '150', '123', 'H1', 'Retained: 0 of 1') + ->and(count($frame->textLayers))->toBeLessThanOrEqual(64); + foreach (['experience', 'gold'] as $key) { + $value = array_column($frame->textLayers, null, 'id')['results-' . $key . '-value']; + expect($value->runs[0]->column + mb_strlen($value->runs[0]->text))->toBe($value->grid->columns); + } +}); + +it('omits zero EXP fills then clips full-sized strips without compressing their source art', function () { + $p = new BattleResultsPlayback(resultsGraphicalFacts(1)); + $p->update(0.8); + $zero = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + expect(array_filter($zero->images, fn($image) => str_contains($image->id, '-fill')))->toBe([]); + $p->update(2); + $full = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + $fill = array_values(array_filter($full->images, fn($image) => str_contains($image->id, '-fill')))[0]; + expect($fill->destination->width)->toBe(578.0)->and($fill->clipRect->width)->toBe(144.5) + ->and($fill->sourceRect)->toEqual(new SpriteSourceRect(0, 0, 8, 8)); +}); + +it('pages reserves, long names, item overflow and all event details within canvas limits', function () { + $facts = resultsGraphicalFacts(8, 7, true); + $p = new BattleResultsPlayback($facts, true); + $all = ''; + foreach (range(0, BattleResultsContent::pageCount($p) - 1) as $_) { + $frame = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + $all .= resultsFrameText($frame); + expect(count($frame->textLayers))->toBeLessThanOrEqual(64); + foreach ($frame->textLayers as $layer) { + $layer->paintBounds->assertWithin(1350, 720); + foreach ($layer->runs as $run) { $run->assertFits($layer->grid->columns, $layer->grid->rows); } + } + $p->navigate(1); + } + expect($all)->toContain('NAME CONTINUED', 'Retained: 6 of 7')->and($p->rewards)->toBe($facts); + $p->confirm(); + $level = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + expect(resultsFrameText($level))->toContain('LEVEL UP', '110'); + $p->update(0.1); + $p->confirm(); + $descriptions = ''; + foreach (range(0, BattleResultsContent::pageCount($p) - 1) as $_) { + $descriptions .= resultsFrameText(GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p)); + $p->navigate(1); + } + expect($descriptions)->toContain('Cost: 7 MP')->and(substr_count($descriptions, 'Healing light.'))->toBeGreaterThanOrEqual(100); +}); + +it('composes explicit special rewards without inventing rarity and clears overlay on finished exit', function () { + $p = new BattleResultsPlayback(new BattleRewards(0, 0, [], specialRewards: [ + ['title' => 'Bronze seal', 'description' => 'A keepsake.'], + ]), true); + $p->confirm(); + $field = resultsBattlefield(); + $frame = GraphicalBattleResults::frame($field, resultsSkinFixture(), $p); + expect(resultsFrameText($frame))->toContain('SPECIAL REWARD', 'Bronze seal', 'A keepsake.') + ->not->toContain('RARE', 'LEGENDARY'); + $p->update(0.1); + $p->confirm(); + expect(GraphicalBattleResults::frame($field, resultsSkinFixture(), $p))->toBe($field); +}); + +it('contains separate menu and bust artwork without enlarging a menu portrait into an event bust', function () { + $menu = new CanvasNineSlice('menu.png', new SpriteSourceRect(0, 0, 100, 100)); + $skin = resultsSkinFixture(['actor-1' => ['menu' => $menu]]); + $p = new BattleResultsPlayback(resultsGraphicalFacts(1), true); + $primary = GraphicalBattleResults::frame(resultsBattlefield(), $skin, $p); + expect(array_filter($primary->images, fn($image) => $image->asset === 'menu.png'))->toHaveCount(1); + $p->confirm(); + $event = GraphicalBattleResults::frame(resultsBattlefield(), $skin, $p); + expect(array_filter($event->images, fn($image) => $image->asset === 'menu.png'))->toBe([]) + ->and(resultsFrameText($event))->toContain('H1'); +}); + +it('validates asset presence, source bounds, unique decoded costs and one-density roles', function () { + $root = sys_get_temp_dir() . '/ichiloto-results-test-' . bin2hex(random_bytes(4)); + mkdir($root); + $skin = resultsSkinFixture(); + try { + foreach ($skin->textures as $texture) { + file_put_contents($root . '/' . $texture->asset, "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', 8, 8)); + } + $costs = GraphicalBattleResults::preflight($skin, $root); + expect($costs)->toHaveCount(8)->and(array_sum($costs))->toBe(8 * 8 * 4 * 8); + foreach ($skin->textures as $texture) { + file_put_contents($root . '/' . $texture->asset, "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', 4096, 4096)); + } + expect(fn() => GraphicalBattleResults::preflight($skin, $root))->toThrow(RuntimeException::class, '64 MiB'); + foreach ($skin->textures as $texture) { + file_put_contents($root . '/' . $texture->asset, "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', 8, 8)); + } + $portrait = new CanvasNineSlice('panel.png', new SpriteSourceRect(1, 0, 7, 8)); + $prepared = GraphicalBattleResults::prepare(resultsSkinFixture(['actor-1' => ['menu' => $portrait]]), $root); + expect($prepared->portraits['actor-1']['menu']->source->toArray()) + ->toBe(['x' => 0, 'y' => 0, 'width' => 8, 'height' => 8]); + unlink($root . '/exp.png'); + expect(fn() => GraphicalBattleResults::preflight($skin, $root))->toThrow(RuntimeException::class); + $textures = $skin->textures; + $textures['exp'] = new CanvasNineSlice('exp.png', new SpriteSourceRect(0, 0, 8, 8), density: 2); + expect(fn() => new BattleResultsSkin($textures, $skin->colors))->toThrow(InvalidArgumentException::class); + } finally { + foreach (glob($root . '/*') ?: [] as $path) { unlink($path); } + rmdir($root); + } +}); + +it('contains current portrait and icon images after same-path replacements without changing rewards', function () { + $root = sys_get_temp_dir() . '/ichiloto-results-replacement-' . bin2hex(random_bytes(4)); + mkdir($root); + $legacy = new CanvasNineSlice('actor.png', new SpriteSourceRect(0, 0, 100, 120)); + $skin = resultsSkinFixture(['actor-1' => ['menu' => $legacy, 'bust' => $legacy]], ['inventory' => $legacy]); + $facts = resultsGraphicalFacts(1); + $before = serialize($facts); + try { + foreach ($skin->textures as $texture) { + file_put_contents($root . '/' . $texture->asset, "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', 8, 8)); + } + foreach ([[40, 80], [200, 60], [100, 120]] as [$width, $height]) { + file_put_contents($root . '/actor.png', "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', $width, $height)); + $prepared = GraphicalBattleResults::prepare($skin, $root); + expect($prepared->icons['inventory']->source->toArray()) + ->toBe(['x' => 0, 'y' => 0, 'width' => $width, 'height' => $height]); + $playback = new BattleResultsPlayback($facts, true); + foreach (['menu', 'bust'] as $family) { + $frame = GraphicalBattleResults::frame(new PresentationCanvas(1350, 720), $prepared, $playback); + CanvasImagePreflight::inspect($frame->images, $root); + $image = array_values(array_filter($frame->images, fn($image) => $image->asset === 'actor.png'))[0]; + expect($image->sourceRect)->toEqual($prepared->portraits['actor-1'][$family]->source) + ->and($image->destination->width / $image->destination->height)->toEqualWithDelta($width / $height, 0.000001); + $playback->confirm(); + } + expect(serialize($facts))->toBe($before)->and($skin->portraits['actor-1']['menu'])->toBe($legacy); + } + } finally { + foreach (glob($root . '/*') ?: [] as $path) { unlink($path); } + rmdir($root); + } +}); + +it('renders signed 64-bit totals without truncation or overlap', function () { + $p = new BattleResultsPlayback(new BattleRewards(PHP_INT_MAX, PHP_INT_MAX, [], [ + ['id' => 'x', 'name' => 'Elixir', 'quantity' => PHP_INT_MAX, 'description' => ''], + ]), true); + $frame = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), $p); + expect(resultsFrameText($frame))->toContain((string)PHP_INT_MAX, 'x' . PHP_INT_MAX); +}); + +it('validates every portrait but budgets Primary pages and individual busts separately over the battlefield', function () { + $root = sys_get_temp_dir() . '/ichiloto-results-stages-' . bin2hex(random_bytes(4)); + mkdir($root); + $png = static function (string $name, int $width, int $height) use ($root): void { + file_put_contents($root . '/' . $name, "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" . pack('NN', $width, $height)); + }; + try { + foreach (resultsSkinFixture()->textures as $texture) { $png($texture->asset, 8, 8); } + $png('arena.png', 3072, 3072); + $portraits = []; + foreach (range(1, 6) as $i) { + $png('menu-' . $i . '.png', 512, 512); + $png('bust-' . $i . '.png', 2048, 2048); + $portraits['actor-' . $i] = [ + 'menu' => new CanvasNineSlice('menu-' . $i . '.png', new SpriteSourceRect(0, 0, 512, 512)), + 'bust' => new CanvasNineSlice('bust-' . $i . '.png', new SpriteSourceRect(0, 0, 2048, 2048)), + ]; + } + $skin = resultsSkinFixture($portraits); + $costs = GraphicalBattleResults::preflight($skin, $root, resultsBattlefield()->images); + expect(array_sum($costs))->toBeGreaterThan(67108864); + + // Missing art is still a catalog error even when that actor is not currently in the party. + unlink($root . '/bust-6.png'); + expect(fn() => GraphicalBattleResults::preflight($skin, $root, [], ['actor-1'])) + ->toThrow(RuntimeException::class, 'readable PNG'); + $png('bust-6.png', 2048, 2048); + + // These actors can share a Primary page when continued names shift the page boundary. + foreach ([4, 5] as $i) { + $png('menu-' . $i . '.png', 3072, 3072); + $portraits['actor-' . $i]['menu'] = new CanvasNineSlice('menu-' . $i . '.png', new SpriteSourceRect(0, 0, 3072, 3072)); + } + $skin = resultsSkinFixture($portraits); + expect(fn() => GraphicalBattleResults::preflight($skin, $root))->toThrow(RuntimeException::class, '64 MiB'); + expect(GraphicalBattleResults::preflight($skin, $root, [], ['actor-1']))->toHaveCount(20); + } finally { + foreach (glob($root . '/*') ?: [] as $path) { unlink($path); } + rmdir($root); + } +}); + +it('keeps multiline party names separate from gains at ordinary and 64-bit widths', function () { + foreach ([150, PHP_INT_MAX] as $gain) { + $award = resultsGraphicalFacts(1, long: true)->progression[0]; + $facts = new BattleRewards($gain, 0, [new BattleProgression($gain, $award->before, $award->after)]); + $frame = GraphicalBattleResults::frame(resultsBattlefield(), resultsSkinFixture(), new BattleResultsPlayback($facts, true)); + $layers = array_column($frame->textLayers, null, 'id'); + $name = $layers['results-party-name-0-1']->bounds; + $number = $layers['results-party-gain-0']->bounds; + expect($name->x + $name->width)->toBeLessThanOrEqual($number->x) + ->and(resultsFrameText($frame))->toContain('+' . $gain); + } +}); + +it('uses admitted semantic item and skill icons rather than falling back for known categories', function () { + $icon = fn(string $name) => new CanvasNineSlice($name . '.png', new SpriteSourceRect(0, 0, 8, 8)); + $skin = resultsSkinFixture(icons: ['item' => $icon('item'), 'skill' => $icon('skill'), 'unknown' => $icon('unknown')]); + $p = new BattleResultsPlayback(resultsGraphicalFacts(1), true); + $assets = []; + while (!$p->isFinished()) { + $frame = GraphicalBattleResults::frame(resultsBattlefield(), $skin, $p); + array_push($assets, ...array_column($frame->images, 'asset')); + $p->update(0.1); + $p->confirm(); + } + expect($assets)->toContain('skill.png', 'item.png')->not->toContain('unknown.png'); +}); diff --git a/tests/Unit/InputBindingsTest.php b/tests/Unit/InputBindingsTest.php index 22983fa4..927a8545 100644 --- a/tests/Unit/InputBindingsTest.php +++ b/tests/Unit/InputBindingsTest.php @@ -10,6 +10,19 @@ require_once __DIR__ . '/../Support/Input/FakeInputSource.php'; +beforeEach(function () { + $this->saved = []; + foreach ([InputManager::class, ConfigStore::class] as $class) { + $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); + } +}); + +afterEach(function () { + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } +}); + /** * An InputConfig that loads from memory and records what it wrote, so the * rebinding path can be exercised without touching a project. @@ -66,9 +79,10 @@ function demoBindings(): array // Back is deliberately absent: escape is how every screen is left, // including the rebinding screen. - expect($actions)->toBe(['action', 'up']) + expect($actions)->toBe(['action', 'up', 'info']) ->and($bindings->describeKeys('action'))->toBe('SPACE, ENTER') - ->and($bindings->describeKeys('up'))->toBe('UP, W'); + ->and($bindings->describeKeys('up'))->toBe('UP, W') + ->and($bindings->describeKeys('info'))->toBe('i, I'); }); it('rebinds an action immediately and writes it back', function () { @@ -139,3 +153,44 @@ function demoBindings(): array expect(new InputBindings()->describeKeys('dance'))->toBe('Unbound'); }); + +it('selects one currently bound primary control while Controls retains every alias', function () { + installBindings([ + 'confirm' => ['keys' => [KeyCode::SPACE, KeyCode::ENTER]], + 'cancel' => ['keys' => [KeyCode::C, KeyCode::c, KeyCode::ESCAPE]], + 'back' => ['keys' => [KeyCode::END, KeyCode::ESCAPE]], + ]); + $bindings = new InputBindings(); + expect($bindings->primaryKey('confirm'))->toBe(KeyCode::ENTER) + ->and($bindings->primaryKey('cancel'))->toBe(KeyCode::ESCAPE) + ->and($bindings->primaryKey('back'))->toBe(KeyCode::ESCAPE) + ->and($bindings->describeKeys('cancel'))->toBe('C, c, ESCAPE') + ->and($bindings->describeKeys('confirm'))->toBe('SPACE, ENTER') + ->and($bindings->controlForAction('cancel')->label)->toBe('Escape'); + $bindings->rebind('cancel', KeyCode::Q); + expect($bindings->primaryKey('cancel'))->toBe(KeyCode::Q) + ->and($bindings->controlForAction('cancel')->control)->toBe('Q') + ->and($bindings->describeKeys('cancel'))->toBe('Q'); +}); + +it('does not invent preferred controls for empty missing or differently bound actions', function () { + installBindings(['confirm' => ['keys' => [KeyCode::F2, KeyCode::SPACE]], 'cancel' => ['keys' => []]]); + $bindings = new InputBindings(); + expect($bindings->primaryKey('confirm'))->toBe(KeyCode::F2) + ->and($bindings->primaryKey('cancel'))->toBeNull()->and($bindings->primaryKey('back'))->toBeNull() + ->and($bindings->controlForAction('cancel'))->toBeNull()->and($bindings->describeKeys('cancel'))->toBe('Unbound'); +}); + +it('discovers a fully conflicted Info default as unbound and permits an explicit rebind', function () { + $authored = ['custom' => ['description' => 'Custom action.', 'keys' => [KeyCode::i, KeyCode::I]]]; + $config = installBindings($authored); + $bindings = new InputBindings(); + expect(array_column($bindings->all(), 'action'))->toBe(['custom', 'info']) + ->and($bindings->describeKeys('info'))->toBe('Unbound') + ->and($bindings->controlForAction('info'))->toBeNull() + ->and($config->all())->toBe($authored)->and($config->written)->toBeEmpty(); + expect($bindings->rebind('info', KeyCode::F2))->toBeTrue() + ->and($bindings->describeKeys('info'))->toBe('F2') + ->and(InputManager::getBindings()['custom'])->toBe($authored['custom']) + ->and($config->written['info']['keys'])->toBe([KeyCode::F2]); +}); diff --git a/tests/Unit/InputLatencyTest.php b/tests/Unit/InputLatencyTest.php index d3b26c54..9922282f 100644 --- a/tests/Unit/InputLatencyTest.php +++ b/tests/Unit/InputLatencyTest.php @@ -141,6 +141,6 @@ function latencyKeys(array $keys): array LatencyTrace::configure(null, fn() => throw new LogicException('disabled clock')); LatencyTrace::beginIteration(); LatencyTrace::record('ignored'); - expect(LatencyTrace::now())->toBeNull(); + expect(LatencyTrace::getTimeNow())->toBeNull(); LatencyTrace::endIteration(); }); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 3b64b329..3691cbc4 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -1,8 +1,5 @@ oldSource); - InputManager::setBindings($this->oldBindings); + new ReflectionProperty(InputManager::class, 'config')->setValue(null, $this->oldBindings); new ReflectionProperty(InputManager::class, 'defaultConfig')->setValue(null, $this->oldDefaults); new ReflectionProperty(InputManager::class, 'eventManager')->setValue(null, $this->oldEventManager); }); @@ -128,7 +127,9 @@ function inputCompatibilityStream(string $bytes) $oldConfig = ConfigStore::has(InputConfig::class) ? ConfigStore::get(InputConfig::class) : null; $oldEventSingleton = new ReflectionProperty(EventManager::class, 'instance')->getValue(); $config = new class extends InputConfig { + public int $writes = 0; protected function load(): array { return ['up' => ['keys' => [KeyCode::W]]]; } + public function persist(): void { $this->writes++; } }; try { ConfigStore::put(InputConfig::class, $config); @@ -136,9 +137,18 @@ protected function load(): array { return ['up' => ['keys' => [KeyCode::W]]]; } public function __construct() {} public function __destruct() {} }); + $effective = InputManager::getBindings(); expect(InputManager::getInputSource())->toBe($source) - ->and(InputManager::getBindings())->toBe($config->all()) - ->and(InputManager::getDefaultBindings())->toBe($config->all()); + ->and($effective['up'])->toBe($config->all()['up']) + ->and($effective['info']['keys'])->toBe([KeyCode::i, KeyCode::I]) + ->and(InputManager::getDefaultBindings())->toBe($effective) + ->and($config->all())->not->toHaveKey('info')->and($config->writes)->toBe(0); + InputManager::setBindings($config->all()); + expect(InputManager::getBindings())->toBe($effective); + InputManager::setBinding('info', [KeyCode::F2]); + expect(InputManager::getDefaultBindings())->toBe($effective); + InputManager::setBindings(InputManager::getDefaultBindings()); + expect(InputManager::getBindings())->toBe($effective)->and($config->writes)->toBe(0); InputManager::handleInput(); expect(InputManager::getPressedKeyCode())->toBe(KeyCode::W); } finally { @@ -147,6 +157,52 @@ public function __destruct() {} } }); +it('adds a missing Info action using only unclaimed default keys', function (array $occupied, array $available) { + $authored = ['custom' => ['description' => 'Authored action.', 'keys' => $occupied]]; + InputManager::setBindings($authored); + expect(InputManager::getBindings()['custom'])->toBe($authored['custom']) + ->and(InputManager::getBindings()['info'])->toBe([ + 'description' => 'Read the next Info page; wrap to the first.', 'keys' => $available, + ])->and($authored)->not->toHaveKey('info'); + foreach ([KeyCode::i, KeyCode::I] as $key) { + InputManager::setInputSource(new FakeInputSource($key, $key)); + InputManager::handleInput(); + expect(Input::isButtonDown('info'))->toBe(in_array($key, $available, true)) + ->and(Input::isButtonDown('custom'))->toBe(in_array($key, $occupied, true)); + InputManager::handleInput(); + expect(Input::isButtonDown('info'))->toBeFalse(); + } +})->with([ + 'unused' => [[], [KeyCode::i, KeyCode::I]], + 'lowercase occupied' => [[KeyCode::i], [KeyCode::I]], + 'uppercase occupied' => [[KeyCode::I], [KeyCode::i]], + 'both occupied' => [[KeyCode::i, KeyCode::I], []], +]); + +it('preserves an explicit Info entry including deliberate unbinding', function (array $entry) { + $authored = ['info' => $entry, 'custom' => ['keys' => [KeyCode::F2]]]; + InputManager::setBindings($authored); + expect(InputManager::getBindings())->toBe($authored); + InputManager::setInputSource(new FakeInputSource(KeyCode::i)); + InputManager::handleInput(); + expect(Input::isButtonDown('info'))->toBeFalse(); +})->with([ + 'rebound' => [['description' => 'Custom Info.', 'keys' => [KeyCode::F2]]], + 'empty keys' => [['description' => 'Disabled Info.', 'keys' => []]], + 'empty entry' => [[]], +]); + +it('does not reapply defaults after an explicit live rebind or unbind', function () { + InputManager::setBindings([]); + expect(InputManager::setBinding('info', [KeyCode::F2]))->toBeTrue(); + $rebound = InputManager::getBindings(); + InputManager::setBindings($rebound); + expect(InputManager::getBindings())->toBe($rebound); + expect(InputManager::setBinding('info', []))->toBeTrue(); + InputManager::setBindings(InputManager::getBindings()); + expect(InputManager::getBindings()['info']['keys'])->toBeEmpty(); +}); + it('keeps pressed, down, repeat, release, and any-key semantics source independent', function () { InputManager::setInputSource(new FakeInputSource(KeyCode::a, KeyCode::a, KeyCode::b, null)); InputManager::handleInput(); diff --git a/tests/Unit/InterpreterFailureCleanupTest.php b/tests/Unit/InterpreterFailureCleanupTest.php new file mode 100644 index 00000000..b749c650 --- /dev/null +++ b/tests/Unit/InterpreterFailureCleanupTest.php @@ -0,0 +1,259 @@ +currentMapId = 'cleanup-test'; + $this->gameState = new GameState(); + $this->cinematicPresentation = new CinematicPresentationManager($this); + } + public function onEventSessionStarted(EventExecutionSession $session): void {} + public function onEventSessionFinished(EventExecutionSession $session, bool $completed): void + { + $this->finished[] = [$session, $completed]; + if ($this->throwOnFinish) { throw new RuntimeException('scene finish failed'); } + } + public function install(EventInterpreter $interpreter): void { $this->eventInterpreter = $interpreter; } +} + +final class FailureCleanupPresentation implements EventPresentationInterface +{ + public int $resets = 0; + public bool $throwOnReset = false; + public function beginText(string $text, string $name = ''): void {} + public function beginChoice(string $prompt, array $options, string $title = ''): void {} + public function update(): void {} + public function render(): void {} + public function isComplete(): bool { return false; } + public function choiceResult(): ?int { return null; } + public function reset(): void + { + $this->resets++; + if ($this->throwOnReset) { throw new RuntimeException('presentation reset failed'); } + } +} + +final class FailureCleanupTarget implements EventSessionCompletionTargetInterface +{ + public int $failed = 0; + public int $completed = 0; + public ?Closure $onFailure = null; + public function onEventSessionCompleted(GameScene $gameScene, EventExecutionSession $session): void { $this->completed++; } + public function onEventSessionFailed(GameScene $gameScene, EventExecutionSession $session): void + { + $this->failed++; + ($this->onFailure)?->__invoke($gameScene, $session); + } +} + +final class FailureCleanupOperation implements EventPendingOperationInterface +{ + public int $cancelled = 0; + public function __construct(private ?Closure $onCancel = null) {} + public function update(float $deltaSeconds): bool { return false; } + public function cancel(): void + { + $this->cancelled++; + ($this->onCancel)?->__invoke(); + } +} + +beforeEach(function () { + $this->debugBefore = new ReflectionClass(Debug::class)->getStaticProperties(); + $this->logs = sys_get_temp_dir() . '/ichiloto-failure-cleanup-' . bin2hex(random_bytes(4)); + mkdir($this->logs); + Debug::configure(['log_directory' => $this->logs]); + $this->scene = new FailureCleanupScene(); + $this->presentation = new FailureCleanupPresentation(); + $this->target = new FailureCleanupTarget(); + $this->interpreter = new EventInterpreter($this->scene, $this->presentation); + $this->scene->install($this->interpreter); + $this->session = $this->interpreter->start([['type' => 'wait', 'seconds' => 10]], 'cleanup', $this->target); +}); + +afterEach(function () { + foreach ($this->debugBefore as $name => $value) { new ReflectionProperty(Debug::class, $name)->setValue(null, $value); } + foreach (glob($this->logs . '/*') ?: [] as $path) { unlink($path); } + rmdir($this->logs); +}); + +it('cancels operations and notifies failure even when presentation reset throws', function () { + $operation = new FailureCleanupOperation(); + $this->session->rootLane()->yieldFor(['type' => 'wait'], [], $operation); + $this->presentation->throwOnReset = true; + $this->scene->cinematicPresentation->hideField(); + $this->target->onFailure = function (GameScene $scene, EventExecutionSession $session): void { + expect($scene->eventInterpreter->activeSession())->toBe($session) + ->and($session->status)->toBe(EventExecutionStatus::FAILED); + }; + $this->interpreter->failActiveSession('original route failure'); + expect($operation->cancelled)->toBe(1)->and($this->target->failed)->toBe(1) + ->and($this->target->completed)->toBe(0)->and($this->scene->finished)->toBe([[$this->session, false]]) + ->and($this->interpreter->activeSession())->toBeNull()->and($this->interpreter->lastSession)->toBe($this->session) + ->and($this->session->failureMessage)->toBe('original route failure') + ->and($this->scene->cinematicPresentation->hasTransitionCover())->toBeFalse(); + $this->interpreter->failActiveSession('not another failure'); + expect($operation->cancelled)->toBe(1)->and($this->target->failed)->toBe(1) + ->and($this->presentation->resets)->toBe(1) + ->and(file_get_contents($this->logs . '/error.log'))->toContain('original route failure', 'presentation reset failed'); +}); + +it('cancels every nested sibling despite multiple throwing operations and retains primary failure', function () { + $group = new EventParallelGroup([ + ['id' => 'first', 'commands' => [['type' => 'wait']]], + ['id' => 'second', 'commands' => [['type' => 'wait']]], + ['id' => 'third', 'commands' => [['type' => 'wait']]], + ], 'root'); + $operations = []; + foreach ($group->lanes() as $index => $lane) { + $operation = new FailureCleanupOperation(static function () use ($index): void { + if ($index < 2) { throw new RuntimeException('cancel failure ' . $index); } + }); + $operations[] = $operation; + $lane->yieldFor(['type' => 'wait'], [], $operation); + } + $this->session->rootLane()->yieldForParallel(['type' => 'parallel'], $group); + $this->interpreter->failActiveSession('original command failure'); + foreach ($operations as $operation) { expect($operation->cancelled)->toBe(1); } + foreach ($group->lanes() as $lane) { + expect($lane->pendingOperation)->toBeNull()->and($lane->frames())->toBe([]) + ->and($lane->status)->toBe(EventExecutionStatus::COMPLETED); + } + expect($this->session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->session->failureMessage)->toBe('original command failure') + ->and($this->target->failed)->toBe(1)->and($this->interpreter->activeSession())->toBeNull() + ->and(file_get_contents($this->logs . '/error.log'))->toContain('cancel failure 0', 'cancel failure 1', 'original command failure'); + $group->cancel(); + foreach ($operations as $operation) { expect($operation->cancelled)->toBe(1); } +}); + +it('detaches all pending lane ownership before invoking cancellation', function () { + $lane = new EventExecutionLane([['type' => 'wait']], 'detached', 'test'); + $operation = new FailureCleanupOperation(static function () use ($lane): void { + expect($lane->pendingOperation)->toBeNull()->and($lane->pendingCommand)->toBeNull() + ->and($lane->pendingState)->toBe([])->and($lane->frameToPush)->toBeNull(); + $lane->cancel(); + throw new RuntimeException('operation cancel failed'); + }); + $lane->queueFrame([['type' => 'wait']], 'next'); + $lane->yieldFor(['type' => 'wait'], ['timer' => 1], $operation); + expect(fn() => $lane->fail('original lane failure'))->toThrow(RuntimeException::class, 'operation cancel failed'); + expect($lane->status)->toBe(EventExecutionStatus::FAILED)->and($lane->failureMessage)->toBe('original lane failure'); + $lane->fail('secondary lane failure'); + expect($operation->cancelled)->toBe(1)->and($lane->failureMessage)->toBe('original lane failure'); +}); + +it('blocks reentrant failure and new sessions during the terminal failure callback', function () { + $operation = new FailureCleanupOperation(function (): void { + $this->interpreter->failActiveSession('reentrant cancellation failure'); + }); + $this->session->rootLane()->yieldFor(['type' => 'wait'], [], $operation); + $this->target->onFailure = function (): void { + $this->interpreter->failActiveSession('reentrant callback failure'); + expect($this->interpreter->start([['type' => 'wait']], 'early retry'))->toBeNull(); + }; + $this->interpreter->failActiveSession('primary failure'); + expect($this->target->failed)->toBe(1)->and($operation->cancelled)->toBe(1) + ->and($this->presentation->resets)->toBe(1)->and($this->session->failureMessage)->toBe('primary failure') + ->and($this->scene->finished)->toBe([[$this->session, false]]); +}); + +it('clears active ownership even when both terminal notifications throw', function () { + $this->target->onFailure = static function (): void { throw new RuntimeException('target failure cleanup failed'); }; + $this->scene->throwOnFinish = true; + $this->interpreter->failActiveSession('primary command failure'); + expect($this->interpreter->activeSession())->toBeNull()->and($this->interpreter->lastSession)->toBe($this->session) + ->and($this->session->failureMessage)->toBe('primary command failure') + ->and($this->target->failed)->toBe(1)->and($this->scene->finished)->toHaveCount(1) + ->and(file_get_contents($this->logs . '/error.log'))->toContain('target failure cleanup failed', 'scene finish failed'); +}); + +it('fails skip entry without outcome writes when reset or cancellation throws', function (bool $resetThrows) { + $this->session->configureCinematic(CinematicDefinition::fromArrays([ + 'id' => 'cleanup', 'name' => 'Cleanup', 'skip' => ['policy' => 'authored'], + 'finalizer' => [['type' => 'set_switch', 'name' => 'skip-completed', 'value' => true]], + ], [['type' => 'wait', 'seconds' => 10]])); + $operation = new FailureCleanupOperation(static function () use ($resetThrows): void { + if (!$resetThrows) { throw new RuntimeException('skip cancel failed'); } + }); + $this->session->rootLane()->yieldFor(['type' => 'wait'], [], $operation); + $subject = new stdClass(); + $this->session->claimMovementSubject($subject); + $this->session->claimPresentation($this->session->rootLane()); + $this->scene->cinematicPresentation->hideField(); + $this->presentation->throwOnReset = $resetThrows; + + expect($this->interpreter->skipActiveCinematic())->toBeFalse() + ->and($this->session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->session->failureMessage)->toContain($resetThrows ? 'presentation reset failed' : 'skip cancel failed') + ->and($this->session->isFinalizing)->toBeFalse() + ->and($this->session->frames())->toBe([]) + ->and($this->interpreter->activeSession())->toBeNull() + ->and($this->scene->gameState->getSwitch('skip-completed'))->toBeFalse() + ->and($this->scene->cinematicPresentation->hasTransitionCover())->toBeFalse() + ->and($this->scene->finished)->toBe([[$this->session, false]]) + ->and($this->target->completed)->toBe(0)->and($this->target->failed)->toBe(1) + ->and($operation->cancelled)->toBe(1); + expect(fn() => $this->session->claimMovementSubject($subject))->not->toThrow(Throwable::class); + expect(fn() => $this->session->claimPresentation(new EventExecutionLane([], 'new-owner'))) + ->not->toThrow(Throwable::class); + $this->interpreter->update(10); + expect($this->interpreter->skipActiveCinematic())->toBeFalse() + ->and($this->target->failed)->toBe(1)->and($this->target->completed)->toBe(0); +})->with(['reset' => [true], 'cancel' => [false]]); + +it('refuses reentrant skip while the failed session still owns its callback identity', function () { + $this->session->configureCinematic(CinematicDefinition::fromArrays([ + 'id' => 'cleanup', 'name' => 'Cleanup', 'skip' => ['policy' => 'authored'], + 'finalizer' => [['type' => 'set_switch', 'name' => 'skip-completed', 'value' => true]], + ], [['type' => 'wait', 'seconds' => 10]])); + $this->target->onFailure = function (): void { + expect($this->interpreter->activeSession())->toBe($this->session) + ->and($this->interpreter->skipActiveCinematic())->toBeFalse(); + }; + $this->interpreter->failActiveSession('primary failure'); + expect($this->session->status)->toBe(EventExecutionStatus::FAILED) + ->and($this->session->failureMessage)->toBe('primary failure') + ->and($this->session->isFinalizing)->toBeFalse() + ->and($this->scene->gameState->getSwitch('skip-completed'))->toBeFalse() + ->and($this->target->completed)->toBe(0)->and($this->target->failed)->toBe(1) + ->and($this->scene->finished)->toBe([[$this->session, false]]) + ->and($this->interpreter->activeSession())->toBeNull(); +}); + +it('falls back to the process logger only after cleanup if the project log fails', function () { + file_put_contents($this->logs . '/not-a-directory', 'blocked'); + Debug::configure(['log_directory' => $this->logs . '/not-a-directory']); + $previousLog = ini_get('error_log'); + ini_set('error_log', $this->logs . '/fallback.log'); + set_error_handler(static function (int $severity, string $message): never { throw new ErrorException($message, 0, $severity); }); + try { + $this->presentation->throwOnReset = true; + $this->interpreter->failActiveSession('primary before logger'); + } finally { + restore_error_handler(); + ini_set('error_log', $previousLog); + } + expect($this->interpreter->activeSession())->toBeNull()->and($this->target->failed)->toBe(1) + ->and($this->session->failureMessage)->toBe('primary before logger') + ->and(file_get_contents($this->logs . '/fallback.log'))->toContain('primary before logger', 'presentation reset failed', 'Project logger failed'); +}); diff --git a/tests/Unit/InventoryTest.php b/tests/Unit/InventoryTest.php index e8931e98..ed3ca37c 100644 --- a/tests/Unit/InventoryTest.php +++ b/tests/Unit/InventoryTest.php @@ -56,3 +56,12 @@ function makeInventoryTestItem(string $name = 'Potion', int $quantity = 1): Item expect($inventory->getQuantityByName('Potion'))->toBe(2) ->and($inventory->all->count())->toBe(1); }); + +it('admits existing-stack units at slot capacity without replacing owned objects', function () { + $inventory = new Inventory(capacity: 1); + $owned = makeInventoryTestItem('Potion', 2); + $inventory->addItems($owned); + $inventory->addItems(makeInventoryTestItem('Ether'), makeInventoryTestItem('Potion')); + expect($inventory->isFull)->toBeTrue()->and($inventory->all->toArray())->toBe([$owned]) + ->and($owned->quantity)->toBe(3); +}); diff --git a/tests/Unit/ItemMenuPresentationTest.php b/tests/Unit/ItemMenuPresentationTest.php new file mode 100644 index 00000000..ea01b836 --- /dev/null +++ b/tests/Unit/ItemMenuPresentationTest.php @@ -0,0 +1,330 @@ +modals = new \Assegai\Collections\Stack(\Ichiloto\Engine\UI\Interfaces\ModalInterface::class); } + public function alert(string $message, string $title = '', int $width = DEFAULT_DIALOG_WIDTH): void + { + $this->messages[] = $message; + ($this->onAlert ?? static fn() => null)(); + } + public function confirm(string $message, string $title = '', int $width = DEFAULT_DIALOG_WIDTH): bool { return $this->confirmation; } + public function selectQuantity(string $message, int $maximum, string $title = '', int $initial = 1, int $width = DEFAULT_DIALOG_WIDTH): ?int { return $this->amount; } +} + +function itemMenuPress(ItemMenuState $state, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $state->execute(); +} + +function itemMenuText(PresentationCanvas $canvas): string +{ + return implode("\n", array_map(fn($layer) => implode('', array_column($layer->runs, 'text')), $canvas->textLayers)); +} + +beforeEach(function () { + $this->saved = []; + foreach ([Console::class, InputManager::class, ActionHints::class, ConfigStore::class, Debug::class, ModalManager::class, + \Ichiloto\Engine\Audio\AudioManager::class] as $class) { + $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-items-' . bin2hex(random_bytes(5)); + mkdir($this->root); + Debug::configure(['log_directory' => $this->root]); + new ReflectionProperty(\Ichiloto\Engine\Audio\AudioManager::class, 'instance')->setValue(null, null); + $this->alerts = new ItemMenuAlerts(); + new ReflectionProperty(ModalManager::class, 'instance')->setValue(null, $this->alerts); + Console::setTerminalOutputEnabled(false); + Console::syncDimensions(135, 36); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + $this->game = $game = new ItemMenuGameProbe(); + $audio = new class extends \Ichiloto\Engine\Audio\AudioManager { + public function __construct() {} + public function playSystemSound(\Ichiloto\Engine\Audio\Enumerations\SystemSound $sound): void {} + }; + new ReflectionProperty($game, 'audioManager')->setValue($game, $audio); + $this->runtime = null; + new ReflectionProperty(Console::class, 'game')->setValue(null, $game); + $manager = makeBareScene(SceneManager::class); + new ReflectionProperty($manager, 'game')->setValue($manager, $game); + $scene = makeBareScene(GameScene::class); + new ReflectionProperty($scene, 'sceneManager')->setValue($scene, $manager); + $ui = makeBareScene(\Ichiloto\Engine\UI\UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($scene, 'uiManager')->setValue($scene, $ui); + $this->party = new Party(); + $this->actor = new Character('First Actor', 0, new Stats(totalHp: 500, totalMp: 100)); + $this->actor->stats->currentHp = 100; + $this->actor->stats->currentMp = 100; + $this->party->addMember($this->actor); + $this->party->addMember(new Character('Second Actor', 0, new Stats())); + new ReflectionProperty($scene, 'party')->setValue($scene, $this->party); + $this->state = new ItemMenuState(new SceneStateContext($scene)); + new ReflectionProperty($scene, 'state')->setValue($scene, $this->state); + InputManager::setBindings([ + 'confirm' => ['keys' => [KeyCode::ENTER]], 'back' => ['keys' => [KeyCode::ESCAPE]], 'cancel' => ['keys' => [KeyCode::C]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]], + ]); + ob_start(); + $this->state->enter(); + $this->theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]); +}); + +afterEach(function () { + $this->runtime?->shutdown(); + ob_end_clean(); + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +function itemMenuInventory(ItemMenuState $state, int $count, bool $keys = false): array +{ + $items = []; + for ($i = 0; $i < $count; $i++) { + $items[] = new Item(sprintf('Item %03d', $i), 'Exact description ' . $i, '/', 10, quantity: $i % 98 + 1, + isKeyItem: $keys, id: ($keys ? 'key.' : 'item.') . $i); + } + $state->getGameScene()->party->inventory->addItems(...$items); + $state->selectionPanel->setItems($items); + return $items; +} + +it('renders the live item commands and inventory through different reusable themes without mutation', function () { + $items = itemMenuInventory($this->state, 3); + $themes = [$this->theme, new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, + 'colors' => ['panel' => [244, 237, 217], 'text' => [30, 35, 50]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 32, 'panelPadding' => 16]])]; + foreach ($themes as $theme) { + $canvas = ItemMenuPresentation::compose($this->state, $theme); + expect($canvas)->toBeInstanceOf(PresentationCanvas::class); + $text = itemMenuText($canvas); + foreach (['Use', 'Sort', 'Discard', 'Key Items', 'Item 000', 'Item 002'] as $label) { expect($text)->toContain($label); } + expect($text)->not->toContain('Enter', 'Escape'); + foreach ($canvas->textLayers as $layer) { expect($layer->id)->not->toContain('command-0-cursor'); } + } + expect($this->state->selectionPanel->items)->toBe($items)->and($items[0]->quantity)->toBe(1); +}); + +it('keeps every item reachable using terminal page slicing and graphical selection following', function () { + itemMenuInventory($this->state, 70); + $this->state->setMode(new UseItemMode($this->state)); + $panel = $this->state->selectionPanel; + $size = $panel->pageSize; + expect($panel->totalPages)->toBe((int)ceil(70 / $size)); + itemMenuPress($this->state, KeyCode::RIGHT); + expect($panel->activeIndex)->toBe($size)->and($panel->page)->toBe(2); + expect(implode('', $panel->getContent()))->toContain(sprintf('Item %03d', $size))->not->toContain('Item 000'); + expect(count($panel->getContent()))->toBe($size); + for ($index = 0; $index < 70; $index++) { + $panel->activeIndex = $index; + $canvas = ItemMenuPresentation::compose($this->state, $this->theme); + expect(itemMenuText($canvas))->toContain(sprintf('Item %03d', $index)); + expect(implode('', $panel->getContent()))->toContain(sprintf('Item %03d', $index)); + } + $panel->selectNext(); + expect($panel->activeIndex)->toBe(0)->and($panel->page)->toBe(1); + $panel->selectPrevious(); + expect($panel->activeIndex)->toBe(69)->and($panel->page)->toBe($panel->totalPages); +}); + +it('normalizes keyed input and clamps selection after a changing or empty inventory', function () { + $items = itemMenuInventory($this->state, 30); + $panel = $this->state->selectionPanel; + $panel->activeIndex = 29; + $panel->setItems([12 => $items[0], 99 => $items[1]]); + expect($panel->items)->toBe([$items[0], $items[1]])->and($panel->activeIndex)->toBe(1)->and($panel->page)->toBe(1); + $panel->setItems([]); + $panel->selectNext(); + $panel->selectPrevious(); + $panel->changePage(1); + $panel->focus(); + expect($panel->activeIndex)->toBe(-1)->and($panel->activeItem)->toBeNull()->and($panel->totalPages)->toBe(1); + expect(ItemMenuPresentation::compose($this->state, $this->theme))->toBeInstanceOf(PresentationCanvas::class); +}); + +it('shares page navigation with read-only key items without use or discard', function () { + $items = itemMenuInventory($this->state, 50, true); + $this->state->setMode(new ViewKeyItemsMode($this->state)); + itemMenuPress($this->state, KeyCode::RIGHT); + $index = $this->state->selectionPanel->activeIndex; + expect($index)->toBe($this->state->selectionPanel->pageSize); + itemMenuPress($this->state, KeyCode::ENTER); + expect($this->state->mode)->toBeInstanceOf(ViewKeyItemsMode::class) + ->and($this->party->inventory->keyItems->count())->toBe(50); + expect(itemMenuText(ItemMenuPresentation::compose($this->state, $this->theme)))->toContain($items[$index]->name, $items[$index]->description); +}); + +it('opens the read-only empty key view while rejecting empty regular inventory actions', function () { + itemMenuPress($this->state, KeyCode::ENTER); + expect($this->state->mode)->toBeInstanceOf(SelectIemMenuCommandMode::class)->and($this->alerts->messages)->toHaveCount(1); + $this->state->itemMenu->setActiveItemByLabel('Key Items'); + itemMenuPress($this->state, KeyCode::ENTER); + expect($this->state->mode)->toBeInstanceOf(ViewKeyItemsMode::class) + ->and(itemMenuText(ItemMenuPresentation::compose($this->state, $this->theme)))->toContain('No key items yet.'); +}); + +it('leaves the Info panel as description while shared quantity and confirmation owners decide use', function () { + $item = new Item('Potion', 'Restore HP', '/', 10, quantity: 3, + effects: [new HPRecoveryEffect('Heal', 'Heal', 50, 100, ValueBasis::ACTUAL)]); + $this->party->inventory->addItems($item); + $this->state->selectionPanel->setItems([$item]); + $this->state->setMode(new UseItemMode($this->state)); + itemMenuPress($this->state, KeyCode::ENTER); + expect($this->state->mode)->toBeInstanceOf(SelectItemTargetMode::class); + $before = ItemMenuPresentation::compose($this->state, $this->theme); + expect(itemMenuText($before))->toContain('First Actor', '100 / 500', '100 / 100'); + itemMenuPress($this->state, KeyCode::ENTER); + $quantity = ItemMenuPresentation::compose($this->state, $this->theme); + expect(itemMenuText($quantity))->toContain('Restore HP')->not->toContain('Use Potion x', 'Up/Down', 'C/Esc'); + expect($item->quantity)->toBe(3)->and($this->actor->stats->currentHp)->toBe(100); + $this->alerts->amount = 2; + itemMenuPress($this->state, KeyCode::ENTER); + expect($item->quantity)->toBe(3)->and($this->actor->stats->currentHp)->toBe(100); + $this->alerts->confirmation = true; + $alertSeen = false; + $this->alerts->onAlert = function () use (&$alertSeen) { + $alertSeen = true; + expect(itemMenuText(ItemMenuPresentation::compose($this->state, $this->theme))) + ->toContain('Restore HP')->not->toContain('Use Potion x', 'Up/Down', 'C/Esc'); + }; + itemMenuPress($this->state, KeyCode::ENTER); + expect($alertSeen)->toBeTrue(); + expect($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(200); + expect(itemMenuText(ItemMenuPresentation::compose($this->state, $this->theme)))->toContain('200 / 500'); +}); + +it('pages complete descriptions inside one fixed two-line Info panel', function () { + itemMenuInventory($this->state, 1); + $description = implode("\n", ['First source line', 'Second source line', 'Third source line', 'Fourth source line']); + $this->state->infoPanel->setText($description); + $canvas = ItemMenuPresentation::compose($this->state, $this->theme); + expect(itemMenuText($canvas))->toContain('First source line', 'Second source line', 'Lines 1-2 / 4') + ->not->toContain('Third source line'); + $bounds = array_column($canvas->textLayers, null, 'id')['items-description']->clipRect; + $this->state->menuInfoText->advance(); + $next = ItemMenuPresentation::compose($this->state, $this->theme); + expect(itemMenuText($next))->toContain('Third source line', 'Fourth source line', 'Lines 3-4 / 4') + ->and(array_column($next->textLayers, null, 'id')['items-description']->clipRect)->toEqual($bounds) + ->and($this->state->menuInfoText->lastPage->source)->toBe($description); +}); + +it('uses the ordinary terminal path when no renderer is selected', function () { + expect($this->state->getPresentationCanvas())->toBeNull(); + itemMenuInventory($this->state, 30); + $this->state->setMode(new DiscardItemMode($this->state)); + itemMenuPress($this->state, KeyCode::RIGHT); + expect($this->state->selectionPanel->activeIndex)->toBe($this->state->selectionPanel->pageSize); + itemMenuPress($this->state, KeyCode::ESCAPE); + expect($this->state->mode)->toBeInstanceOf(SelectIemMenuCommandMode::class); +}); + +it('returns to the same inventory selection after cancelling target selection', function () { + itemMenuInventory($this->state, 40); + $this->state->setMode(new UseItemMode($this->state)); + $this->state->selectionPanel->activeIndex = 30; + itemMenuPress($this->state, KeyCode::ENTER); + itemMenuPress($this->state, KeyCode::ESCAPE); + expect($this->state->mode)->toBeInstanceOf(UseItemMode::class) + ->and($this->state->selectionPanel->activeIndex)->toBe(30); +}); + +it('does not leak key items into the discard list after confirming removal', function () { + itemMenuInventory($this->state, 2); + $key = new Item('Key', 'Do not discard', '!', 0, isKeyItem: true); + $this->party->inventory->addItems($key); + $this->state->setMode(new DiscardItemMode($this->state)); + itemMenuPress($this->state, KeyCode::ENTER); + expect($this->state->selectionPanel->items)->toHaveCount(2); + $this->alerts->confirmation = true; + itemMenuPress($this->state, KeyCode::ENTER); + expect($this->state->selectionPanel->items)->toHaveCount(1) + ->and($this->party->inventory->keyItems->count())->toBe(1) + ->and($this->state->selectionPanel->items[0]->isKeyItem)->toBeFalse(); +}); + +it('uses menu-only renderer capabilities and retains its canvas beneath alerts', function (bool $supported) { + mkdir($this->root . '/Data/Presentation', 0777, true); + file_put_contents($this->root . '/' . MenuPresentationCatalog::FILE, + ' 'ichiloto.menu/1', 'showInputHints' => false], true) . ';'); + $caps = $supported ? MenuPresentationCatalog::CAPABILITIES : []; + $transport = new FakeRendererTransport(); + $transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $this->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $this->root, + requiredCapabilities: $caps), $transport); + $this->runtime->start('Isolated Items', 135, 36); + $this->game->useRendererRuntime($this->runtime); + itemMenuInventory($this->state, 30); + $mode = $this->state->mode; + if (!$supported) { + expect($this->state->getPresentationCanvas())->toBeNull()->and($this->state->mode)->toBe($mode); + expect(file_get_contents($this->root . '/error.log'))->toContain('Menu renderer lacks'); + return; + } + $before = $this->state->getPresentationCanvas(); + expect($before)->toBeInstanceOf(PresentationCanvas::class); + $modal = new \Ichiloto\Engine\UI\Modal\AlertModal($this->game, 'Used item.', ''); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $this->alerts); + $this->alerts->open($modal); + $during = $this->state->getPresentationCanvas(); + expect($during)->toBeInstanceOf(PresentationCanvas::class) + ->and(itemMenuText($during))->toContain('Used item.', 'Item 000'); + $modal->hide(); + expect($this->state->getPresentationCanvas())->toEqual($before); +})->with([true, false]); diff --git a/tests/Unit/ItemQuantityConfirmationTest.php b/tests/Unit/ItemQuantityConfirmationTest.php new file mode 100644 index 00000000..467fa47f --- /dev/null +++ b/tests/Unit/ItemQuantityConfirmationTest.php @@ -0,0 +1,346 @@ + $key) { + if ($index > 0) { $this->keys[] = null; } + $this->keys[] = $key; + } + } + + public function poll(): ?KeyCode + { + if ($this->keys === []) { throw new RuntimeException('Unexpected extra input poll.'); } + $key = array_shift($this->keys); + if ($key === null) { return null; } + $modal = $this->manager->currentModal; + if ($modal !== null) { + $this->frames[] = ['type' => $modal::class, 'snapshot' => $modal->getModalPresentation(), + 'text' => implode("\n", array_map(TerminalText::stripAnsi(...), Console::getBuffer()))]; + } + ($this->beforePoll ?? static fn() => null)($modal); + return $key; + } + + public function reset(bool $drainBufferedInput = false): void + { + if ($drainBufferedInput) { $this->keys = []; } + } +} + +beforeEach(function () { + $this->saved = []; + foreach ([Console::class, InputManager::class, ConfigStore::class, Timers::class, EventManager::class, + ModalManager::class, AudioManager::class, QuestManager::class, ActionHints::class] as $class) { + $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); + } + foreach ([EventManager::class, ModalManager::class] as $class) { + new ReflectionProperty($class, 'instance')->setValue(null, null); + } + new ReflectionProperty(QuestManager::class, 'current')->setValue(null, null); + new ReflectionProperty(InputManager::class, 'eventManager')->setValue(null, null); + ConfigStore::remove(\Ichiloto\Engine\Util\Stores\ItemStore::class); + ActionHints::useProvider(null); + Timers::setFrameTick(null); + Console::setTerminalOutputEnabled(false); + Console::syncDimensions(135, 36); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + $this->game = new ItemQuantityGame(); + $audio = new class extends AudioManager { + public function __construct() {} + public function playSystemSound(SystemSound $sound): void {} + }; + new ReflectionProperty(AudioManager::class, 'instance')->setValue(null, $audio); + new ReflectionProperty($this->game, 'audioManager')->setValue($this->game, $audio); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $sceneManager = makeBareScene(SceneManager::class); + new ReflectionProperty($sceneManager, 'game')->setValue($sceneManager, $this->game); + $scene = makeBareScene(GameScene::class); + new ReflectionProperty($scene, 'sceneManager')->setValue($scene, $sceneManager); + $this->party = new Party(); + $this->actor = new Character('First Actor', 0, new Stats(totalHp: 500)); + $this->actor->stats->currentHp = 100; + $this->party->addMember($this->actor); + new ReflectionProperty($scene, 'party')->setValue($scene, $this->party); + $this->state = new ItemMenuState(new SceneStateContext($scene)); + $this->manager = ModalManager::getInstance($this->game); + InputManager::setBindings([ + 'confirm' => ['keys' => [KeyCode::ENTER]], 'back' => ['keys' => [KeyCode::ESCAPE]], + 'cancel' => ['keys' => [KeyCode::ESCAPE, KeyCode::C]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]], + ]); + ob_start(); + $this->state->initializeMenuUI(); +}); + +afterEach(function () { + ob_end_clean(); + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } +}); + +function quantityItemSetup(ItemMenuState $state, int $quantity, bool $discard = false): Item +{ + $item = new Item('Potion', 'Restores HP.', '', 10, quantity: $quantity, id: 'potion', + effects: [new HPRecoveryEffect('Heal', 'Heal', 50, 100, ValueBasis::ACTUAL)]); + $state->getGameScene()->party->inventory->addItems($item); + $state->selectionPanel->setItems([$item]); + $state->selectionPanel->focus(); + $state->setMode($discard ? new DiscardItemMode($state) : new SelectItemTargetMode($state)); + return $item; +} + +function quantityItemRun(ItemMenuState $state, ModalManager $manager, array $keys, ?Closure $beforePoll = null): ItemQuantityInput +{ + $input = new ItemQuantityInput($manager, [KeyCode::ENTER, ...$keys]); + $input->beforePoll = $beforePoll; + InputManager::setInputSource($input); + InputManager::handleInput(); + $state->execute(); + expect($manager->currentModal)->toBeNull()->and(Input::isButtonDown('confirm'))->toBeFalse(); + return $input; +} + +it('requires a real confirmation before using a single unit and consumes its input only once', function () { + $item = quantityItemSetup($this->state, 1); + $mode = $this->state->mode; + $input = quantityItemRun($this->state, $this->manager, [KeyCode::ENTER, KeyCode::ENTER], function ($modal) use ($item) { + if ($modal instanceof ConfirmModal) { expect($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(100); } + }); + expect(array_column($input->frames, 'type'))->toBe([ConfirmModal::class, AlertModal::class]) + ->and($input->frames[0]['snapshot']->message)->toBe('Use Potion x 1 on First Actor?') + ->and($item->quantity)->toBe(0)->and($this->party->inventory->all->count())->toBe(0) + ->and($this->actor->stats->currentHp)->toBe(150)->and($this->state->mode)->toBe($mode); + $this->state->execute(); + expect($this->actor->stats->currentHp)->toBe(150); +}); + +it('cancels either a semantic action or the Cancel choice without using a single unit', function (array $keys) { + $item = quantityItemSetup($this->state, 1); + quantityItemRun($this->state, $this->manager, $keys); + expect($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(100) + ->and($this->state->selectionPanel->activeItem)->toBe($item) + ->and($this->state->targetSelectionPanel->activeCharacter)->toBe($this->actor); +})->with([[ [KeyCode::ESCAPE] ], [ [KeyCode::RIGHT, KeyCode::ENTER] ]]); + +it('selects stack amount in a modal then separately confirms its exact use', function () { + $item = quantityItemSetup($this->state, 3); + $input = quantityItemRun($this->state, $this->manager, [KeyCode::UP, KeyCode::ENTER, KeyCode::ENTER, KeyCode::ENTER], function ($modal) use ($item) { + if ($modal instanceof QuantityModal || $modal instanceof ConfirmModal) { + expect($item->quantity)->toBe(3)->and($this->actor->stats->currentHp)->toBe(100) + ->and(implode('', $this->state->infoPanel->getContent()))->toContain('Restores HP.')->not->toContain('Use Potion'); + } + }); + expect(array_column($input->frames, 'type'))->toBe([QuantityModal::class, QuantityModal::class, ConfirmModal::class, AlertModal::class]) + ->and($input->frames[0]['snapshot']->quantity->value)->toBe(1) + ->and($input->frames[1]['snapshot']->quantity->value)->toBe(2) + ->and($input->frames[1]['snapshot']->message)->toBe('Use Potion on First Actor') + ->and($input->frames[1]['text'])->toContain('Quantity: 02 / 3', 'Continue') + ->and($input->frames[2]['snapshot']->message)->toBe('Use Potion x 2 on First Actor?') + ->and($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(200); +}); + +it('cancels stack selection or its confirmation without changing items or target', function (bool $discard, array $keys) { + $item = quantityItemSetup($this->state, 3, $discard); + $mode = $this->state->mode; + quantityItemRun($this->state, $this->manager, $keys); + expect($item->quantity)->toBe(3)->and($this->actor->stats->currentHp)->toBe(100) + ->and($this->state->mode)->toBe($mode)->and($this->state->selectionPanel->activeItem)->toBe($item); +})->with([false, true])->with([[ [KeyCode::ESCAPE] ], [ [KeyCode::UP, KeyCode::ENTER, KeyCode::ESCAPE] ]]); + +it('discards only the confirmed quantity and retains the selected remainder', function () { + $item = quantityItemSetup($this->state, 5, true); + $input = quantityItemRun($this->state, $this->manager, [KeyCode::UP, KeyCode::ENTER, KeyCode::ENTER]); + expect(array_column($input->frames, 'type'))->toBe([QuantityModal::class, QuantityModal::class, ConfirmModal::class]) + ->and($input->frames[2]['snapshot']->message)->toBe('Discard Potion x 2?') + ->and($item->quantity)->toBe(3)->and($this->state->selectionPanel->activeItem)->toBe($item) + ->and($this->state->mode)->toBeInstanceOf(DiscardItemMode::class); + $this->state->execute(); + expect($item->quantity)->toBe(3); +}); + +it('clamps discard to available stock and returns to commands when regular items run out', function () { + $item = quantityItemSetup($this->state, 3, true); + $key = new Item('Key', 'Keep', '', 0, isKeyItem: true); + $this->party->inventory->addItems($key); + quantityItemRun($this->state, $this->manager, [KeyCode::RIGHT, KeyCode::ENTER, KeyCode::ENTER, KeyCode::ENTER]); + expect($item->quantity)->toBe(0)->and($key->quantity)->toBe(1) + ->and($this->party->inventory->all->toArray())->toBe([$key]) + ->and($this->state->mode)->toBeInstanceOf(SelectIemMenuCommandMode::class) + ->and(implode('', $this->state->infoPanel->getContent()))->toContain($this->state->itemMenu->getActiveItem()->getDescription()); +}); + +it('rejects key items and equipment even when a stale panel exposes them', function (bool $discard, bool $equipment) { + quantityItemSetup($this->state, 3, $discard); + $protected = $equipment ? makeBareScene(Weapon::class) : new Item('Key', 'Keep', '', 0, isKeyItem: true); + if ($equipment) { + foreach (['id' => 'weapon', 'name' => 'Weapon', 'description' => 'Keep', 'quantity' => 3] as $name => $value) { + new ReflectionProperty($protected, $name)->setValue($protected, $value); + } + } + $this->party->inventory->addItems($protected); + $this->state->selectionPanel->setItems([$protected]); + $before = $protected->quantity; + $input = quantityItemRun($this->state, $this->manager, [KeyCode::ENTER]); + expect(array_column($input->frames, 'type'))->toBe([AlertModal::class]) + ->and($protected->quantity)->toBe($before)->and($this->actor->stats->currentHp)->toBe(100); +})->with([false, true])->with([false, true]); + +it('rejects stock that shrinks during quantity selection before opening confirmation', function (bool $discard) { + $item = quantityItemSetup($this->state, 3, $discard); + $input = quantityItemRun($this->state, $this->manager, [KeyCode::UP, KeyCode::ENTER, KeyCode::ENTER], function ($modal) use ($item) { + if ($modal instanceof QuantityModal && $modal->quantity === 2) { $item->quantity = 1; } + }); + expect(array_column($input->frames, 'type'))->not->toContain(ConfirmModal::class) + ->and($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(100); +})->with([false, true]); + +it('does not apply a stale confirmation to a replacement stack with the same identity', function (bool $discard) { + $item = quantityItemSetup($this->state, 1, $discard); + $replacement = new Item('Potion', 'Replacement', '', 10, quantity: 9, id: 'potion'); + quantityItemRun($this->state, $this->manager, [KeyCode::ENTER, KeyCode::ENTER], function ($modal) use ($item, $replacement) { + if ($modal instanceof ConfirmModal) { + $this->party->inventory->all->remove($item); + $this->party->inventory->addItems($replacement); + } + }); + expect($replacement->quantity)->toBe(9)->and($item->quantity)->toBe(1) + ->and($this->actor->stats->currentHp)->toBe(100); +})->with([false, true]); + +it('rejects a confirmed amount when the same stack shrinks while the dialog is open', function (bool $discard) { + $item = quantityItemSetup($this->state, 3, $discard); + quantityItemRun($this->state, $this->manager, [KeyCode::UP, KeyCode::ENTER, KeyCode::ENTER, KeyCode::ENTER], function ($modal) use ($item) { + if ($modal instanceof ConfirmModal) { $item->quantity = 1; } + }); + expect($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(100); +})->with([false, true]); + +it('rejects an empty stack before either selection or confirmation', function (bool $discard) { + $item = quantityItemSetup($this->state, 0, $discard); + $input = quantityItemRun($this->state, $this->manager, [KeyCode::ENTER]); + expect(array_column($input->frames, 'type'))->toBe([AlertModal::class]) + ->and($item->quantity)->toBe(0)->and($this->actor->stats->currentHp)->toBe(100); +})->with([false, true]); + +it('rejects a target that leaves the party while confirmation is open', function () { + $item = quantityItemSetup($this->state, 1); + quantityItemRun($this->state, $this->manager, [KeyCode::ENTER, KeyCode::ENTER], function ($modal) { + if ($modal instanceof ConfirmModal) { $this->party->members->remove($this->actor); } + }); + expect($item->quantity)->toBe(1)->and($this->actor->stats->currentHp)->toBe(100); +}); + +it('keeps quantity metadata readonly and optional for ordinary modals', function () { + $quantity = new QuantityPresentation(1, 3, 2); + expect(fn() => $quantity->value = 3)->toThrow(Error::class); + expect(new ModalPresentation('', 'Confirm?', ['OK', 'Cancel'], 0)->quantity)->toBeNull(); +}); + +it('rejects invalid quantity snapshots', function (int $minimum, int $maximum, int $value) { + expect(fn() => new QuantityPresentation($minimum, $maximum, $value))->toThrow(InvalidArgumentException::class); +})->with([[-1, 3, 1], [3, 1, 2], [1, 3, 0], [1, 3, 4]]); + +it('uses remapped semantic axes and actions in the real quantity modal and cleans up its stack', function () { + InputManager::setBindings(['up' => ['keys' => [KeyCode::W]], 'right' => ['keys' => [KeyCode::D]], + 'left' => ['keys' => [KeyCode::A]], 'down' => ['keys' => [KeyCode::S]], + 'confirm' => ['keys' => [KeyCode::Q]], 'cancel' => ['keys' => [KeyCode::X]]]); + $input = new ItemQuantityInput($this->manager, [KeyCode::W, KeyCode::D, KeyCode::D, KeyCode::A, KeyCode::S, KeyCode::Q]); + InputManager::setInputSource($input); + expect($this->manager->selectQuantity('Choose amount', 14, initial: 4))->toBe(3) + ->and(array_map(fn($frame) => $frame['snapshot']->quantity->value, $input->frames))->toBe([4, 5, 14, 14, 4, 3]) + ->and($input->frames[0]['text'])->toContain('W: +1', 'Quantity: 04 / 14') + ->and($this->manager->currentModal)->toBeNull(); +}); + +it('removes the quantity modal from its manager even when input fails', function () { + InputManager::setInputSource(new ItemQuantityInput($this->manager, [])); + expect(fn() => $this->manager->selectQuantity('Choose amount', 3))->toThrow(RuntimeException::class, 'Unexpected extra input poll.') + ->and($this->manager->currentModal)->toBeNull(); +}); + +it('accepts a separately bound Back action without requiring a Cancel alias', function () { + InputManager::setBindings(['back' => ['keys' => [KeyCode::X]]]); + InputManager::setInputSource(new ItemQuantityInput($this->manager, [KeyCode::X])); + expect($this->manager->selectQuantity('Choose amount', 3))->toBeNull() + ->and($this->manager->currentModal)->toBeNull(); +}); + +it('keeps long Unicode labels and the entire quantity range reachable in terminal rows', function () { + $message = "Use Caf\u{00e9} \u{6f22}\u{5b57} item on the selected actor"; + $input = new ItemQuantityInput($this->manager, [KeyCode::ENTER]); + $input->beforePoll = function ($modal) use ($message) { + expect($modal)->toBeInstanceOf(QuantityModal::class); + $lines = new ReflectionProperty($modal, 'content')->getValue($modal); + foreach ($lines as $line) { + expect(TerminalText::displayWidth($line))->toBeLessThanOrEqual(18) + ->and(mb_check_encoding($line, 'UTF-8'))->toBeTrue(); + } + expect(implode('', $lines))->toBe($message . sprintf('Quantity: %019d / %d', 1, PHP_INT_MAX)); + }; + InputManager::setInputSource($input); + expect($this->manager->selectQuantity($message, PHP_INT_MAX, width: 20))->toBe(1); +}); + +it('restores an existing modal owner after the quantity modal closes', function () { + $parent = new AlertModal($this->game, 'Parent', ''); + $this->manager->open($parent); + InputManager::setInputSource(new ItemQuantityInput($this->manager, [KeyCode::ESCAPE])); + expect($this->manager->selectQuantity('Choose amount', 3))->toBeNull() + ->and($this->manager->currentModal)->toBe($parent)->and($parent->isShowing())->toBeTrue(); + $parent->hide(); +}); diff --git a/tests/Unit/JournalMenuPresentationTest.php b/tests/Unit/JournalMenuPresentationTest.php new file mode 100644 index 00000000..e34a5d0a --- /dev/null +++ b/tests/Unit/JournalMenuPresentationTest.php @@ -0,0 +1,465 @@ +entries++; } +} + +function journalMenuKey(QuestMenuState|RecordsMenuState $state, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $state->execute(); +} + +function journalMenuText(PresentationCanvas $canvas): array +{ + $text = []; + foreach ($canvas->textLayers as $layer) { + $runs = array_filter($layer->runs, fn($run) => $run->foreground !== null); + if ($runs !== []) { $text[$layer->id] = implode('', array_column($runs, 'text')); } + } + return $text; +} + +function journalMenuTheme(bool $alternative = false): array +{ + $base = ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]; + if (!$alternative) { return $base; } + $art = ['asset' => 'surface.png', 'cuts' => [3, 4, 3, 4]]; + return [...$base, 'colors' => ['text' => [30, 20, 10], 'background' => [230, 220, 205], 'panel' => [240, 230, 220]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 34, 'panelPadding' => 16, 'sectionGap' => 8], + 'rowMetrics' => ['padding' => 24, 'separatorWidth' => 1.5, 'cursorWidth' => 10, 'cursorHeight' => 20, 'cursorInset' => 6, 'cursorTravel' => 3], 'cursor' => 'surface.png', + 'rowArtwork' => ['normal' => $art, 'selected' => $art, 'focus' => ['asset' => 'surface.png']], + 'frames' => ['panel' => $art, 'quiet' => $art]]; +} + +function journalMenuPng(string $path, int $width = 53, int $height = 37): void +{ + $chunk = static fn($type, $bytes) => pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAA\xBB\xCC\xFF", $width), $height))) . $chunk('IEND', '')); +} + +function journalMenuOwner(object $fixture, bool $records): QuestMenuState|RecordsMenuState +{ + $state = $records ? new RecordsMenuState(new SceneStateContext($fixture->scene)) : new QuestMenuState(new SceneStateContext($fixture->scene)); + new ReflectionProperty($fixture->scene, 'state')->setValue($fixture->scene, $state); + $state->enter(); + return $state; +} + +function journalMenuRuntime(object $fixture, ?array $theme, ?array $caps = null): void +{ + if ($theme !== null) { file_put_contents($fixture->root . '/Data/Presentation/menus.php', 'transport = new FakeRendererTransport(); + $fixture->transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $fixture->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $fixture->root, + requiredCapabilities: $caps), $fixture->transport); + $fixture->runtime->start('Journal fixture', 135, 36); + $fixture->game->useRendererRuntime($fixture->runtime); +} + +beforeEach(function () { + $this->savedStatics = []; + foreach ([Console::class, InputManager::class, ActionHints::class, ConfigStore::class, Debug::class, ModalManager::class, AudioManager::class, Time::class] as $class) { + $this->savedStatics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-journal-menu-' . bin2hex(random_bytes(5)); + mkdir($this->root . '/Data/Presentation', 0777, true); + journalMenuPng($this->root . '/surface.png'); + Debug::configure(['log_directory' => $this->root . '/logs']); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + ConfigStore::put(ProjectConfig::class, new SceneAudioConfigStub()); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + new ReflectionProperty(Time::class, 'time')->setValue(null, 600.0); + $this->game = new JournalMenuTestGame(); + new ReflectionProperty(AudioManager::class, 'instance')->setValue(null, null); + $audio = new class extends AudioManager { + public function __construct() {} + public function playSystemSound(\Ichiloto\Engine\Audio\Enumerations\SystemSound $sound): void {} + }; + new ReflectionProperty($this->game, 'audioManager')->setValue($this->game, $audio); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $manager = makeBareScene(SceneManager::class); + new ReflectionProperty($manager, 'game')->setValue($manager, $this->game); + $this->scene = makeBareScene(GameScene::class); + new ReflectionProperty($this->scene, 'sceneManager')->setValue($this->scene, $manager); + $this->flags = new GameState(); + new ReflectionProperty($this->scene, 'gameState')->setValue($this->scene, $this->flags); + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, new Party()); + $ui = makeBareScene(UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + $this->quests = makeBareScene(QuestManager::class); + new ReflectionProperty($this->quests, 'gameScene')->setValue($this->quests, $this->scene); + new ReflectionProperty($this->quests, 'log')->setValue($this->quests, new QuestLog()); + $items = makeBareScene(ItemStore::class); + $items->set('reward.tonic', new Item('Reward Tonic', 'Current reward.', '!', 1, id: 'reward.tonic')); + ConfigStore::put(ItemStore::class, $items); + $objective = new QuestObjective(QuestObjectiveType::TALK_TO, 'private-target', 2, 'Ask the witness.', 'SECRET REVEALED TARGET', [['type' => 'switch', 'name' => 'reveal', 'value' => true]]); + $this->longText = implode("\n", array_map(fn($i) => sprintf('Authored line %03d with manual and S-Potion.', $i), range(1, 60))); + $entries = []; + foreach (range(0, 39) as $i) { + $quest = new Quest('quest.' . $i, 'Quest ' . $i, $this->longText, 'The Giver', [$objective], + ['gold' => 123, 'experience' => 456, 'items' => [['item' => 'reward.tonic', 'quantity' => 3]]], isOptional: true); + $entries[$quest->id] = $quest; + $this->quests->log->accept($quest->id, 1); + } + new ReflectionProperty($this->quests, 'quests')->setValue($this->quests, $entries); + new ReflectionProperty($this->scene, 'questManager')->setValue($this->scene, $this->quests); + $this->achievements = makeBareScene(AchievementManager::class); + new ReflectionProperty($this->achievements, 'achievements')->setValue($this->achievements, [ + 'secret' => new Achievement('secret', 'SECRET NAME', 'SECRET DESCRIPTION', 'PRIVATE ICON', true, 50), + 'public' => new Achievement('public', 'Public record', $this->longText, '*', false, 25), + ]); + new ReflectionProperty($this->achievements, 'unlocked')->setValue($this->achievements, ['public' => '2026-09-21']); + new ReflectionProperty($this->scene, 'achievementManager')->setValue($this->scene, $this->achievements); + $catalog = new KnowledgeCatalog(['recordTypes' => ['place'], 'subjects' => [ + ['id' => 'place.known', 'recordType' => 'place', 'displayName' => 'Known Place', 'family' => 'Family', 'species' => 'Species', + 'quickCard' => 'Quick source card.', 'deepCard' => $this->longText, 'observations' => ['visit']], + ['id' => 'place.hidden', 'recordType' => 'place', 'displayName' => 'UNDISCOVERED NAME', 'quickCard' => 'UNDISCOVERED CARD', 'hidden' => true], + ], 'reports' => [ + ['id' => 'report.known', 'subject' => 'place.known', 'title' => 'Earned Report', 'summary' => 'Report summary.', 'details' => $this->longText . "\nFINAL REPORT DETAIL"], + ['id' => 'report.unearned', 'subject' => 'place.known', 'title' => 'UNEARNED REPORT', 'summary' => 'UNEARNED SUMMARY', 'displayOrder' => -1], + ]]); + $this->knowledge = new KnowledgeProgressService($catalog); + $this->knowledge->discoverSubject('place.known'); + new ReflectionProperty($this->scene, 'knowledge')->setValue($this->scene, $this->knowledge); + $this->returnState = new JournalMenuReturnState(new SceneStateContext($this->scene)); + new ReflectionProperty($this->scene, 'mainMenuState')->setValue($this->scene, $this->returnState); + $modal = makeBareScene(ModalManager::class); + new ReflectionProperty($modal, 'modals')->setValue($modal, new Stack(ModalInterface::class)); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $modal); + InputManager::setBindings(array_map(fn($key) => ['keys' => [$key]], ['confirm' => KeyCode::ENTER, 'cancel' => KeyCode::ESCAPE, + 'up' => KeyCode::UP, 'down' => KeyCode::DOWN, 'character_next' => KeyCode::Q, 'character_previous' => KeyCode::E])); + $this->runtime = null; + ob_start(); +}); + +afterEach(function () { + $this->runtime?->shutdown(); + ob_end_clean(); + foreach ($this->savedStatics as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +it('keeps owner content and focus identical through two themes within serialized budgets', function (bool $records, bool $alternative) { + $state = journalMenuOwner($this, $records); + $theme = new MenuPresentationCatalog($this->root, journalMenuTheme($alternative)); + $before = [$this->quests->log->toArray(), $this->knowledge->progress->toArray(), $this->achievements->toArray()]; + foreach ([false, true] as $details) { + if ($details) { journalMenuKey($state, KeyCode::ENTER); } + $content = $state->getPresentationContent(); + $page = $state->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme)); + $frame = JournalMenuPresentation::compose($content, $theme, $page); + $text = journalMenuText($frame); + expect($content->detailsOpen)->toBe($details)->and(implode('', $text)) + ->not->toContain('SECRET')->not->toContain('UNEARNED')->not->toContain('UNDISCOVERED'); + if ($records) { expect(implode('', $text))->toContain($details ? 'Details - Lines' : 'View details'); } + $ids = [...array_column($frame->textLayers, 'id'), ...array_column($frame->images, 'id')]; + expect(count(array_unique($ids)))->toBe(count($ids))->and(count($frame->textLayers))->toBeLessThanOrEqual(64) + ->and($frame->toArray()['width'])->toBe(1350); + if ($alternative) { expect($frame->images)->not->toBeEmpty(); } + } + expect([$this->quests->log->toArray(), $this->knowledge->progress->toArray(), $this->achievements->toArray()])->toBe($before); +})->with([false, true])->with([false, true]); + +it('reaches complete long quest and earned report text in terminal and graphical views', function (bool $records, bool $graphical) { + $this->knowledge->unlockReport('place.known', 'report.known', 'fixture'); + $state = journalMenuOwner($this, $records); + if ($graphical) { journalMenuRuntime($this, journalMenuTheme()); } + journalMenuKey($state, KeyCode::ENTER); + $seen = []; + for ($i = 0; $i < 180; $i++) { + $frame = $graphical ? $state->getPresentationCanvas() : null; + if ($graphical) { expect($frame)->toBeInstanceOf(PresentationCanvas::class); } + $page = new ReflectionProperty($state, 'displayedPage')->getValue($state); + foreach ($page->lines as $line => $text) { $seen[$page->first + $line] = $text; } + if ($graphical) { + $painted = array_filter(journalMenuText($frame), fn($id) => $id === 'journal-text' || str_starts_with($id, 'journal-text-'), ARRAY_FILTER_USE_KEY); + expect(implode('', $painted))->toBe(implode('', $page->lines)); + } + else { + $panel = new ReflectionProperty($state, $records ? 'infoPanel' : 'listPanel')->getValue($state); + $painted = array_slice($panel->getContent(), 0, count($page->lines)); + expect(array_map(TerminalText::stripAnsi(...), $painted))->toBe($page->lines); + } + if ($page->first + count($page->lines) === $page->total) { break; } + journalMenuKey($state, KeyCode::DOWN); + } + ksort($seen); + expect(array_values($seen))->toBe(array_column(TextViewport::wrap($page->source, $page->columns), 1)) + ->and(implode('', $seen))->toContain('Authored line 001')->toContain('Authored line 060') + ->toContain($records ? 'FINAL REPORT DETAIL' : 'Reward: 123 G, 456 EXP, Reward Tonic x3'); + expect($state->getPresentationContent()->index)->toBe(0); + journalMenuKey($state, KeyCode::ESCAPE); + expect($state->getPresentationContent()->detailsOpen)->toBeFalse()->and($this->returnState->entries)->toBe(0); + journalMenuKey($state, KeyCode::ESCAPE); + expect($this->returnState->entries)->toBe(1); +})->with([false, true])->with([false, true]); + +it('keeps secret and discovery masking and resets reading when earned content changes', function () { + $state = journalMenuOwner($this, true); + expect($state->getPresentationContent()->detailText)->toContain('Quick source card.')->not->toContain('Authored line'); + journalMenuKey($state, KeyCode::Q); + $secret = $state->getPresentationContent(); + expect($secret->rows[0]['label'])->toBe('???')->and($secret->detailText)->not->toContain('SECRET')->not->toContain('PRIVATE'); + journalMenuKey($state, KeyCode::DOWN); + journalMenuKey($state, KeyCode::ENTER); + journalMenuKey($state, KeyCode::DOWN); + expect($state->getPresentationPage(104, 4)->first)->toBeGreaterThan(0); + journalMenuKey($state, KeyCode::Q); + expect($state->getPresentationContent()->detailsOpen)->toBeFalse()->and($state->getPresentationContent()->index)->toBe(0); + $this->knowledge->unlockReport('place.known', 'report.known', 'fixture'); + $state->resume(); + expect($state->getPresentationPage(104, 4)->first)->toBe(0) + ->and($state->getPresentationContent()->detailText)->toContain('Authored line 060')->toContain('Earned Report')->not->toContain('UNEARNED'); + $this->knowledge->withdrawReport('place.known', 'report.known', 'fixture'); + expect($state->getPresentationContent()->detailText)->toContain('Withdrawn: Report summary.'); +}); + +it('follows long lists preserves same selection on back and retains completed tab semantics', function () { + $state = journalMenuOwner($this, false); + for ($i = 0; $i < 35; $i++) { journalMenuKey($state, KeyCode::DOWN); } + $panel = new ReflectionProperty($state, 'listPanel')->getValue($state); + expect(count($panel->getContent()))->toBe($panel->getContentHeight()) + ->and(implode('', array_map(TerminalText::stripAnsi(...), $panel->getContent())))->toContain('Quest 35'); + journalMenuKey($state, KeyCode::ENTER); + journalMenuKey($state, KeyCode::TAB); + expect($state->getPresentationContent()->tabIndex)->toBe(0)->and($state->getPresentationContent()->detailsOpen)->toBeTrue(); + journalMenuKey($state, KeyCode::ESCAPE); + expect($state->getPresentationContent()->index)->toBe(35); + $this->quests->log->markCompleted('quest.35'); + journalMenuKey($state, KeyCode::TAB); + expect($state->getPresentationContent()->tabIndex)->toBe(1)->and($state->getPresentationContent()->detailText)->toContain('[Complete] Ask the witness. (2/2)'); + $this->flags->setSwitch('reveal', true); + $state->resume(); + expect($state->getPresentationContent()->detailText)->toContain('SECRET REVEALED TARGET')->not->toContain('Ask the witness.'); +}); + +it('consumes confirm and back once even when a binding also drives navigation', function (bool $records) { + $state = journalMenuOwner($this, $records); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::DOWN]], 'down' => ['keys' => [KeyCode::DOWN]], + 'cancel' => ['keys' => [KeyCode::UP]], 'up' => ['keys' => [KeyCode::UP]]]); + journalMenuKey($state, KeyCode::DOWN); + expect($state->getPresentationContent()->detailsOpen)->toBeTrue()->and($state->getPresentationContent()->index)->toBe(0) + ->and($state->getPresentationPage(100, 4)->first)->toBe(0); + journalMenuKey($state, KeyCode::UP); + expect($state->getPresentationContent()->detailsOpen)->toBeFalse()->and($this->returnState->entries)->toBe(0); +})->with([false, true]); + +it('submits through the existing renderer and retains reading position around supported and unsupported modals', function (bool $records) { + $this->knowledge->unlockReport('place.known', 'report.known', 'fixture'); + journalMenuRuntime($this, journalMenuTheme()); + $state = journalMenuOwner($this, $records); + journalMenuKey($state, KeyCode::ENTER); + journalMenuKey($state, KeyCode::DOWN); + $before = $state->getPresentationPage(100, 4); + expect($this->runtime->present($this->scene))->toBeTrue(); + expect(array_filter($this->transport->sent, fn($message) => isset($message->payload['canvas'])))->not->toBeEmpty(); + $stack = new ReflectionProperty($this->game->modalManager, 'modals')->getValue($this->game->modalManager); + $modal = new AlertModal($this->game, 'Owner notice.', 'Notice'); + $stack->push($modal); + new ReflectionProperty($modal, 'isShowing')->setValue($modal, true); + expect($this->runtime->present($this->scene))->toBeTrue() + ->and(implode('', journalMenuText($this->scene->getPresentationCanvas())))->toContain('Owner notice.'); + new ReflectionProperty($modal, 'isShowing')->setValue($modal, false); + expect(implode('', journalMenuText($this->scene->getPresentationCanvas())))->not->toContain('Owner notice.'); + $stack->pop(); + $prompt = new PromptModal($this->game, 'Actual prompt', 'Prompt'); + $stack->push($prompt); + new ReflectionProperty($prompt, 'isShowing')->setValue($prompt, true); + expect($this->scene->getPresentationCanvas())->toBeNull()->and($state->getPresentationPage(100, 4))->toEqual($before); + $stack->pop(); + expect($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class) + ->and($state->getPresentationContent()->detailsOpen)->toBeTrue()->and($this->scene->state)->toBe($state); +})->with([false, true]); + +it('retains useful terminal reading and diagnostics for absent or unavailable graphical presentation', function (bool $records, string $reason) { + $theme = $reason === 'absent' ? null : journalMenuTheme(); + if ($reason === 'invalid') { $theme['frames'] = ['panel' => ['asset' => 'missing.png']]; } + if ($reason === 'viewport') { $theme['metrics'] = ['panelPadding' => 200]; } + journalMenuRuntime($this, $theme, $reason === 'capabilities' ? [] : null); + $state = journalMenuOwner($this, $records); + expect($this->scene->getPresentationCanvas())->toBeNull(); + journalMenuKey($state, KeyCode::ENTER); + journalMenuKey($state, KeyCode::DOWN); + expect($state->getPresentationContent()->detailsOpen)->toBeTrue(); + $panel = new ReflectionProperty($state, $records ? 'infoPanel' : 'listPanel')->getValue($state); + expect(implode('', $panel->getContent()))->not->toBe(''); + if ($reason !== 'absent') { expect(file_get_contents($this->root . '/logs/error.log'))->toContain('Menu presentation degraded to terminal'); } +})->with([false, true])->with(['absent', 'capabilities', 'invalid', 'viewport']); + +it('handles empty tabs without synthetic records or detail focus and resumes after source removal', function (bool $records) { + if ($records) { + new ReflectionProperty($this->scene, 'knowledge')->setValue($this->scene, new KnowledgeProgressService(KnowledgeCatalog::empty())); + } else { new ReflectionProperty($this->quests, 'quests')->setValue($this->quests, []); } + $state = journalMenuOwner($this, $records); + journalMenuKey($state, KeyCode::ENTER); + $content = $state->getPresentationContent(); + $theme = new MenuPresentationCatalog($this->root, journalMenuTheme()); + $frame = JournalMenuPresentation::compose($content, $theme, $state->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme))); + expect($content->rows)->toBeEmpty()->and($content->detailsOpen)->toBeFalse() + ->and(implode('', journalMenuText($frame)))->toContain($content->emptyText)->not->toContain('View details'); + expect(array_filter($frame->textLayers, fn($layer) => str_starts_with($layer->id, 'journal-entry')))->toBeEmpty(); + $state->resume(); + expect($state->getPresentationContent()->index)->toBe(0); +})->with([false, true]); + +it('marks long localized list previews and keeps their full values readable with replaceable theme artwork', function (bool $alternative) { + $name = str_repeat("\u{65E5}\u{672C}\u{8A9E} cafe\u{0301} ", 25); + $quest = $this->quests->quests['quest.0']; + new ReflectionProperty($quest, 'name')->setValue($quest, $name); + new ReflectionProperty($quest, 'giver')->setValue($quest, 'Giver ' . str_repeat('Longword', 40)); + $objectives = array_map(fn($i) => new QuestObjective(QuestObjectiveType::FLAG, 'flag.' . $i, 1, 'Objective ' . $i), range(1, 30)); + new ReflectionProperty($quest, 'objectives')->setValue($quest, $objectives); + $state = journalMenuOwner($this, false); + $theme = new MenuPresentationCatalog($this->root, journalMenuTheme($alternative)); + $content = $state->getPresentationContent(); + $page = $state->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme, 960, 540)); + $frame = JournalMenuPresentation::compose($content, $theme, $page, width: 960, height: 540); + expect(implode('', array_filter(journalMenuText($frame), fn($id) => str_starts_with($id, 'journal-entry-0-'), ARRAY_FILTER_USE_KEY)))->toContain('...') + ->and($content->detailText)->toContain($name)->toContain($quest->giver)->toContain('Objective 30 (0/1)'); + journalMenuPng($this->root . '/surface.png', 79, 43); + $second = JournalMenuPresentation::compose($content, $theme, $page, width: 960, height: 540); + expect($second->toArray()['width'])->toBe(960)->and(journalMenuText($second))->toBe(journalMenuText($frame)); + journalMenuKey($state, KeyCode::ENTER); + $reading = new ReflectionProperty($state, 'reading')->getValue($state); + $snapshot = clone $reading; + $state->getPresentationPage(40, 3); + $state->getPresentationPage(100, 20); + expect($reading)->toEqual($snapshot); +})->with([false, true]); + +it('keeps quest navigation visible while independently presenting structured sections and optional icons', function (bool $alternative) { + $quest = $this->quests->quests['quest.0']; + new ReflectionProperty($quest, 'description')->setValue($quest, 'A short account of this quest.'); + $state = journalMenuOwner($this, false); + $data = journalMenuTheme($alternative); + $data['icons'] = array_fill_keys(['journal.quests', 'quest.optional', 'journal.description', 'journal.objectives', + 'journal.rewards', 'objective.open'], 'surface.png'); + $theme = new MenuPresentationCatalog($this->root, $data); + foreach ([[1350, 720], [960, 540], [1600, 900]] as [$width, $height]) { + $content = $state->getPresentationContent(); + $size = JournalMenuPresentation::pageSize($content, $theme, $width, $height); + $frame = JournalMenuPresentation::compose($content, $theme, $state->getPresentationPage(...$size), width: $width, height: $height); + $labels = journalMenuText($frame); + expect(implode('', $labels))->toContain('Quest 0')->toContain('Description'); + $list = array_find($frame->textLayers, fn($layer) => str_starts_with($layer->id, 'journal-entry-0-') && $layer->runs[0]->foreground !== null); + $detail = array_find($frame->textLayers, fn($layer) => $layer->id === 'journal-text'); + expect($list->x + $list->bounds->width)->toBeLessThan($detail->x); + expect(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'journal-icon-')))->not->toBeEmpty(); + $frame->toArray(); + } + journalMenuKey($state, KeyCode::ENTER); + $content = $state->getPresentationContent(); + $frame = JournalMenuPresentation::compose($content, $theme, $state->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme))); + expect(array_filter(journalMenuText($frame), fn($id) => str_starts_with($id, 'journal-entry-0-'), ARRAY_FILTER_USE_KEY))->not->toBeEmpty() + ->and(implode('', journalMenuText($frame)))->toContain('Description')->toContain('Objectives')->toContain('Rewards'); + expect(array_keys(array_filter(journalMenuText($frame), fn($id) => str_starts_with($id, 'journal-marker-'), ARRAY_FILTER_USE_KEY)))->toBeEmpty(); + $unknownOnly = new MenuPresentationCatalog($this->root, [...journalMenuTheme(), 'icons' => ['unknown' => 'surface.png']]); + $plain = JournalMenuPresentation::compose($content, $unknownOnly, $state->getPresentationPage(...JournalMenuPresentation::pageSize($content, $unknownOnly))); + expect($plain->images)->toBeEmpty()->and(implode('', journalMenuText($plain)))->toContain("\u{25CB}")->toContain('Objectives'); +})->with([false, true]); + +it('retains semantic section metadata through wrapping and source normalization without parsing labels', function () { + $document = new \Ichiloto\Engine\UI\Presentation\JournalDocument([ + new \Ichiloto\Engine\UI\Presentation\JournalSection('Translated heading', [ + ['text' => "First\r\nSecond\ttext", 'icon' => 'objective.open'], + ['text' => str_repeat('word ', 20), 'icon' => 'objective.complete', 'color' => 'increase'], + ], 'journal.objectives'), + new \Ichiloto\Engine\UI\Presentation\JournalSection('A different heading', [['text' => 'Reward body.']]), + ]); + foreach ([8, 23, 60] as $columns) { + $lines = $document->lines($columns); + expect(array_column($lines, 'text'))->toBe(array_column(TextViewport::wrap($document->text, $columns), 1)); + expect(count(array_filter($lines, fn($line) => $line['icon'] === 'objective.complete')))->toBe(1) + ->and(count(array_filter($lines, fn($line) => $line['icon'] === 'objective.open')))->toBe(1); + expect(array_find($lines, fn($line) => str_contains($line['text'], 'word'))['color'])->toBe('increase'); + } +}); + +it('bounds dense journal layers and measures wrapped category tabs', function (array $metrics, int $width, int $height) { + $quest = $this->quests->quests['quest.0']; + new ReflectionProperty($quest, 'description')->setValue($quest, 'Brief description.'); + new ReflectionProperty($quest, 'objectives')->setValue($quest, array_map(fn($i) => + new QuestObjective(QuestObjectiveType::FLAG, 'step.' . $i, 1, 'Objective ' . $i), range(1, 100))); + $state = journalMenuOwner($this, false); + $theme = new MenuPresentationCatalog($this->root, [...journalMenuTheme(), 'metrics' => $metrics]); + $content = $state->getPresentationContent(); + $page = $state->getPresentationPage(...JournalMenuPresentation::pageSize($content, $theme, $width, $height)); + $frame = JournalMenuPresentation::compose($content, $theme, $page, width: $width, height: $height); + expect(count($frame->textLayers))->toBeLessThanOrEqual(64)->and($frame->toArray()['width'])->toBe($width) + ->and(implode('', journalMenuText($frame)))->toContain('Completed (0)')->toContain('Objectives'); +})->with([ + 'dense' => [['cellWidth' => 6, 'cellHeight' => 12, 'rowHeight' => 24, 'panelPadding' => 16, 'sectionGap' => 8], 1350, 720], + 'wide glyphs' => [['cellWidth' => 16], 960, 540], +]); diff --git a/tests/Unit/JournalTerminalTabsTest.php b/tests/Unit/JournalTerminalTabsTest.php new file mode 100644 index 00000000..bf9f2140 --- /dev/null +++ b/tests/Unit/JournalTerminalTabsTest.php @@ -0,0 +1,222 @@ +exits++; } + public function header(): Window { return $this->summaryPanel; } + public function list(): Window { return $this->listPanel; } + public function info(): Window { return $this->infoPanel; } +} + +class JournalTerminalQuestState extends QuestMenuState { use JournalTerminalHeaderProbe; } +class JournalTerminalRecordsState extends RecordsMenuState { use JournalTerminalHeaderProbe; } + +function journalTerminalKey(QuestMenuState|RecordsMenuState $state, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $state->execute(); +} + +function assertJournalTerminalHeader(JournalTerminalQuestState|JournalTerminalRecordsState $state): void +{ + $header = $state->header(); + $content = $state->getPresentationContent(); + $lines = $header->getContent(); + $width = $header->getContentWidth(); + $segment = intdiv($width, count($content->tabs)); + $expected = ''; + foreach ($content->tabs as $i => $label) { + $chunk = TerminalText::padCenter($label, $segment); + $expected .= $i === $content->tabIndex ? SelectionStyle::apply($chunk) : $chunk; + expect(TerminalText::stripAnsi($lines[0]))->toContain($label); + } + $highlighted = array_keys(array_filter(TerminalText::visibleSymbols($lines[0]), fn($symbol) => str_contains($symbol, "\033["))); + expect($header)->toBeInstanceOf(MagicTabPanel::class) + ->and($lines)->toHaveCount(2) + ->and($lines[0])->toBe(TerminalText::padRight($expected, $width)) + ->and(SelectionStyle::resolveColor())->toBe(Color::YELLOW) + ->and($highlighted)->toBe(range($content->tabIndex * $segment, ($content->tabIndex + 1) * $segment - 1)) + ->and($lines[1])->toBe($content->summary) + ->and(TerminalText::displayWidth($lines[0]))->toBe($width); +} + +beforeEach(function () { + $this->savedStatics = []; + foreach ([Console::class, InputManager::class, ConfigStore::class] as $class) { + $this->savedStatics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + ConfigStore::put(ProjectConfig::class, new SceneAudioConfigStub(['ui' => ['menu' => ['selection_color' => Color::YELLOW]]])); + Console::setTerminalOutputEnabled(false); + new ReflectionProperty(Console::class, 'game')->setValue(null, null); + new ReflectionProperty(InputManager::class, 'eventManager')->setValue(null, null); + InputManager::setBindings(array_map(fn($key) => ['keys' => [$key]], [ + 'confirm' => KeyCode::ENTER, 'cancel' => KeyCode::ESCAPE, 'character_next' => KeyCode::Q, 'character_previous' => KeyCode::E, + 'up' => KeyCode::UP, 'down' => KeyCode::DOWN, + ])); + $this->scene = makeBareScene(GameScene::class); + $ui = makeBareScene(UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + new ReflectionProperty($this->scene, 'gameState')->setValue($this->scene, new GameState()); + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, new Party()); + $quests = makeBareScene(QuestManager::class); + $log = new QuestLog(); + $definitions = []; + foreach (['active', 'completed'] as $id) { + $definitions[$id] = new Quest($id, ucfirst($id), 'Journal text.', objectives: [new QuestObjective(QuestObjectiveType::FLAG, 'done')]); + $log->accept($id, 1); + } + $log->markCompleted('completed'); + new ReflectionProperty($quests, 'log')->setValue($quests, $log); + new ReflectionProperty($quests, 'quests')->setValue($quests, $definitions); + new ReflectionProperty($quests, 'gameScene')->setValue($quests, $this->scene); + new ReflectionProperty($this->scene, 'questManager')->setValue($this->scene, $quests); + $achievements = makeBareScene(AchievementManager::class); + new ReflectionProperty($achievements, 'achievements')->setValue($achievements, ['earned' => new Achievement('earned', 'Earned', 'Description.', points: 7)]); + new ReflectionProperty($achievements, 'unlocked')->setValue($achievements, ['earned' => '2026-09-21']); + new ReflectionProperty($this->scene, 'achievementManager')->setValue($this->scene, $achievements); + $knowledge = new KnowledgeProgressService(new KnowledgeCatalog(['recordTypes' => ['place'], 'subjects' => [ + ['id' => 'place.known', 'recordType' => 'place', 'displayName' => 'Known place', 'quickCard' => 'Source card.'], + ]])); + $knowledge->discoverSubject('place.known'); + new ReflectionProperty($this->scene, 'knowledge')->setValue($this->scene, $knowledge); + new ReflectionProperty($this->scene, 'mainMenuState')->setValue($this->scene, new MainMenuState(new SceneStateContext($this->scene))); +}); + +afterEach(function () { + foreach ($this->savedStatics as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } +}); + +it('uses the shared horizontal highlighted tab strip and preserves owner tab and detail actions', function (bool $records, int $width) { + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => $width, 'height' => 36])); + Console::syncDimensions($width, 36); + $state = $records ? new JournalTerminalRecordsState(new SceneStateContext($this->scene)) : new JournalTerminalQuestState(new SceneStateContext($this->scene)); + $state->enter(); + assertJournalTerminalHeader($state); + $initial = $state->getPresentationContent()->tabIndex; + foreach ([KeyCode::Q, KeyCode::E, KeyCode::TAB, KeyCode::SHIFT_TAB] as $key) { + $previous = $state->getPresentationContent()->tabIndex; + journalTerminalKey($state, $key); + expect($state->getPresentationContent()->tabIndex)->toBe(1 - $previous); + assertJournalTerminalHeader($state); + } + expect($state->getPresentationContent()->tabIndex)->toBe($initial); + journalTerminalKey($state, KeyCode::ENTER); + expect($state->getPresentationContent()->detailsOpen)->toBeTrue(); + assertJournalTerminalHeader($state); + journalTerminalKey($state, KeyCode::ESCAPE); + expect($state->getPresentationContent()->detailsOpen)->toBeFalse()->and($state->exits)->toBe(0); + assertJournalTerminalHeader($state); + journalTerminalKey($state, KeyCode::ESCAPE); + expect($state->exits)->toBe(1); +})->with([false, true])->with([80, 135]); + +it('puts list pagination in the supported bottom-left border and uses every content row', function (bool $records) { + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + Console::syncDimensions(135, 36); + if ($records) { + $subjects = array_map(fn($i) => ['id' => 'place.' . $i, 'recordType' => 'place', 'displayName' => sprintf('Entry %02d', $i), 'quickCard' => 'Card.'], range(0, 39)); + $knowledge = new KnowledgeProgressService(new KnowledgeCatalog(['recordTypes' => ['place'], 'subjects' => $subjects])); + foreach ($subjects as $subject) { $knowledge->discoverSubject($subject['id']); } + new ReflectionProperty($this->scene, 'knowledge')->setValue($this->scene, $knowledge); + $state = new JournalTerminalRecordsState(new SceneStateContext($this->scene)); + } else { + $quests = []; + $log = new QuestLog(); + foreach (range(0, 39) as $i) { + $quests['quest.' . $i] = new Quest('quest.' . $i, sprintf('Entry %02d', $i)); + $log->accept('quest.' . $i, 0); + } + new ReflectionProperty($this->scene->questManager, 'quests')->setValue($this->scene->questManager, $quests); + new ReflectionProperty($this->scene->questManager, 'log')->setValue($this->scene->questManager, $log); + $state = new JournalTerminalQuestState(new SceneStateContext($this->scene)); + } + $state->enter(); + $list = $state->list(); + $capacity = $list->getContentHeight(); + foreach ([false, true] as $last) { + if ($last) { journalTerminalKey($state, KeyCode::UP); } + $expected = $last ? sprintf('%d-40 / 40', 41 - $capacity) : sprintf('1-%d / 40', $capacity); + expect($list->getHelp())->toBe($expected) + ->and(count(array_filter($list->getContent(), fn($line) => trim(TerminalText::stripAnsi($line)) !== '')))->toBe($capacity) + ->and(implode('', $list->getContent()))->not->toContain($expected); + $border = new ReflectionMethod(Window::class, 'getBottomBorder')->invoke($list); + expect($border)->toStartWith($list->getBorderPack()->getBottomLeftCorner() . $list->getBorderPack()->getHorizontalBorder() . $expected) + ->and(TerminalText::displayWidth($border))->toBe($list->getWidth()); + } + expect($state->getPresentationContent()->index)->toBe(39); +})->with([false, true]); + +it('moves detail ranges to border help and keeps complete scrolling and back focus', function (bool $records) { + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + Console::syncDimensions(135, 36); + $text = implode("\n", array_map(fn($i) => 'Authored detail ' . $i, range(1, 60))); + if ($records) { + $knowledge = new KnowledgeProgressService(new KnowledgeCatalog(['recordTypes' => ['place'], 'subjects' => [ + ['id' => 'place.known', 'recordType' => 'place', 'displayName' => 'Known place', 'quickCard' => $text], + ]])); + $knowledge->discoverSubject('place.known'); + new ReflectionProperty($this->scene, 'knowledge')->setValue($this->scene, $knowledge); + $state = new JournalTerminalRecordsState(new SceneStateContext($this->scene)); + } else { + new ReflectionProperty(Quest::class, 'description')->setValue($this->scene->questManager->quests['active'], $text); + $state = new JournalTerminalQuestState(new SceneStateContext($this->scene)); + } + $state->enter(); + journalTerminalKey($state, KeyCode::ENTER); + $panel = $records ? $state->info() : $state->list(); + $seen = []; + for ($i = 0; $i < 100; $i++) { + $page = $state->getPresentationPage($state->list()->getContentWidth() - 2, $panel->getContentHeight()); + expect($panel->getHelp())->toBe(($records ? 'Details - ' : '') . $page->range()) + ->and(array_slice($panel->getContent(), 0, count($page->lines)))->toBe($page->lines) + ->and(implode('', $panel->getContent()))->not->toContain($page->range()); + foreach ($page->lines as $line => $value) { $seen[$page->first + $line] = $value; } + if ($page->first + count($page->lines) === $page->total) { break; } + journalTerminalKey($state, KeyCode::DOWN); + } + expect(implode("\n", $seen))->toContain($text)->and($state->getPresentationContent()->index)->toBe(0); + journalTerminalKey($state, KeyCode::ESCAPE); + expect($state->getPresentationContent()->detailsOpen)->toBeFalse()->and($state->list()->getHelp())->toBe('1-1 / 1') + ->and($state->info()->getHelp())->toBe('')->and($state->exits)->toBe(0); +})->with([false, true]); diff --git a/tests/Unit/LocalClockTest.php b/tests/Unit/LocalClockTest.php new file mode 100644 index 00000000..2346c128 --- /dev/null +++ b/tests/Unit/LocalClockTest.php @@ -0,0 +1,81 @@ +phpTimezone = date_default_timezone_get(); + $this->hostTimezone = getenv('TZ'); + date_default_timezone_set('UTC'); +}); + +afterEach(function () { + date_default_timezone_set($this->phpTimezone); + putenv($this->hostTimezone === false ? 'TZ' : 'TZ=' . $this->hostTimezone); +}); + +it('uses host-local evening rather than the PHP UTC hour', function () { + putenv('TZ=Africa/Lusaka'); + $instant = new DateTimeImmutable('2026-09-22T17:48:00Z'); + $local = LocalClock::getTime($instant); + $title = new TitlePlayback(); + $title->advance(0, $local, false); + + expect($local->format('H:i P'))->toBe('19:48 +02:00') + ->and($local->getTimestamp())->toBe($instant->getTimestamp()) + ->and($title->nightWeight)->toBe(1.0) + ->and(get_local_timezone())->toBe('Africa/Lusaka') + ->and(date_default_timezone_get())->toBe('UTC'); +}); + +it('converts host-local day boundaries before choosing scenery', function (string $utc, float $night) { + putenv('TZ=Africa/Lusaka'); + $title = new TitlePlayback(); + $title->advance(0, LocalClock::getTime(new DateTimeImmutable($utc)), false); + expect($title->nightWeight)->toBe($night); +})->with([ + ['2026-09-22T03:59:59Z', 1.0], ['2026-09-22T04:00:00Z', 0.0], + ['2026-09-22T15:59:59Z', 0.0], ['2026-09-22T16:00:00Z', 1.0], +]); + +it('retains regional daylight-saving rules rather than freezing an offset', function () { + putenv('TZ=America/New_York'); + expect(LocalClock::getTime(new DateTimeImmutable('2026-01-22T12:00:00Z'))->format('H:i P'))->toBe('07:00 -05:00') + ->and(LocalClock::getTime(new DateTimeImmutable('2026-07-22T12:00:00Z'))->format('H:i P'))->toBe('08:00 -04:00'); +}); + +it('rechecks a changed host timezone without changing application time', function () { + $instant = new DateTimeImmutable('2026-09-22T17:48:00Z'); + putenv('TZ=Africa/Lusaka'); + expect(LocalClock::getTime($instant)->format('H:i'))->toBe('19:48'); + putenv('TZ=America/New_York'); + expect(LocalClock::getTime($instant)->format('H:i'))->toBe('13:48') + ->and(date_default_timezone_get())->toBe('UTC'); +}); + +it('accepts colon-prefixed host zone identifiers', function () { + putenv('TZ=:Pacific/Auckland'); + expect(LocalClock::getTimezone()->getName())->toBe('Pacific/Auckland'); +}); + +it('falls through an invalid environment zone to the host configuration', function () { + putenv('TZ'); + $host = LocalClock::getTimezone()->getName(); + putenv('TZ=Not/A_Timezone'); + expect(LocalClock::getTimezone()->getName())->toBe($host); +}); + +it('uses the local clock when title playback receives no explicit test time', function () { + // Force the same instant to opposite local dayparts without changing any clock. + $utcHour = (int)gmdate('G'); + foreach ([12 => 0.0, 0 => 1.0] as $hour => $night) { + $offset = (($hour - $utcHour + 36) % 24) - 12; + putenv('TZ=' . sprintf('%+03d:00', $offset)); + $title = new TitlePlayback(); + $title->advance(0, null, false); + expect($title->nightWeight)->toBe($night); + } + expect(date_default_timezone_get())->toBe('UTC'); +}); diff --git a/tests/Unit/MainMenuPresentationTest.php b/tests/Unit/MainMenuPresentationTest.php new file mode 100644 index 00000000..e2a2470b --- /dev/null +++ b/tests/Unit/MainMenuPresentationTest.php @@ -0,0 +1,741 @@ + $this->saveAllowed; } + public function canvas(MenuPresentationCatalog $theme, float $time = 0): ?PresentationCanvas { return $this->composeMenuCanvas($theme, $time); } +} + +function mainMenuPresentationPng(string $path, int $width, int $height): void +{ + $chunk = static fn(string $type, string $bytes) => pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAD\xBE\xCF\xFF", $width), $height))) . $chunk('IEND', '')); +} + +function mainMenuPresentationTheme(bool $alternative): array +{ + $theme = ['schema' => 'ichiloto.menu/1', 'icons' => ['unknown' => 'cursor.png'], 'cursor' => 'cursor.png', + 'portraits' => ['actor.0' => 'portrait.png', 'actor.1' => 'portrait.png', 'actor.2' => 'portrait.png', 'actor.3' => 'portrait.png']]; + if (!$alternative) { return $theme; } + $art = ['asset' => 'surface.png', 'cuts' => [3, 4, 3, 4]]; + return [...$theme, 'colors' => ['text' => [43, 30, 10], 'background' => [237, 230, 210], 'panel' => [245, 235, 225]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 34, 'panelPadding' => 16, 'sectionGap' => 8, 'portraitSize' => 88], + 'rowMetrics' => ['padding' => 24, 'iconWidth' => 32, 'iconHeight' => 12, 'cursorWidth' => 10, 'cursorHeight' => 20, + 'cursorInset' => 6, 'cursorTravel' => 3, 'cursorPeriod' => 2], + 'rowArtwork' => ['normal' => $art, 'selected' => $art, 'focus' => ['asset' => 'focus.png'], + 'command.selected' => $art, 'command.focus' => ['asset' => 'focus.png']], + 'frames' => ['panel' => $art, 'quiet' => $art, 'portrait' => $art]]; +} + +function mainMenuPresentationText(PresentationCanvas $frame): array +{ + $text = []; + foreach ($frame->textLayers as $layer) { + $runs = array_column(array_filter($layer->runs, fn($run) => $run->foreground !== null), 'text'); + if ($runs !== []) { $text[$layer->id] = implode('', $runs); } + } + return $text; +} + +function mainMenuPresentationFrameBounds(PresentationCanvas $frame, string $id): CanvasRectangle +{ + $layers = array_column($frame->textLayers, null, 'id'); + if (isset($layers[$id])) { return $layers[$id]->clipRect; } + $pieces = array_filter($frame->images, fn($image) => str_starts_with($image->id, $id . '-')); + $boxes = array_column($pieces, 'destination'); + $x = min(array_column($boxes, 'x')); + $y = min(array_column($boxes, 'y')); + return new CanvasRectangle($x, $y, + max(array_map(fn($box) => $box->x + $box->width, $boxes)) - $x, + max(array_map(fn($box) => $box->y + $box->height, $boxes)) - $y); +} + +function mainMenuPresentationRuntime(string $root, array $caps): RendererRuntime +{ + $transport = new FakeRendererTransport(); + $transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $root, + requiredCapabilities: $caps), $transport); + $runtime->start('Main Menu test', 135, 36); + return $runtime; +} + +function mainMenuPresentationKey(KeyCode $key, MainMenuState $state): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $state->getPresentationMode()->update(); +} + +beforeEach(function () { + $this->savedStatics = []; + foreach ([Console::class, InputManager::class, ActionHints::class, ConfigStore::class, Debug::class, ModalManager::class, AudioManager::class] as $class) { + $this->savedStatics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-main-menu-' . bin2hex(random_bytes(5)); + mkdir($this->root . '/Data/Presentation', 0777, true); + foreach (['portrait', 'cursor', 'surface', 'focus'] as $name) { mainMenuPresentationPng($this->root . '/' . $name . '.png', 73, 41); } + Debug::configure(['log_directory' => $this->root . '/logs']); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + ConfigStore::put(\Ichiloto\Engine\Progress\Knowledge\KnowledgeCatalog::class, new SceneAudioConfigStub()); + ConfigStore::put(ProjectConfig::class, new SceneAudioConfigStub(['vocab' => ['currency' => ['name' => 'Tokens', 'symbol' => 'T']]])); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + $this->game = new MainMenuPresentationGame(); + new ReflectionProperty(AudioManager::class, 'instance')->setValue(null, null); + $audio = new class extends AudioManager { + public function __construct() {} + public function playSystemSound(\Ichiloto\Engine\Audio\Enumerations\SystemSound $sound): void {} + }; + new ReflectionProperty($this->game, 'audioManager')->setValue($this->game, $audio); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $manager = makeBareScene(SceneManager::class); + new ReflectionProperty($manager, 'game')->setValue($manager, $this->game); + $this->scene = makeBareScene(GameScene::class); + new ReflectionProperty($this->scene, 'sceneManager')->setValue($this->scene, $manager); + new ReflectionProperty($this->scene, 'gameState')->setValue($this->scene, new GameState()); + $ui = makeBareScene(\Ichiloto\Engine\UI\UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + $this->party = new Party(); + $this->party->location = new PartyLocation('Owner Location', 'Owner Region'); + $this->party->accountBalance = 4321; + for ($i = 0; $i < 4; $i++) { + $this->party->addMember(new Character('Actor ' . $i, 0, new Stats(totalHp: 901 + $i, totalMp: 123 + $i), actorId: 'actor.' . $i)); + } + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, $this->party); + $this->state = new MainMenuPresentationProbe(new SceneStateContext($this->scene)); + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $this->state); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::ENTER]], 'cancel' => ['keys' => [KeyCode::C]]]); + $this->state->enter(); + $this->runtime = null; + ob_start(); +}); + +afterEach(function () { + $this->runtime?->shutdown(); + ob_end_clean(); + foreach ($this->savedStatics as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +it('projects all current fields and commands through two themes with independent unique portraits', function () { + $frames = []; + foreach ([false, true] as $alternative) { + $frame = $this->state->canvas(new MenuPresentationCatalog($this->root, mainMenuPresentationTheme($alternative))); + $frames[] = $frame; + $text = mainMenuPresentationText($frame); + $layers = array_column($frame->textLayers, null, 'id'); + foreach ($this->state->mainMenu->getItems() as $index => $command) { + $label = $layers['main-command-' . $index . '-text']; + expect($text[$label->id])->toBe($command->getLabel()) + ->and($label->x + $label->grid->columns * $label->grid->cellWidth / 2)->toBe($label->clipRect->x + $label->clipRect->width / 2); + } + expect($text['main-info-value'])->toBe($this->state->mainMenu->getActiveItem()->getDescription()) + ->and($text['main-money-title'])->toBe('Tokens')->and($text['main-money-value'])->toBe('4,321 T') + ->and($text['main-location-value'])->toBe('Owner LocationOwner Region') + ->and($text['main-time-value'])->toBe($this->state->getPresentationSummaries()['time']['lines'][0]); + foreach ($this->party->members as $index => $member) { + expect($text['main-party-' . $index . '-identity-text'])->toBe($member->name) + ->and($text['main-party-' . $index . '-role'])->toBe('Role: ' . $member->role->name) + ->and($text['main-party-' . $index . '-resources-hp-text'])->toBe('HP' . $member->effectiveStats->currentHp . ' / ' . $member->effectiveStats->totalHp) + ->and($text['main-party-' . $index . '-resources-mp-text'])->toBe('MP' . $member->effectiveStats->currentMp . ' / ' . $member->effectiveStats->totalMp); + } + $ids = [...array_column($frame->images, 'id'), ...array_column($frame->textLayers, 'id')]; + expect(count(array_unique($ids)))->toBe(count($ids)) + ->and(count(array_filter($frame->images, fn($image) => $image->asset === 'portrait.png')))->toBe(4) + ->and($layers)->not->toHaveKey('main-command-0-separator'); + $rules = array_filter($frame->textLayers, fn($layer) => str_contains($layer->id, '-resources-') && str_ends_with($layer->id, '-separator')); + expect(array_sum(array_map(fn($layer) => count($layer->runs), $rules)))->toBe(12); + foreach ($frame->images as $image) { $image->destination->assertWithin(1350, 720); } + } + expect(mainMenuPresentationText($frames[0]))->toBe(mainMenuPresentationText($frames[1])); +}); + +it('preserves unoccupied frames and supports absent portrait mappings', function (int $count) { + $members = array_slice($this->party->members->toArray(), 0, $count); + new ReflectionProperty($this->party, 'members')->setValue($this->party, new ItemList(\Ichiloto\Engine\Entities\Interfaces\CharacterInterface::class, $members)); + $this->state->characterSelectionMenu->refreshMembers(); + $frame = $this->state->canvas(new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1'])); + $layers = array_column($frame->textLayers, null, 'id'); + expect($frame->images)->toBeEmpty(); + for ($i = 0; $i < 4; $i++) { + expect($layers)->toHaveKey('main-party-' . $i . '-frame'); + if ($i >= $count) { expect($layers)->not->toHaveKey('main-party-' . $i . '-identity-text')->not->toHaveKey('main-party-' . $i . '-identity-separator'); } + } +})->with([0, 1]); + +it('shares the bottom row with Location and meets the fourth character card with or without hints', function (bool $alternative, bool $hints) { + $data = mainMenuPresentationTheme($alternative); + $data['showInputHints'] = $hints; + $data['frames'] ??= ['panel' => ['asset' => 'surface.png', 'cuts' => [3, 4, 3, 4]]]; + $theme = new MenuPresentationCatalog($this->root, $data); + foreach ([4, 1] as $count) { + $members = array_slice($this->party->members->toArray(), 0, $count); + new ReflectionProperty($this->party, 'members')->setValue($this->party, + new ItemList(\Ichiloto\Engine\Entities\Interfaces\CharacterInterface::class, $members)); + $this->state->characterSelectionMenu->refreshMembers(); + $frame = $this->state->canvas($theme); + $location = mainMenuPresentationFrameBounds($frame, 'main-location'); + $help = mainMenuPresentationFrameBounds($frame, 'main-help'); + $lastCard = mainMenuPresentationFrameBounds($frame, 'main-party-3-frame'); + expect($help->y)->toBe($location->y)->toBe($lastCard->y + $lastCard->height) + ->and($help->height)->toBe($location->height) + ->and($help->y + $help->height)->toBe(700.0); + } +})->with([false, true])->with([false, true]); + +it('grows both bottom panels together when either contains wrapped content', function (bool $alternative, bool $hints, string $taller) { + $data = mainMenuPresentationTheme($alternative); + $data['showInputHints'] = $hints; + $data['frames'] ??= ['panel' => ['asset' => 'surface.png', 'cuts' => [3, 4, 3, 4]]]; + $theme = new MenuPresentationCatalog($this->root, $data); + if ($taller === 'location') { + new ReflectionProperty($this->state, 'locationDetailPanel')->getValue($this->state) + ->setLocation(str_repeat('Extended location name ', 6), 'Owner Region'); + } + else { $this->state->characterSelectionMenu->setHelpText(str_repeat('Extended help message ', 20)); } + $frame = $this->state->canvas($theme); + $location = mainMenuPresentationFrameBounds($frame, 'main-location'); + $help = mainMenuPresentationFrameBounds($frame, 'main-help'); + expect($help->y)->toBe($location->y) + ->and($help->height)->toBe($location->height)->toBeGreaterThan(80) + ->and($help->y + $help->height)->toBe(700.0); + $layers = array_column($frame->textLayers, null, 'id'); + foreach (['main-location-value' => $location, 'main-help-text' => $help] as $id => $box) { + $text = $layers[$id]; + expect($text->y)->toBeGreaterThanOrEqual($box->y) + ->and($text->y + $text->grid->rows * $theme->metrics->cellHeight)->toBeLessThanOrEqual($box->y + $box->height); + } +})->with([false, true])->with([false, true])->with(['location', 'help']); + +it('follows owner character focus across large parties without changing active or reserve membership', function (bool $alternative) { + for ($i = 4; $i < 12; $i++) { $this->party->addMember(new Character('Actor ' . $i, 0, new Stats(), actorId: 'actor.' . $i)); } + $before = $this->party->members->toArray(); + $battlers = $this->party->battlers->toArray(); + $this->state->setMode(new MainMenuCharacterSelectionMode($this->state)); + for ($i = 0; $i < 11; $i++) { $this->state->characterSelectionMenu->selectNext(); } + $frame = $this->state->canvas(new MenuPresentationCatalog($this->root, mainMenuPresentationTheme($alternative))); + expect(mainMenuPresentationText($frame)['main-party-11-identity-text'])->toBe('Actor 11') + ->and(mainMenuPresentationText($frame)['main-party-range'])->toEndWith('/ 12') + ->and($this->party->members->toArray())->toBe($before)->and($this->party->battlers->toArray())->toBe($battlers) + ->and($this->state->characterSelectionMenu->getActivePanelIndex())->toBe(11); + $this->state->characterSelectionMenu->selectNext(); + expect($this->state->characterSelectionMenu->getActivePanelIndex())->toBe(0); +})->with([false, true]); + +it('highlights the focused character with the command selection treatment and clears it on return', function () { + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme(false)); + $menu = $this->state->mainMenu; + $menu->setActiveItemByLabel('Status'); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $this->state->characterSelectionMenu->selectNext(); + $frame = $this->state->canvas($theme); + $layers = array_column($frame->textLayers, null, 'id'); + $character = $layers['main-party-1-identity-selected']; + $command = $layers['main-command-' . $menu->activeIndex . '-selected']; + expect($character->runs[0]->background)->toEqual($command->runs[0]->background) + ->and($character->clipRect->height)->toBeGreaterThan(100) + ->and($character->clipRect->width)->toBeGreaterThan(700) + ->and($layers)->not->toHaveKey('main-party-0-identity-selected') + ->and($this->state->characterSelectionMenu->getMarkedPanelIndex())->toBeNull(); + $portrait = array_values(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'main-party-1-portrait') && $image->asset === 'portrait.png')); + expect($portrait)->toHaveCount(1) + ->and($character->layer)->toBeLessThan($portrait[0]->layer); + mainMenuPresentationKey(KeyCode::C, $this->state); + $layers = array_column($this->state->canvas($theme)->textLayers, null, 'id'); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuCommandSelectionMode::class) + ->and($layers)->not->toHaveKey('main-party-1-identity-selected') + ->and($layers)->toHaveKey('main-command-' . $menu->activeIndex . '-selected'); +}); + +it('fills the complete padded card interior without a name cursor or persistent helpers', function () { + mainMenuPresentationPng($this->root . '/surface.png', 96, 96); + $data = mainMenuPresentationTheme(false); + $data['showInputHints'] = false; + $data['frames'] = ['panel' => ['asset' => 'surface.png', 'cuts' => [24, 24, 24, 24], 'borderWidths' => [8, 8, 8, 8]]]; + $theme = new MenuPresentationCatalog($this->root, $data); + $this->state->mainMenu->setActiveItemByLabel('Status'); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $frame = $this->state->canvas($theme); + $text = array_column($frame->textLayers, null, 'id'); + $images = array_column($frame->images, null, 'id'); + $fill = $text['main-party-0-identity-selected']; + $name = $text['main-party-0-identity-text']; + expect($fill->clipRect->toArray())->toBe(['x' => 428.0, 'y' => 68.0, 'width' => 784.0, 'height' => 124.0]) + ->and($fill->clipRect->y)->toBeLessThanOrEqual($name->y) + ->and($images['main-party-0-frame-0-1-backing']->layer)->toBeLessThan($fill->layer) + ->and($images['main-party-0-frame-0-1-border']->layer)->toBeGreaterThan($fill->layer) + ->and($images['main-party-0-frame-0-0']->layer)->toBeGreaterThan($fill->layer) + ->and(array_filter(array_keys($images), fn($id) => str_contains($id, '-identity-cursor')))->toBeEmpty() + ->and($text)->not->toHaveKey('main-hints') + ->and(mainMenuPresentationText($frame)['main-help-text'])->toBe('Select a character.') + ->and(implode(' ', mainMenuPresentationText($frame)))->toContain('Controls'); +}); + +it('keeps owner source mark destination focus and command selection independent while swapping party members', function (bool $alternative) { + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme($alternative)); + $menu = $this->state->mainMenu; + $menu->setActiveItemByLabel('Order'); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuPartyOrderMode::class); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $this->state->characterSelectionMenu->focusPanelByIndex(3); + $before = $this->party->members->toArray(); + $frame = $this->state->canvas($theme); + $ids = [...array_column($frame->images, 'id'), ...array_column($frame->textLayers, 'id')]; + foreach (['main-party-0-identity-selected', 'main-party-3-identity-selected', 'main-party-3-identity-focus', 'main-command-' . $menu->activeIndex . '-selected'] as $prefix) { + expect(array_filter($ids, fn($id) => str_starts_with($id, $prefix)))->not->toBeEmpty(); + } + expect(array_filter($ids, fn($id) => str_starts_with($id, 'main-party-0-identity-focus')))->toBeEmpty() + ->and(array_filter($ids, fn($id) => str_starts_with($id, 'main-command-' . $menu->activeIndex . '-focus')))->toBeEmpty() + ->and(mainMenuPresentationText($frame)['main-help-text'])->toBe($this->state->characterSelectionMenu->getHelpText()); + $name = array_column($frame->textLayers, null, 'id')['main-party-3-identity-text']; + expect(array_filter($frame->images, fn($image) => str_contains($image->id, '-identity-cursor')))->toBeEmpty() + ->and($this->party->members->toArray())->toBe($before); + $layers = [...$frame->images, ...$frame->textLayers]; + foreach ([0, 3] as $index) { + $prefix = 'main-party-' . $index; + $treatments = array_filter($layers, fn($layer) => str_starts_with($layer->id, $prefix . '-identity-') + && (str_contains($layer->id, '-selected') || str_contains($layer->id, '-focus') || str_contains($layer->id, '-normal'))); + $portrait = array_values(array_filter($frame->images, fn($image) => str_starts_with($image->id, $prefix . '-portrait-') && $image->asset === 'portrait.png'))[0]; + foreach ($treatments as $treatment) { + expect($treatment->layer)->toBeLessThan($portrait->layer)->toBeLessThan($name->layer) + ->toBeLessThan(array_column($frame->textLayers, null, 'id')[$prefix . '-resources-hp-text']->layer); + } + if ($alternative) { + $border = array_values(array_filter($frame->images, fn($image) => $image->id === $prefix . '-frame-0-0'))[0]; + expect($border->layer)->toBeGreaterThan(max(array_column($treatments, 'layer'))) + ->toBeLessThan($portrait->layer)->toBeLessThan($name->layer); + $portraitFrame = array_filter($frame->images, fn($image) => str_starts_with($image->id, $prefix . '-portrait-frame-')); + expect($portraitFrame)->not->toBeEmpty(); + foreach ($portraitFrame as $piece) { + expect($piece->layer)->toBeGreaterThan($border->layer)->toBeLessThan($portrait->layer); + } + } + } + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + expect($this->party->members->toArray())->toBe([$before[3], $before[1], $before[2], $before[0]]) + ->and($this->party->battlers->toArray())->toBe([$before[3], $before[1], $before[2]]) + ->and($this->state->characterSelectionMenu->getMarkedPanelIndex())->toBeNull(); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + mainMenuPresentationKey(KeyCode::C, $this->state); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuPartyOrderMode::class) + ->and($this->state->characterSelectionMenu->getMarkedPanelIndex())->toBeNull(); + mainMenuPresentationKey(KeyCode::C, $this->state); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuCommandSelectionMode::class) + ->and($menu->getActiveItem()->getLabel())->toBe('Order'); +})->with([false, true]); + +it('preserves character selection and routes the real selected actor through the existing command', function () { + $target = new class(new SceneStateContext($this->scene)) extends EquipmentMenuState { + public function enter(): void {} + }; + new ReflectionProperty($this->scene, 'equipmentMenuState')->setValue($this->scene, $target); + $this->state->mainMenu->setActiveItemByLabel('Equipment'); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $this->state->characterSelectionMenu->selectNext(); + $this->state->canvas(new MenuPresentationCatalog($this->root, mainMenuPresentationTheme(true))); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + expect($target->character)->toBe($this->party->members->toArray()[1]); +}); + +it('renders all thirteen actual command variants centered and honors disabled owner actions', function () { + $menu = $this->state->mainMenu; + $commands = $menu->getItems()->toArray(); + array_splice($commands, 4, 0, [new OpenSummonsMenuCommand($menu), new OpenQuestsMenuCommand($menu), new OpenRecordsMenuCommand($menu)]); + $menu->setItems(new ItemList(MenuItemInterface::class, $commands)); + $this->state->setMode(new MainMenuCommandSelectionMode($this->state)); + $menu->setActiveItemByLabel('Equipment'); + $menu->getActiveItem()->disable(); + foreach ([false, true] as $alternative) { + $frame = $this->state->canvas(new MenuPresentationCatalog($this->root, mainMenuPresentationTheme($alternative))); + $labels = array_filter(array_keys(mainMenuPresentationText($frame)), fn($id) => preg_match('/^main-command-\d+-text$/', $id)); + expect($labels)->toHaveCount(13); + expect(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'main-command-' . $menu->activeIndex . '-cursor')))->toBeEmpty(); + } + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuCommandSelectionMode::class); +}); + +it('wraps live labels and semantic hints without overlapping records or descriptions', function (bool $alternative) { + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme($alternative)); + $label = str_repeat('Long command ', 4); + $this->state->mainMenu->getActiveItem()->setLabel($label); + $description = str_repeat('Owner description ', 8); + $this->state->infoPanel->setText($description); + $member = $this->party->members->toArray()[0]; + new ReflectionProperty($member, 'name')->setValue($member, str_repeat('Long actor ', 7)); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::PAGE_UP, KeyCode::PAGE_DOWN, KeyCode::LEFT, KeyCode::RIGHT]], + 'cancel' => ['keys' => [KeyCode::SHIFT_TAB, KeyCode::UP, KeyCode::DOWN, KeyCode::PAGE_UP, KeyCode::PAGE_DOWN, KeyCode::BACKSPACE, KeyCode::ENTER]]]); + $controlLabel = str_repeat('Visible control ', 3) . 'One'; + ActionHints::useProvider(new class($controlLabel) implements ActionHintProvider { + public function __construct(private string $label) {} + public function controlForAction(string $action): ?ControlHint { return new ControlHint('profile', $action, $this->label); } + }); + $source = new FakeInputSource(KeyCode::ENTER); + InputManager::setInputSource($source); + $frame = $this->state->canvas($theme); + $text = mainMenuPresentationText($frame); + expect($text['main-command-0-text'])->toBe($label)->and($text['main-info-value'])->toBe($description) + ->and($text['main-party-0-identity-text'])->toBe($member->name) + ->and($text['main-hints'])->toContain(' ' . $controlLabel . ' : Confirm', ' ' . $controlLabel . ' : Cancel')->not->toContain('ESC') + ->and($source->keys)->toBe([KeyCode::ENTER]); + $layers = array_column($frame->textLayers, null, 'id'); + $hints = $layers['main-hints']; + expect($hints->grid->rows)->toBeGreaterThan(1) + ->and($layers['main-help-text']->y + $layers['main-help-text']->grid->rows * $theme->metrics->cellHeight)->toBeLessThanOrEqual($hints->y); + foreach ($frame->textLayers as $layer) { + if (preg_match('/^main-party-\d+/', $layer->id)) { + expect($layer->clipRect->y + $layer->clipRect->height)->toBeLessThanOrEqual($layers['main-help-text']->y); + } + } + ActionHints::useProvider(null); + InputManager::setBindings([]); + expect(mainMenuPresentationText($this->state->canvas($theme))['main-hints'])->toBe('Unbound: Confirm Unbound: Cancel'); +})->with([false, true]); + +it('refreshes compact hints and profile glyphs on the real four-party menu without changing its actions', function (bool $alternative) { + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::SPACE, KeyCode::ENTER]], + 'cancel' => ['keys' => [KeyCode::C, KeyCode::c, KeyCode::ESCAPE]]]); + $data = mainMenuPresentationTheme($alternative); + $data['icons']['input.profile-one.confirm'] = 'surface.png'; + $data['icons']['input.profile-two.confirm'] = 'focus.png'; + $theme = new MenuPresentationCatalog($this->root, $data); + $this->state->mainMenu->setActiveItemByLabel('Order'); + $frame = $this->state->canvas($theme); + expect(mainMenuPresentationText($frame)['main-hints'])->toBe(' Enter : Confirm Escape : Cancel'); + $provider = new class implements ActionHintProvider { + public function __construct(public string $family = 'profile-one') {} + public function controlForAction(string $action): ?ControlHint { return new ControlHint($this->family, $action, 'Primary ' . $action); } + }; + $mode = $this->state->getPresentationMode(); + $bindings = InputManager::getBindings(); + $source = new FakeInputSource(KeyCode::ENTER); + InputManager::setInputSource($source); + ActionHints::useProvider($provider); + foreach (['profile-one' => 'surface.png', 'profile-two' => 'focus.png'] as $family => $asset) { + $provider->family = $family; + $frame = $this->state->canvas($theme); + expect(count($frame->textLayers))->toBeLessThanOrEqual(64) + ->and(mainMenuPresentationText($frame))->toHaveKeys(['main-party-0-identity-text', 'main-party-3-identity-text']) + ->and(mainMenuPresentationText($frame)['main-hints'])->toBe(': Confirm Primary cancel : Cancel') + ->and(array_column(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'main-hints-control-')), 'asset'))->toBe([$asset]) + ->and($this->state->getPresentationMode())->toBe($mode)->and(InputManager::getBindings())->toBe($bindings) + ->and($source->keys)->toBe([KeyCode::ENTER]); + } + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuPartyOrderMode::class); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + mainMenuPresentationKey(KeyCode::ESCAPE, $this->state); + expect($this->state->characterSelectionMenu->getMarkedPanelIndex())->toBeNull() + ->and($this->state->getPresentationMode())->toBeInstanceOf(MainMenuPartyOrderMode::class); + ActionHints::useProvider(null); + InputManager::setBinding('cancel', [KeyCode::Q]); + expect(mainMenuPresentationText($this->state->canvas($theme))['main-hints'])->toBe(' Enter : Confirm Q : Cancel'); +})->with([false, true]); + +it('contains replacement portraits using current PNG facts and preserves valid unique instances', function () { + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme(true)); + $before = $this->state->canvas($theme); + mainMenuPresentationPng($this->root . '/portrait.png', 23, 137); + clearstatcache(); + $after = $this->state->canvas($theme); + $portraits = array_values(array_filter($after->images, fn($image) => $image->asset === 'portrait.png')); + expect($portraits)->toHaveCount(4)->and(mainMenuPresentationText($before))->toBe(mainMenuPresentationText($after)); + foreach ($portraits as $portrait) { + expect($portrait->destination->width / $portrait->destination->height)->toEqualWithDelta(23 / 137, 0.000001) + ->and($portrait->destination->width)->toBeLessThanOrEqual($portrait->clipRect->width) + ->and($portrait->destination->height)->toBeLessThanOrEqual($portrait->clipRect->height); + } +}); + +it('uses shared Config when themed and overlays alerts without changing the active menu or consuming input', function () { + expect($this->scene->getPresentationCanvas())->toBeNull(); + $this->runtime = mainMenuPresentationRuntime($this->root, MenuPresentationCatalog::CAPABILITIES); + $this->game->useRendererRuntime($this->runtime); + expect($this->scene->getPresentationCanvas())->toBeNull(); + file_put_contents($this->root . '/' . MenuPresentationCatalog::FILE, 'state->enter(); + $this->state->mainMenu->setActiveItemByLabel('Config'); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $configFrame = $this->scene->getPresentationCanvas(); + $configRuns = array_merge(...array_map(fn($layer) => array_column($layer->runs, 'text'), $configFrame->textLayers)); + expect($this->state->getPresentationMode())->toBeInstanceOf(MainMenuConfigMode::class) + ->and($configRuns)->toContain('Config', 'Volume', '75%', 'Sets the master volume for music and sound effects.', 'Cancel'); + $config = $this->state->getPresentationMode()->getConfigMenu(); + InputManager::setBindings([...InputManager::getBindings(), 'down' => ['keys' => [KeyCode::DOWN]]]); + mainMenuPresentationKey(KeyCode::DOWN, $this->state); + expect($config->selection->getActiveIndex())->toBe(1); + $this->state->render(); + expect($this->state->getPresentationMode()->getConfigMenu())->toBe($config) + ->and($config->selection->getActiveIndex())->toBe(1); + mainMenuPresentationKey(KeyCode::C, $this->state); + expect($this->state->mainMenu->getActiveItem()->getLabel())->toBe('Config') + ->and($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class) + ->and(mainMenuPresentationText($this->scene->getPresentationCanvas()))->not->toHaveKey('config-title'); + $this->state->setMode(new MainMenuPartyOrderMode($this->state)); + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $this->state->characterSelectionMenu->selectNext(); + $before = mainMenuPresentationText($this->scene->getPresentationCanvas()); + $mode = $this->state->getPresentationMode(); + $modal = makeBareScene(ModalManager::class); + $stack = new Stack(ModalInterface::class); + new ReflectionProperty($modal, 'modals')->setValue($modal, $stack); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $modal); + $alert = new AlertModal($this->game, 'Equipment optimized!', 'Equipment'); + new ReflectionProperty($alert, 'isShowing')->setValue($alert, true); + $stack->push($alert); + $source = new FakeInputSource(KeyCode::C); + InputManager::setInputSource($source); + $overlay = $this->scene->getPresentationCanvas(); + expect($overlay)->toBeInstanceOf(PresentationCanvas::class) + ->and(mainMenuPresentationText($overlay)['menu-modal-message'])->toBe('Equipment optimized!'); + foreach ($before as $id => $text) { + expect(mainMenuPresentationText($overlay)[$id])->toBe($text); + } + $stack->pop(); + expect(mainMenuPresentationText($this->scene->getPresentationCanvas()))->toBe($before) + ->and($this->state->getPresentationMode())->toBe($mode)->and($source->keys)->toBe([KeyCode::C]) + ->and($this->state->characterSelectionMenu->getMarkedPanelIndex())->toBe(0) + ->and($this->state->characterSelectionMenu->getActivePanelIndex())->toBe(1); +}); + +it('connects in-game Save to the shared themed canvas and modal lifecycle without writing saves', function (bool $alternative) { + $slots = [new SaveSlot(1, '', false, 'Rest Point', 'Actor 0', 6, 3600), + ...array_map(static fn($index) => SaveSlot::empty($index, ''), range(2, 5))]; + $manager = $this->createMock(SaveManager::class); + $manager->expects($this->atLeastOnce())->method('getSaveSlots')->with(5)->willReturn($slots); + $manager->expects($this->never())->method('save'); + new ReflectionProperty($this->scene->sceneManager, 'saveManager')->setValue($this->scene->sceneManager, $manager); + $state = new SaveMenuState(new SceneStateContext($this->scene)); + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $state); + expect($state)->toBeInstanceOf(CanvasProviderInterface::class); + $state->enter(); + expect($this->scene->getPresentationCanvas())->toBeNull(); + $terminal = new ReflectionProperty($state, 'helpWindow')->getValue($state); + expect(implode(' ', $terminal->getContent()))->toContain('Choose a file.', 'Enter saves. Esc returns.'); + + $themeData = mainMenuPresentationTheme($alternative); + file_put_contents($this->root . '/' . MenuPresentationCatalog::FILE, 'runtime = mainMenuPresentationRuntime($this->root, MenuPresentationCatalog::CAPABILITIES); + $this->game->useRendererRuntime($this->runtime); + $state->enter(); + $frame = $this->scene->getPresentationCanvas(); + expect($frame)->not->toBeNull(new ReflectionProperty($state, 'menuPresentationError')->getValue($state) ?? ''); + $text = mainMenuPresentationText($frame); + expect($text['save-title'])->toBe('Save') + ->and($text['save-prompt-text'])->toBe('Which file would you like to save to?') + ->and($text['save-slot-1-location-text'])->toBe('Rest Point') + ->and($text['save-slot-1-duration'])->toBe('01:00:00'); + $ids = [...array_column($frame->textLayers, 'id'), ...array_column($frame->images, 'id')]; + expect(array_filter($ids, static fn($id) => str_starts_with($id, 'save-slot-1-location-selected')))->not->toBeEmpty(); + $theme = new MenuPresentationCatalog($this->root, $themeData); + foreach (['increase' => 'Saved to File 1.', 'decrease' => 'Save unavailable.'] as $role => $status) { + new ReflectionProperty($state, 'statusMessage')->setValue($state, $status); + new ReflectionProperty($state, 'statusColor')->setValue($state, $role); + $frame = $this->scene->getPresentationCanvas(); + $layer = array_find($frame->textLayers, static fn($layer) => $layer->id === 'save-info-status'); + expect(mainMenuPresentationText($frame)['save-info-status'])->toBe($status) + ->and($layer->runs[0]->foreground)->toEqual($theme->colors[$role]); + } + + $modals = makeBareScene(ModalManager::class); + $stack = new Stack(ModalInterface::class); + new ReflectionProperty($modals, 'modals')->setValue($modals, $stack); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $modals); + $alert = new AlertModal($this->game, 'Saved to File 1.', 'Save Complete'); + new ReflectionProperty($alert, 'isShowing')->setValue($alert, true); + $stack->push($alert); + $overlay = implode(' ', mainMenuPresentationText($this->scene->getPresentationCanvas())); + expect($overlay)->toContain('Save Complete', 'Saved to File 1.', 'OK', 'Rest Point'); + $stack->pop(); + $state->resume(); + expect(mainMenuPresentationText($this->scene->getPresentationCanvas())['save-info-status'])->toBe('Save unavailable.'); + $state->enter(); + expect(mainMenuPresentationText($this->scene->getPresentationCanvas())['save-info-description'])->toBe('Choose a file.'); +})->with([false, true]); + +it('diagnoses unrenderable content or invalid art and recovers without mutating the owner', function () { + file_put_contents($this->root . '/' . MenuPresentationCatalog::FILE, 'runtime = mainMenuPresentationRuntime($this->root, MenuPresentationCatalog::CAPABILITIES); + $this->game->useRendererRuntime($this->runtime); + $mode = $this->state->getPresentationMode(); + $this->state->infoPanel->setText(str_repeat('Long description ', 1000)); + expect($this->scene->getPresentationCanvas())->toBeNull()->and($this->state->getPresentationMode())->toBe($mode); + $this->state->infoPanel->setText('Recovered description'); + expect($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class); + file_put_contents($this->root . '/portrait.png', 'invalid PNG'); + clearstatcache(); + expect($this->scene->getPresentationCanvas())->toBeNull()->and($this->state->getPresentationMode())->toBe($mode); + $logs = implode('', array_map(file_get_contents(...), glob($this->root . '/logs/*'))); + expect($logs)->toContain('Menu presentation degraded to terminal'); +}); + +it('keeps default row output identical while separate record treatments retain the same name cursor geometry', function (bool $alternative) { + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme($alternative)); + $row = new MenuRow('actor', 'Actor Name', selected: true, focused: true); + $layout = new MenuRowLayout(new CanvasRectangle(250, 100, 600, 40), rowHeight: 40, + cellWidth: $theme->metrics->cellWidth, cellHeight: $theme->metrics->cellHeight); + $normal = MenuRowPainter::compose(1350, 720, 'test', [$row], $layout, $theme->rows, $theme->icons, reducedMotion: true); + $explicit = MenuRowPainter::compose(1350, 720, 'test', [$row], $layout, $theme->rows, $theme->icons, + reducedMotion: true, recordBounds: $layout->viewport); + expect($explicit)->toEqual($normal); + $full = MenuRowPainter::compose(1350, 720, 'test', [$row], $layout, $theme->rows, $theme->icons, + reducedMotion: true, recordBounds: new CanvasRectangle(200, 90, 700, 150)); + expect(array_column($full->textLayers, null, 'id')['test-actor-text'])->toEqual(array_column($normal->textLayers, null, 'id')['test-actor-text']); + $cursor = fn(PresentationCanvas $frame) => array_values(array_filter($frame->images, fn($image) => str_contains($image->id, '-cursor'))); + expect($cursor($full))->toEqual($cursor($normal)); + $separators = array_column($full->textLayers, null, 'id'); + expect($separators['test-actor-separator']->clipRect->y)->toBe(239.0); +})->with([false, true]); + +it('retains source-specific terminal fields and owner Save availability', function () { + $this->state->render(); + $terminal = TerminalText::stripAnsi(implode("\n", Console::getBuffer())); + foreach (['Actor 0', 'Role:', 'Lv', 'HP', 'MP', 'Play Time', 'Tokens', '4,321 T', 'Owner Location', 'Owner Region', 'Equipment', 'Save'] as $text) { + expect($terminal)->toContain($text); + } + $this->state->saveAllowed = false; + $this->state->enter(); + $this->state->render(); + expect($this->state->mainMenu->getItemByLabel('Save'))->toBeNull(); + $native = mainMenuPresentationText($this->state->canvas(new MenuPresentationCatalog($this->root, mainMenuPresentationTheme(true)))); + expect(array_values($native))->not->toContain('Save'); +}); + +it('keeps reduced-motion character focus stationary without moving content', function () { + ConfigStore::put(ProjectConfig::class, new PlaySettings(['accessibility' => ['reducedMotion' => true]])); + $this->state->setMode(new MainMenuCharacterSelectionMode($this->state)); + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme(true)); + expect($this->state->canvas($theme, 0)->toArray())->toBe($this->state->canvas($theme, 0.5)->toArray()); +}); + +it('compacts neutral fills and separators without changing coverage opacity or content', function (bool $rulesOnly) { + $theme = new MenuPresentationCatalog($this->root, mainMenuPresentationTheme(false)); + $view = new MenuCanvas($theme, width: 400, height: 500); + for ($i = 0; $i < ($rulesOnly ? 0 : 30); $i++) { + $view->frame('frame-' . $i, new CanvasRectangle(0, $i * 10, 100, 10)); + } + for ($i = 0; $i < ($rulesOnly ? 32 : 20); $i++) { + $view->rows('row-' . $i, [new MenuRow('value', 'Record')], new MenuRowLayout(new CanvasRectangle(100, $i * 14, 200, 14), + rowHeight: 14, cellWidth: 8, cellHeight: 12)); + } + $before = new ReflectionProperty($view, 'text')->getValue($view); + expect(count($before))->toBeGreaterThan(64); + $after = $view->finish()->textLayers; + $pixels = static function (array $layers): array { + $result = []; + foreach ($layers as $layer) { + foreach ($layer->runs as $run) { + if ($run->background === null) { continue; } + $clip = $layer->clipRect; + $left = max($clip->x, $layer->x + $run->column * $layer->grid->cellWidth); + $top = max($clip->y, $layer->y + $run->row * $layer->grid->cellHeight); + $right = min($clip->x + $clip->width, $layer->x + ($run->column + mb_strlen($run->text)) * $layer->grid->cellWidth); + $bottom = min($clip->y + $clip->height, $layer->y + ($run->row + 1) * $layer->grid->cellHeight); + $color = json_encode([$run->background->toArray(), $layer->opacity]); + for ($y = $top; $y < $bottom; $y++) { + for ($x = $left; $x < $right; $x++) { $result[$layer->layer * 200000 + (int)$y * 400 + (int)$x] = $color; } + } + } + } + ksort($result); + return $result; + }; + expect(count($after))->toBeLessThanOrEqual(64)->and($pixels($after))->toBe($pixels($before)); +})->with([false, true]); + +it('fits four actors and thirteen commands with framed art and neutral row treatments', function () { + $data = mainMenuPresentationTheme(false); + $data['frames'] = array_fill_keys(['panel', 'quiet', 'portrait'], ['asset' => 'surface.png', 'cuts' => [24, 24, 24, 24]]); + $theme = new MenuPresentationCatalog($this->root, $data); + $menu = $this->state->mainMenu; + $commands = $menu->getItems()->toArray(); + array_splice($commands, 4, 0, [new OpenSummonsMenuCommand($menu), new OpenQuestsMenuCommand($menu), new OpenRecordsMenuCommand($menu)]); + $menu->setItems(new ItemList(MenuItemInterface::class, $commands)); + foreach ([new MainMenuCommandSelectionMode($this->state), new MainMenuCharacterSelectionMode($this->state), + new MainMenuPartyOrderMode($this->state)] as $mode) { + $this->state->setMode($mode); + if ($mode instanceof MainMenuPartyOrderMode) { + mainMenuPresentationKey(KeyCode::ENTER, $this->state); + $this->state->characterSelectionMenu->selectNext(); + } + $frame = $this->state->canvas($theme); + expect(count($frame->textLayers))->toBeLessThanOrEqual(64) + ->and(mainMenuPresentationText($frame))->toHaveKeys(['main-party-0-identity-text', 'main-party-1-identity-text', + 'main-party-2-identity-text', 'main-party-3-identity-text', 'main-command-12-text']); + } +}); diff --git a/tests/Unit/MenuControlsTest.php b/tests/Unit/MenuControlsTest.php new file mode 100644 index 00000000..420a9c49 --- /dev/null +++ b/tests/Unit/MenuControlsTest.php @@ -0,0 +1,125 @@ +root = sys_get_temp_dir() . '/ichiloto-menu-controls-' . bin2hex(random_bytes(5)); + mkdir($this->root); + $chunk = static fn(string $type, string $bytes): string => pack('N', strlen($bytes)) . $type . $bytes + . pack('N', crc32($type . $bytes)); + file_put_contents($this->root . '/surface.png', "\x89PNG\r\n\x1a\n" + . $chunk('IHDR', pack('NNCCCCC', 32, 32, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xFF\xFF\xFF\xFF", 32), 32))) . $chunk('IEND', '')); +}); + +afterEach(function () { + unlink($this->root . '/surface.png'); + rmdir($this->root); +}); + +function getScrollControlTheme(string $root, bool $art, array $metrics = []): MenuPresentationCatalog +{ + return new MenuPresentationCatalog($root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, + 'metrics' => $metrics, + 'frames' => $art ? ['scroll.track' => ['asset' => 'surface.png'], + 'scroll.thumb' => ['asset' => 'surface.png', 'cuts' => [0, 12, 0, 12]]] : [], + 'icons' => $art ? ['navigation.up' => 'surface.png', 'navigation.down' => 'surface.png'] : []]); +} + +it('renders shared scroll artwork and fallback at every endpoint without overlap', function (float $ratio, float $visible, bool $art) { + $theme = getScrollControlTheme($this->root, $art); + $controls = new MenuControls($theme); + $box = new CanvasRectangle(50, 20, 24, 256); + $controls->renderScrollbar('scroll', $box, (100 - $visible) * $ratio, $visible, 100); + $frame = $controls->finish(100, 300); + $pieces = [...$frame->images, ...$frame->textLayers]; + $track = new CanvasRectangle(56, 48, 12, 200); + $thumbHeight = max(28, 200 * $visible / 100); + $thumb = new CanvasRectangle(56, 48 + (200 - $thumbHeight) * $ratio, 12, $thumbHeight); + foreach (['track' => $track, 'thumb' => $thumb] as $role => $expected) { + $surface = array_values(array_filter($pieces, fn($piece) => str_starts_with($piece->id, 'scroll-' . $role))); + expect($surface)->not->toBeEmpty(); + foreach ($surface as $piece) { expect($piece->clipRect)->toEqual($expected); } + } + $up = array_find($pieces, fn($piece) => str_starts_with($piece->id, 'scroll-up')); + $down = array_find($pieces, fn($piece) => str_starts_with($piece->id, 'scroll-down')); + expect($up->opacity)->toBe($ratio === 0.0 ? 0.35 : 1.0) + ->and($down->opacity)->toBe($ratio === 1.0 ? 0.35 : 1.0) + ->and($up->clipRect->y + $up->clipRect->height + 4)->toEqual($track->y) + ->and($track->y + $track->height + 4)->toEqual($down->clipRect->y); + if (!$art) { + expect($up->runs[0]->text)->toBe("\u{2227}")->and($down->runs[0]->text)->toBe("\u{2228}"); + } + foreach ($pieces as $piece) { $piece->clipRect->assertWithin(100, 300); } + $frame->toArray(); +})->with([0.0, 0.5, 1.0])->with([2.0, 25.0])->with([false, true]); + +it('uses authored scrollbar metrics and clamps the minimum thumb to short tracks', function () { + $theme = getScrollControlTheme($this->root, false, ['scrollbarWidth' => 30, 'scrollbarTrackWidth' => 8, + 'scrollbarArrowGap' => 6, 'scrollbarMinThumbHeight' => 40]); + $controls = new MenuControls($theme); + $controls->renderScrollbar('scroll', new CanvasRectangle(0, 0, 30, 90), 99, 1, 100); + $pieces = array_column($controls->finish(100, 100)->textLayers, null, 'id'); + expect($pieces['scroll-track']->clipRect)->toEqual(new CanvasRectangle(11, 36, 8, 18)) + ->and($pieces['scroll-thumb']->clipRect)->toEqual($pieces['scroll-track']->clipRect) + ->and($pieces['scroll-down']->opacity)->toBe(0.35); +}); + +it('omits unnecessary scrollbars and rejects invalid extents before painting', function () { + $theme = getScrollControlTheme($this->root, false); + $box = new CanvasRectangle(0, 0, 24, 200); + foreach ([0, 5, 10] as $total) { + $controls = new MenuControls($theme); + $controls->renderScrollbar('scroll', $box, 0, 10, $total); + expect($controls->finish(100, 300)->textLayers)->toBeEmpty(); + } + foreach ([[-1, 10, 100], [91, 10, 100], [0, 0, 100], [0, 10, -1], [NAN, 10, 100], + [0, INF, 100], [0, 10, INF]] as [$offset, $visible, $total]) { + $controls = new MenuControls($theme); + expect(fn() => $controls->renderScrollbar('scroll', $box, $offset, $visible, $total))->toThrow(InvalidArgumentException::class) + ->and($controls->finish(100, 300)->textLayers)->toBeEmpty(); + } + $controls = new MenuControls($theme); + expect(fn() => $controls->renderScrollbar('scroll', new CanvasRectangle(0, 0, 24, 56), 0, 1, 10)) + ->toThrow(InvalidArgumentException::class); + foreach ([['scrollbarWidth' => 65], ['scrollbarTrackWidth' => 25], ['scrollbarTrackWidth' => 0], + ['scrollbarMinThumbHeight' => 0], ['scrollbarArrowGap' => -1]] as $metrics) { + expect(fn() => getScrollControlTheme($this->root, false, $metrics))->toThrow(InvalidArgumentException::class); + } +}); + +it('uses the same themed scrollbar for quest prose without painting over text or changing reading state', function (int $width, int $height, bool $art) { + $theme = getScrollControlTheme($this->root, $art); + $document = new JournalDocument([new JournalSection('Objectives', + array_map(fn($i) => ['text' => 'Objective ' . $i], range(1, 80)))]); + $content = new JournalMenuContent('Quests', ['Active', 'Completed'], 0, '', + [['label' => 'A journey', 'values' => ['', 'In progress']]], 0, 'No quests', 'journey', + $document->text, '', true, true, $document); + $reading = new TextViewport(); + $reading->setText($document->text); + [$columns, $rows] = QuestJournalPresentation::pageSize($content, $theme, $width, $height); + for ($i = 0; $i < 100; $i++) { + $page = $reading->page($columns, $rows); + $frame = QuestJournalPresentation::compose($content, $theme, $page, width: $width, height: $height); + expect($reading->page($columns, $rows))->toEqual($page); + $pieces = [...$frame->images, ...$frame->textLayers]; + $track = array_find($pieces, fn($piece) => str_starts_with($piece->id, 'journal-scroll-track'))->clipRect; + $text = array_find($frame->textLayers, fn($piece) => str_starts_with($piece->id, 'journal-text')); + expect($text->clipRect->x + $text->clipRect->width)->toBeLessThan($track->x); + $frame->toArray(); + if ($page->first + $page->rows >= $page->total) { break; } + $reading->scroll(1, $page); + } + expect($page->first + $page->rows)->toBe($page->total); + $down = array_find($pieces, fn($piece) => str_starts_with($piece->id, 'journal-scroll-down')); + expect($down->opacity)->toBe(0.35); +})->with([[1350, 720], [960, 540], [736, 414]])->with([false, true]); diff --git a/tests/Unit/MenuInfoOwnerTest.php b/tests/Unit/MenuInfoOwnerTest.php new file mode 100644 index 00000000..24d894e3 --- /dev/null +++ b/tests/Unit/MenuInfoOwnerTest.php @@ -0,0 +1,292 @@ +options; } + public function persist(): void { $this->writes++; } +} + +beforeEach(function () { + $this->saved = []; + foreach ([Console::class, InputManager::class, ConfigStore::class, AudioManager::class, QuestManager::class] as $class) { + $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); + } + new ReflectionProperty(QuestManager::class, 'current')->setValue(null, null); + new ReflectionProperty(InputManager::class, 'eventManager')->setValue(null, null); + Console::setTerminalOutputEnabled(false); + Console::syncDimensions(135, 36); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + $this->config = new MenuInfoMemoryConfig([]); + ConfigStore::put(ProjectConfig::class, $this->config); + $this->game = new MenuInfoGame(); + $audio = new class extends AudioManager { + public function __construct() {} + public function playSystemSound(SystemSound $sound): void {} + }; + new ReflectionProperty(AudioManager::class, 'instance')->setValue(null, $audio); + new ReflectionProperty($this->game, 'audioManager')->setValue($this->game, $audio); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $manager = makeBareScene(SceneManager::class); + new ReflectionProperty($manager, 'game')->setValue($manager, $this->game); + $this->scene = makeBareScene(GameScene::class); + new ReflectionProperty($this->scene, 'sceneManager')->setValue($this->scene, $manager); + new ReflectionProperty($this->scene, 'gameState')->setValue($this->scene, new GameState()); + $ui = makeBareScene(UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + $this->party = new Party(); + $this->actor = new Character('Actor', 0, new Stats(), actorId: 'actor.one'); + $this->party->addMember($this->actor); + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, $this->party); + InputManager::setBindings(array_map(fn($key) => ['keys' => [$key]], [ + 'info' => KeyCode::F2, 'down' => KeyCode::DOWN, 'up' => KeyCode::UP, 'confirm' => KeyCode::ENTER, + 'cancel' => KeyCode::ESCAPE, 'back' => KeyCode::ESCAPE, 'character_next' => KeyCode::Q, + ])); + ob_start(); +}); + +afterEach(function () { + ob_end_clean(); + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } +}); + +function pressMenuInfoKey(object $owner, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $owner instanceof ConfigMenu ? $owner->update() : $owner->execute(); +} + +function getOwnerInfoPanel(object $owner): Window +{ + if ($owner instanceof ConfigMenu) { return $owner->detail; } + $property = $owner instanceof EquipmentMenuState ? 'equipmentInfoPanel' : 'infoPanel'; + return new ReflectionProperty($owner, $property)->getValue($owner); +} + +function createInfoOwner(object $fixture, string $kind, string $description): object +{ + if ($kind === 'config') { + $manager = new MainMenuSettingsManager(); + $owner = new ConfigMenu($manager, new ConfigSelectionWindow(new Rect(0, 0, 80, 20), $manager), + new ConfigDetailPanel(new Rect(0, 20, 80, 4)), static fn() => null); + $owner->enter(); + $owner->selection->setSettings([new GameSetting('first', 'First', $description), new GameSetting('second', 'Second', $description), + new GameSetting('third', 'Third', "New one\nNew two\nNew three\nNew four")]); + $owner->selection->focus(); + $owner->render(); + return $owner; + } + if ($kind === 'items') { + $fixture->party->inventory->addItems(new Item('First', $description, '', 1), new Item('Second', $description, '', 1), + new Item('Third', "New one\nNew two\nNew three\nNew four", '', 1)); + $owner = new ItemMenuState(new SceneStateContext($fixture->scene)); + $owner->enter(); + $owner->setMode(new UseItemMode($owner)); + return $owner; + } + if ($kind === 'equipment') { + $fixture->party->inventory->addItems(new Weapon('First', $description, '', 1), new Weapon('Second', $description, '', 1), + new Weapon('Third', "New one\nNew two\nNew three\nNew four", '', 1)); + $owner = new EquipmentMenuState(new SceneStateContext($fixture->scene)); + $owner->enter(); + $mode = new EquipmentSelectionMode($owner); + $mode->character = $fixture->actor; + $mode->equipmentSlot = $fixture->actor->equipment[0]; + $owner->setMode($mode); + return $owner; + } + $class = $kind === 'magic' ? MagicSkill::class : SpecialSkill::class; + $skills = [new $class('First', $description, '', 1, 0), new $class('Second', $description, '', 1, 0), + new $class('Third', "New one\nNew two\nNew three\nNew four", '', 1, 0)]; + foreach ($skills as $skill) { $fixture->actor->learnSkill($skill); } + $class = $kind === 'magic' ? MagicMenuState::class : AbilityMenuState::class; + $owner = new $class(new SceneStateContext($fixture->scene)); + $owner->enter(); + return $owner; +} + +it('pages every owner in the existing Info window and keeps ranges solely on its border', function (string $kind) { + $owner = createInfoOwner($this, $kind, "One\nTwo\nThree\nFour\nFive"); + $panel = getOwnerInfoPanel($owner); + expect($panel->getContent())->toBe(['One', 'Two']); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['Three', 'Four'])->and($panel->getHelp())->toBe('Lines 3-4 / 5'); + $border = new ReflectionMethod($panel, 'getBottomBorder')->invoke($panel); + expect(TerminalText::stripAnsi($border))->toContain('Lines 3-4 / 5'); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['Five', ''])->and($panel->getHelp())->toBe('Lines 5-5 / 5'); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['One', 'Two'])->and($this->config->writes)->toBe(0); + if ($owner instanceof AbilityMenuState || $owner instanceof MagicMenuState) { + expect($owner->getPresentationContent()->infoModel)->toBe($owner->menuInfoText); + } +})->with(['items', 'equipment', 'abilities', 'magic', 'config']); + +it('resets reading on identity changes even with matching descriptions and uses fresh source before advancing', function (string $kind) { + $owner = createInfoOwner($this, $kind, "One\nTwo\nThree\nFour"); + $panel = getOwnerInfoPanel($owner); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['Three', 'Four']); + pressMenuInfoKey($owner, KeyCode::DOWN); + expect($panel->getContent())->toBe(['One', 'Two']); + pressMenuInfoKey($owner, KeyCode::DOWN); + expect($panel->getContent())->toBe(['New one', 'New two']); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['New three', 'New four']); +})->with(['items', 'equipment', 'abilities', 'magic', 'config']); + +it('honors live Info rebindings without executing the owners confirm actions', function (string $kind) { + $owner = createInfoOwner($this, $kind, "One\nTwo\nThree\nFour"); + $panel = getOwnerInfoPanel($owner); + InputManager::setBindings(['info' => ['keys' => [KeyCode::X]], 'confirm' => ['keys' => [KeyCode::X]]]); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['One', 'Two']); + pressMenuInfoKey($owner, KeyCode::X); + expect($panel->getContent())->toBe(['Three', 'Four'])->and($this->config->writes)->toBe(0); +})->with(['items', 'equipment', 'abilities', 'magic', 'config']); + +it('keeps complete status and description reachable without restoring old source on Info input', function (string $kind) { + $owner = createInfoOwner($this, $kind, "One\nTwo\nThree\nFour"); + $model = $owner->menuInfoText; + $model->getPage('Old renderer source', null, 20); + new ReflectionProperty($owner, 'statusMessage')->setValue($owner, "Status one\nStatus two\nStatus three"); + pressMenuInfoKey($owner, KeyCode::F2); + expect($model->lastPage->source)->toBe("One\nTwo\nThree\nFour\nStatus one\nStatus two\nStatus three"); + pressMenuInfoKey($owner, KeyCode::F2); + expect(getOwnerInfoPanel($owner)->getContent())->toBe(['Status one', 'Status two']); + pressMenuInfoKey($owner, KeyCode::F2); + expect(getOwnerInfoPanel($owner)->getContent())->toBe(['Status three', '']); +})->with(['abilities', 'magic', 'config']); + +it('refreshes a changed Item or Equipment description before consuming the Info action', function (string $kind) { + $owner = createInfoOwner($this, $kind, "One\nTwo\nThree\nFour"); + pressMenuInfoKey($owner, KeyCode::F2); + $panel = getOwnerInfoPanel($owner); + $panel->setText("Changed one\nChanged two\nChanged three\nChanged four"); + pressMenuInfoKey($owner, KeyCode::F2); + expect($panel->getContent())->toBe(['Changed three', 'Changed four']) + ->and($owner->menuInfoText->lastPage->source)->toBe("Changed one\nChanged two\nChanged three\nChanged four"); +})->with(['items', 'equipment']); + +it('resets a skill Info page when cycling to an actor with the same description', function (string $kind) { + $owner = createInfoOwner($this, $kind, "One\nTwo\nThree\nFour"); + $other = new Character('Other Actor', 0, new Stats(), actorId: 'actor.two'); + $skills = $kind === 'magic' ? $this->actor->spellbook->getLearnedSpells() : $this->actor->abilityBook->getLearnedAbilities(); + foreach ($skills as $skill) { $other->learnSkill($skill); } + $this->party->addMember($other); + pressMenuInfoKey($owner, KeyCode::F2); + pressMenuInfoKey($owner, KeyCode::Q); + expect($owner->character)->toBe($other)->and(getOwnerInfoPanel($owner)->getContent())->toBe(['One', 'Two']); +})->with(['abilities', 'magic']); + +it('pages read-only Key Items through the actual state canvas without an authored Info binding', function (bool $alternate) { + InputManager::setBindings([ + 'confirm' => ['keys' => [KeyCode::ENTER]], 'back' => ['keys' => [KeyCode::ESCAPE]], + 'cancel' => ['keys' => [KeyCode::C, KeyCode::c, KeyCode::ESCAPE]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]], + ]); + $lines = ['First read-only line.', 'Second read-only line.', 'Third read-only line.', 'Fourth read-only line.']; + $key = new Item('Test key', implode("\n", $lines), '', 0, isKeyItem: true, id: 'test.key'); + $this->party->inventory->addItems($key); + $owner = new ItemMenuState(new SceneStateContext($this->scene)); + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $owner); + $owner->enter(); + for ($i = 0; $i < 3; $i++) { pressMenuInfoKey($owner, KeyCode::RIGHT); } + pressMenuInfoKey($owner, KeyCode::ENTER); + expect($owner->mode)->toBeInstanceOf(ViewKeyItemsMode::class); + + $data = ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]; + if ($alternate) { + $data['metrics'] = ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 32, 'panelPadding' => 16]; + $data['colors'] = ['panel' => [244, 237, 217], 'text' => [30, 35, 50]]; + } + $theme = new MenuPresentationCatalog(sys_get_temp_dir(), $data); + // An already-loaded theme exercises state delegation without files or a native process. + $this->game->useRendererRuntime(new RendererRuntime(new RendererRuntimeConfig( + new RendererProcessConfig(['not-launched']), sys_get_temp_dir()), new FakeRendererTransport())); + new ReflectionProperty($owner, 'menuThemeLoaded')->setValue($owner, true); + new ReflectionProperty($owner, 'menuTheme')->setValue($owner, $theme); + $readCanvas = function () use ($owner, $theme): string { + expect(ItemMenuPresentation::compose($owner, $theme))->toBeInstanceOf(PresentationCanvas::class); + $canvas = $this->scene->getPresentationCanvas(); + expect($canvas)->toBeInstanceOf(PresentationCanvas::class); + return implode("\n", array_map(fn($layer) => implode('', array_column($layer->runs, 'text')), $canvas->textLayers)); + }; + expect($readCanvas())->toContain($lines[0], $lines[1], 'Lines 1-2 / 4')->not->toContain($lines[2]); + pressMenuInfoKey($owner, KeyCode::i); + expect($owner->infoPanel->getContent())->toBe(array_slice($lines, 2)) + ->and($owner->infoPanel->getHelp())->toBe('Lines 3-4 / 4'); + expect($readCanvas())->toContain($lines[2], $lines[3], 'Lines 3-4 / 4')->not->toContain($lines[0]); + expect($readCanvas())->toContain($lines[2])->and($owner->menuInfoText->lastPage->first)->toBe(2); + pressMenuInfoKey($owner, KeyCode::I); + expect($readCanvas())->toContain($lines[0], $lines[1])->not->toContain($lines[2]); + pressMenuInfoKey($owner, KeyCode::ENTER); + expect($owner->mode)->toBeInstanceOf(ViewKeyItemsMode::class)->and($key->quantity)->toBe(1); + pressMenuInfoKey($owner, KeyCode::ESCAPE); + expect($owner->mode)->toBeInstanceOf(SelectIemMenuCommandMode::class) + ->and($owner->itemMenu->activeIndex)->toBe(3)->and($this->config->writes)->toBe(0); +})->with([false, true]); diff --git a/tests/Unit/MenuInfoTextTest.php b/tests/Unit/MenuInfoTextTest.php new file mode 100644 index 00000000..4865fed1 --- /dev/null +++ b/tests/Unit/MenuInfoTextTest.php @@ -0,0 +1,83 @@ +advance())->toBeFalse(); + $page = $model->getPage($source, null, 20); + expect($page)->toBeInstanceOf(TextPage::class)->and($page->lines)->toBe(['One', 'Two'])->and($page->rows)->toBe(2); + expect($model->advance())->toBeTrue(); + expect($model->getPage($source, null, 20)->lines)->toBe(['Three', 'Four']); + $model->advance(); + expect($model->getPage($source, null, 20)->lines)->toBe(['Five']); + $model->advance(); + expect($model->getPage($source, null, 20)->first)->toBe(0); +}); + +it('does not advance during repeated reads or reflow between renderers', function () { + $model = new MenuInfoText(); + $source = str_repeat('Complete source words stay readable. ', 12); + $model->getPage($source, null, 15); + $model->advance(); + $page = $model->getPage($source, null, 15); + for ($i = 0; $i < 5; $i++) { + $model->getPage($source, null, 49); + expect($model->getPage($source, null, 15))->toEqual($page); + } + expect(fn() => $model->lastPage = null)->toThrow(Error::class); +}); + +it('retains complete descriptions and statuses through Unicode-safe wrapping', function (int $columns) { + $model = new MenuInfoText(); + $description = "Caf\u{00e9} \u{6f22}\u{5b57}\r\nS-Potion\tmanual " . str_repeat('LongUnbrokenWord', 5); + $status = "First outcome\n" . str_repeat('Complete outcome. ', 6); + $first = $model->getPage($description, $status, $columns); + $seen = []; + do { + $page = $model->getPage($description, $status, $columns); + array_push($seen, ...$page->lines); + foreach ($page->lines as $line) { + expect(mb_check_encoding($line, 'UTF-8'))->toBeTrue()->and(mb_strlen($line))->toBeLessThanOrEqual($columns); + } + $model->advance(); + } while ($model->getPage($description, $status, $columns)->first !== 0); + expect($seen)->toBe(array_column(TextViewport::wrap($first->source, $columns), 1)) + ->and($first->source)->toContain('S-Potion', 'Complete outcome.'); +})->with([8, 23, 60]); + +it('resets changed sources and explicit identity resets even when text matches', function () { + $model = new MenuInfoText(); + $source = "One\nTwo\nThree\nFour"; + $model->getPage($source, null, 10); + $model->advance(); + expect($model->getPage($source, null, 10)->first)->toBe(2); + expect($model->getPage("New\nText\nThree\nFour", null, 10)->first)->toBe(0); + $model->advance(); + expect($model->getPage("New\nText\nThree\nFour", 'Outcome', 10)->first)->toBe(0); + $model->advance(); + $model->reset(); + expect($model->lastPage)->toBeNull()->and($model->getPage("New\nText\nThree\nFour", 'Outcome', 10)->first)->toBe(0); +}); + +it('leaves a successfully measured page intact when a later layout is invalid', function () { + $model = new MenuInfoText(); + $page = $model->getPage("One\nTwo\nThree", null, 10); + expect(fn() => $model->getPage('Changed source', null, 0))->toThrow(InvalidArgumentException::class) + ->and($model->lastPage)->toBe($page); + $model->advance(); + expect($model->getPage($page->source, null, 10)->lines)->toBe(['Three']); +}); + +it('does not manufacture extra pages for empty or already fitting content', function () { + $model = new MenuInfoText(); + expect($model->getPage('', null, 20)->source)->toBe('')->and($model->advance())->toBeFalse(); + expect($model->getPage('', 'Outcome', 20)->lines)->toBe(['Outcome'])->and($model->advance())->toBeFalse(); + expect($model->getPage('Description', 'Outcome', 20)->lines)->toBe(['Description', 'Outcome']) + ->and($model->advance())->toBeFalse(); +}); diff --git a/tests/Unit/MenuModalPresentationTest.php b/tests/Unit/MenuModalPresentationTest.php new file mode 100644 index 00000000..2a749d67 --- /dev/null +++ b/tests/Unit/MenuModalPresentationTest.php @@ -0,0 +1,595 @@ + true; } +} + +function modalMenuPng(string $path): void +{ + $chunk = static fn(string $type, string $data) => pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', 43, 61, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAD\xBE\xCF\xFF", 43), 61))) . $chunk('IEND', '')); +} + +function modalMenuTheme(bool $art): array +{ + $theme = ['schema' => 'ichiloto.menu/1', 'cursor' => 'art.png', + 'portraits' => array_fill_keys(['actor.0', 'actor.1', 'actor.2', 'actor.3'], 'art.png')]; + if (!$art) { return $theme; } + $frame = ['asset' => 'art.png', 'cuts' => [3, 4, 3, 4]]; + return [...$theme, 'colors' => ['text' => [40, 30, 20], 'panel' => [230, 220, 200]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 34, 'panelPadding' => 16, 'sectionGap' => 8, 'portraitSize' => 88], + 'frames' => ['panel' => $frame, 'quiet' => $frame], + 'rowArtwork' => ['command.normal' => $frame, 'command.selected' => $frame, 'command.focus' => ['asset' => 'art.png']]]; +} + +/** Foreground paint positions must survive backing/rule batching unchanged. */ +function modalMenuText(PresentationCanvas $canvas): array +{ + $text = []; + foreach ($canvas->textLayers as $layer) { + foreach ($layer->runs as $run) { + if ($run->foreground !== null) { + $text[] = [$layer->id, $layer->x + $run->column * $layer->grid->cellWidth, + $layer->y + $run->row * $layer->grid->cellHeight, $run->text]; + } + } + } + return $text; +} + +function getModalMenuFrameBounds(PresentationCanvas $canvas): CanvasRectangle +{ + $frame = array_find($canvas->images, fn($image) => $image->id === 'menu-modal-frame-0-0') + ?? array_find($canvas->textLayers, fn($layer) => $layer->id === 'menu-modal-frame'); + return $frame->clipRect; +} + +function modalMenuForeground(array $layers): array +{ + $paint = []; + foreach ($layers as $layer) { + if (str_starts_with($layer->id, 'menu-modal-')) { continue; } + foreach ($layer->runs as $run) { + if ($run->foreground === null) { continue; } + $paint[] = [$layer->layer, $layer->x + $run->column * $layer->grid->cellWidth, + $layer->y + $run->row * $layer->grid->cellHeight, $run->text, $run->foreground->toArray(), + $run->background?->toArray(), $layer->opacity, $layer->grid->cellWidth, $layer->grid->cellHeight]; + } + } + sort($paint); + return $paint; +} + +beforeEach(function () { + $this->saved = []; + foreach ([Console::class, ConfigStore::class, InputManager::class, ActionHints::class, Timers::class, + ModalManager::class, EventManager::class, AudioManager::class, Debug::class] as $class) { + $this->saved[$class] = new ReflectionClass($class)->getStaticProperties(); + } + Timers::setFrameTick(null); + ActionHints::useProvider(null); + new ReflectionProperty(ModalManager::class, 'instance')->setValue(null, null); + new ReflectionProperty(EventManager::class, 'instance')->setValue(null, null); + $this->root = sys_get_temp_dir() . '/ichiloto-menu-modal-' . bin2hex(random_bytes(5)); + mkdir($this->root . '/Data/Presentation', 0777, true); + modalMenuPng($this->root . '/art.png'); + Debug::configure(['log_directory' => $this->root . '/logs']); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + ConfigStore::put(ProjectConfig::class, new SceneAudioConfigStub()); + ConfigStore::put(\Ichiloto\Engine\Progress\Knowledge\KnowledgeCatalog::class, new SceneAudioConfigStub()); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + $this->game = new ModalMenuGame(); + $audio = new class extends AudioManager { + public array $sounds = []; + public function __construct() {} + public function playSystemSound(SystemSound $sound): void { $this->sounds[] = $sound; } + }; + $this->audio = $audio; + new ReflectionProperty(AudioManager::class, 'instance')->setValue(null, $audio); + new ReflectionProperty($this->game, 'audioManager')->setValue($this->game, $audio); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $this->manager = ModalManager::getInstance($this->game); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $this->manager); + $sceneManager = makeBareScene(SceneManager::class); + new ReflectionProperty($sceneManager, 'game')->setValue($sceneManager, $this->game); + new ReflectionProperty($this->game, 'sceneManager')->setValue($this->game, $sceneManager); + $this->scene = makeBareScene(GameScene::class); + new ReflectionProperty($this->scene, 'sceneManager')->setValue($this->scene, $sceneManager); + new ReflectionProperty($sceneManager, 'currentScene')->setValue($sceneManager, $this->scene); + new ReflectionProperty($this->scene, 'gameState')->setValue($this->scene, new GameState()); + $ui = makeBareScene(UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($ui, 'uiElements')->setValue($ui, new ItemList(UIElementInterface::class)); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + $this->party = new Party(); + $this->party->location = new PartyLocation('Current Location', 'Current Region'); + for ($i = 0; $i < 4; $i++) { $this->party->addMember(new Character('Actor ' . $i, 0, new Stats(), actorId: 'actor.' . $i)); } + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, $this->party); + $this->state = new ModalMenuMainState(new SceneStateContext($this->scene)); + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $this->state); + InputManager::setBindings(['confirm' => ['keys' => [KeyCode::ENTER]], 'cancel' => ['keys' => [KeyCode::ESCAPE]], + 'left' => ['keys' => [KeyCode::LEFT]], 'right' => ['keys' => [KeyCode::RIGHT]], + 'up' => ['keys' => [KeyCode::UP]], 'down' => ['keys' => [KeyCode::DOWN]]]); + $this->state->enter(); + foreach (['Additional One', 'Additional Two'] as $label) { + $this->state->mainMenu->addItem(new class($this->state->mainMenu, $label, 'Current command.') extends \Ichiloto\Engine\Core\Menu\MenuItem {}); + } + $this->runtime = null; + $this->startCanvas = function (bool $art = false, bool $showInputHints = true): void { + file_put_contents($this->root . '/' . MenuPresentationCatalog::FILE, ' $showInputHints], true) . ';'); + $this->transport = new FakeRendererTransport(); + $caps = MenuPresentationCatalog::CAPABILITIES; + $this->transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $this->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $this->root, + requiredCapabilities: $caps), $this->transport); + $this->runtime->start('Modal fixture', 135, 36); + $this->game->useRendererRuntime($this->runtime); + // Exercise the same presentation callback that Game installs for blocking modal waits. + Timers::setFrameTick(static function (): void {}, fn() => new ReflectionMethod(Game::class, 'presentBlockedFrame')->invoke($this->game)); + }; + ob_start(); +}); + +afterEach(function () { + $this->runtime?->shutdown(); + ob_end_clean(); + foreach ($this->saved as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +it('layers supported modals above the complete four-party menu through either theme', function (bool $art, string $type) { + ($this->startCanvas)($art); + $base = $this->scene->getPresentationCanvas(); + $modal = match ($type) { + 'alert' => new AlertModal($this->game, 'Equipment optimized!', 'Equipment'), + 'confirm' => new ConfirmModal($this->game, 'Confirm this action.', 'Confirmation'), + 'select' => new SelectModal($this->game, 'Choose where to go.', ['To Title', 'Exit'], 'Quit', default: 1), + }; + $this->manager->open($modal); + MenuModalPresentation::compose($base, $modal->getModalPresentation(), MenuPresentationCatalog::load($this->root)); + $frame = $this->scene->getPresentationCanvas(); + expect($frame)->toBeInstanceOf(PresentationCanvas::class)->and(count($frame->textLayers))->toBeLessThanOrEqual(64); + $modalText = array_filter($frame->textLayers, fn($text) => str_starts_with($text->id, 'menu-modal-')); + $modalImages = array_filter($frame->images, fn($image) => str_starts_with($image->id, 'menu-modal-')); + $maxBase = max([...array_column($base->textLayers, 'layer'), ...array_column($base->images, 'layer')]); + expect(min(array_column($modalText, 'layer')))->toBeGreaterThan($maxBase); + expect(min([PHP_INT_MAX, ...array_column($modalImages, 'layer')]))->toBeGreaterThan($maxBase); + expect(modalMenuForeground($frame->textLayers))->toBe(modalMenuForeground($base->textLayers)); + foreach ($base->images as $image) { + $retained = array_find($frame->images, fn($candidate) => $candidate->id === $image->id); + expect($retained)->not->toBeNull(); + if (!str_contains($image->id, 'cursor')) { expect($retained)->toEqual($image); } + } + foreach ($modalText as $text) { + if (!preg_match('/menu-modal-choice-\d+-text/', $text->id)) { continue; } + expect($text->x + $text->bounds->width / 2)->toEqualWithDelta($text->clipRect->x + $text->clipRect->width / 2, 0.000001); + } + expect(array_filter(array_column($modalText, 'id'), fn($id) => str_contains($id, 'separator')))->toBeEmpty(); + expect(array_filter([...array_column($modalText, 'id'), ...array_column($frame->images, 'id')], + fn($id) => str_starts_with($id, 'menu-modal-choice-' . $modal->getModalPresentation()->activeIndex . '-selected')))->not->toBeEmpty(); + $modal->hide(); + $source = new FakeInputSource(KeyCode::TAB); + InputManager::setInputSource($source); + expect($this->manager->currentModal)->toBe($modal) + ->and(modalMenuText($this->scene->getPresentationCanvas()))->toBe(modalMenuText($base)); + new ReflectionMethod(Game::class, 'presentBlockedFrame')->invoke($this->game); + expect(end($this->transport->sent)->payload)->toHaveKey('canvas') + ->and(json_encode(end($this->transport->sent)->payload))->not->toContain('menu-modal-') + ->and($source->keys)->toBe([KeyCode::TAB]); +})->with([false, true])->with(['alert', 'confirm', 'select']); + +it('follows the existing selected index through many long choices without dropping the current choice', function (bool $art) { + ($this->startCanvas)($art); + $base = $this->scene->getPresentationCanvas(); + $theme = MenuPresentationCatalog::load($this->root); + $choices = array_map(fn($i) => $i . ' ' . str_repeat('Long option ', 10), range(0, 59)); + foreach ([0, 30, 59] as $active) { + $frame = MenuModalPresentation::compose($base, new ModalPresentation('Choose', str_repeat('Complete message ', 8), $choices, $active, true), $theme); + $layer = array_find($frame->textLayers, fn($text) => $text->id === 'menu-modal-choice-' . $active . '-text'); + expect($layer)->not->toBeNull()->and(implode('', array_column($layer->runs, 'text')))->toBe($choices[$active]) + ->and(count($frame->textLayers))->toBeLessThanOrEqual(64) + ->and(array_column($frame->textLayers, 'id'))->toContain('menu-modal-choice-range'); + } +})->with([false, true]); + +it('submits the real Optimize alert through the blocked render frame and restores the same equipment owner', function (bool $art, bool $showInputHints) { + $equipment = new EquipmentMenuState(new SceneStateContext($this->scene)); + $equipment->character = $this->party->members->toArray()[0]; + new ReflectionProperty($this->scene, 'state')->setValue($this->scene, $equipment); + $equipment->enter(); + ($this->startCanvas)($art, $showInputHints); + $equipment->equipmentMenu->setActiveItemByIndex(1); + $source = new FakeInputSource(null, KeyCode::ENTER, KeyCode::TAB); + InputManager::setInputSource($source); + $equipment->equipmentMenu->getActiveItem()->execute($equipment->equipmentMenuContext); + $frames = array_filter($this->transport->sent, fn($message) => isset($message->payload['canvas'])); + expect($frames)->not->toBeEmpty()->and($this->manager->currentModal)->toBeNull() + ->and($this->scene->state)->toBe($equipment)->and($equipment->equipmentMenu->activeIndex)->toBe(1) + ->and($source->keys)->toBe([KeyCode::TAB]); + foreach ($frames as $message) { + $text = json_encode($message->payload['canvas']['textLayers']); + expect($text)->toContain('Equipment optimized!', 'menu-modal-choice-0-text', 'equipment-identity'); + if (!$showInputHints) { expect($text)->not->toContain('menu-modal-hints'); } + } + new ReflectionMethod(Game::class, 'presentBlockedFrame')->invoke($this->game); + expect(json_encode(end($this->transport->sent)->payload))->not->toContain('menu-modal-')->toContain('equipment-identity'); +})->with([false, true])->with([false, true]); + +it('cancels the real Quit selection through the same input path without exiting or consuming the next input', function () { + ($this->startCanvas)(); + $this->state->mainMenu->setActiveItemByLabel('Quit'); + $source = new FakeInputSource(null, KeyCode::ESCAPE, KeyCode::TAB); + InputManager::setInputSource($source); + $this->state->mainMenu->getActiveItem()->execute(); + expect($source->keys)->toBe([KeyCode::TAB])->and($this->game->hasStopped())->toBeFalse() + ->and($this->manager->currentModal)->toBeNull()->and($this->state->mainMenu->getActiveItem()->getLabel())->toBe('Quit'); + $frames = array_filter($this->transport->sent, fn($message) => isset($message->payload['canvas'])); + expect($frames)->not->toBeEmpty(); + foreach ($frames as $message) { expect(json_encode($message->payload))->toContain('To Title', 'Exit', 'menu-modal-choice-0-text'); } +}); + +it('honors safe default selection indexes and live semantic cancel bindings without a hardcoded C alias', function () { + ($this->startCanvas)(); + $source = new FakeInputSource(null, KeyCode::ENTER); + InputManager::setInputSource($source); + expect($this->manager->select('Discard progress?', ['Proceed', 'Cancel'], 'Confirm', default: 1))->toBe(1); + $frame = end($this->transport->sent)->payload['canvas']; + expect(array_filter(array_column($frame['textLayers'], 'id'), fn($id) => str_starts_with($id, 'menu-modal-choice-1-focus')))->not->toBeEmpty(); + InputManager::setBinding('cancel', [KeyCode::Q]); + $source = new FakeInputSource(KeyCode::C, KeyCode::ESCAPE, KeyCode::Q, KeyCode::TAB); + InputManager::setInputSource($source); + expect($this->manager->select('Cancel after rebinding', ['One', 'Two']))->toBe(-1) + ->and($source->keys)->toBe([KeyCode::TAB]); +}); + +it('keeps the presentation snapshot detached from mutable caller references and the real choice owner', function () { + $label = 'Cancel'; + $snapshot = new ModalPresentation('Title', 'Message', ['Proceed', &$label], 1, true); + $label = 'Changed'; + expect($snapshot->choices)->toBe(['Proceed', 'Cancel'])->and($snapshot->activeIndex)->toBe(1); + $modal = new SelectModal($this->game, 'Choose', ['One', 'Two'], default: 1); + $snapshot = $modal->getModalPresentation(); + $modal->setActiveButton(0); + expect($snapshot->activeIndex)->toBe(1)->and($modal->getModalPresentation()->activeIndex)->toBe(0); +}); + +it('uses the Select owner navigation for large-choice viewport changes without reading extra input', function (bool $art) { + ($this->startCanvas)($art); + MenuModalPresentation::compose($this->scene->getPresentationCanvas(), + new ModalPresentation('Choose', 'Choose', array_map(fn($i) => 'Choice ' . $i, range(0, 59)), 31, true), + MenuPresentationCatalog::load($this->root)); + $source = new FakeInputSource(KeyCode::DOWN, null, KeyCode::ENTER, KeyCode::TAB); + InputManager::setInputSource($source); + expect($this->manager->select('Choose', array_map(fn($i) => 'Choice ' . $i, range(0, 59)), default: 30))->toBe(31) + ->and($source->keys)->toBe([KeyCode::TAB]); + $frame = end($this->transport->sent)->payload['canvas']; + expect(array_column($frame['textLayers'], 'id'))->toContain('menu-modal-choice-31-text', 'menu-modal-choice-range') + ->and(count($frame['textLayers']))->toBeLessThanOrEqual(64); +})->with([false, true]); + +it('preserves confirm button outcomes and gives a shared confirm-cancel key one outcome', function (array $keys, bool $expected) { + ($this->startCanvas)(); + InputManager::setInputSource(new FakeInputSource(null, ...$keys)); + expect($this->manager->confirm('Proceed?', 'Confirm'))->toBe($expected)->and($this->manager->currentModal)->toBeNull(); + $this->audio->sounds = []; + InputManager::setBinding('cancel', [KeyCode::ENTER]); + InputManager::setInputSource(new FakeInputSource(KeyCode::ENTER)); + expect($this->manager->confirm('One outcome', 'Confirm'))->toBeTrue()->and($this->audio->sounds)->toBe([SystemSound::CONFIRM]); +})->with([[[KeyCode::ENTER], true], [[KeyCode::RIGHT, KeyCode::ENTER], false], [[KeyCode::ESCAPE], false]]); + +it('diagnoses unsupported or oversized modals instead of disguising them as generic alerts', function (string $type) { + ($this->startCanvas)(); + $modal = match ($type) { + 'prompt' => new PromptModal($this->game, 'Edit value', 'Prompt', 'Current'), + 'text' => new TextBoxModal($this->game, 'Typed dialogue', 'Speaker'), + 'long' => new AlertModal($this->game, str_repeat('Complete long message ', 2000), 'Long'), + }; + $this->manager->open($modal); + expect($this->scene->getPresentationCanvas())->toBeNull()->and(is_dir($this->root . '/logs'))->toBeTrue(); + $logs = implode('', array_map(file_get_contents(...), glob($this->root . '/logs/*'))); + expect($logs)->toContain('Menu presentation degraded to terminal', $type === 'long' ? 'finite menu viewport' : 'no supported menu canvas'); + new ReflectionMethod(Game::class, 'presentBlockedFrame')->invoke($this->game); + expect(end($this->transport->sent)->payload)->not->toHaveKey('canvas')->toHaveKey('textLayers'); + $modal->hide(); + new ReflectionMethod(Game::class, 'presentBlockedFrame')->invoke($this->game); + expect(end($this->transport->sent)->payload)->toHaveKey('canvas'); +})->with(['prompt', 'text', 'long']); + +it('sizes compact dialogs from current text and wraps long content at the same bounded font size', function (bool $art) { + ($this->startCanvas)($art); + $theme = MenuPresentationCatalog::load($this->root); + $base = $this->scene->getPresentationCanvas(); + $small = MenuModalPresentation::compose($base, new ModalPresentation('Alert', 'Equipment optimized!', ['OK'], 0), $theme); + $message = str_repeat('A complete longer message. ', 8); + $large = MenuModalPresentation::compose($base, new ModalPresentation('Long', $message, ['OK'], 0), $theme); + $smallBox = getModalMenuFrameBounds($small); + $largeBox = getModalMenuFrameBounds($large); + expect($smallBox->width)->toBeLessThan($largeBox->width)->toBeLessThan(500) + ->and($largeBox->width)->toBeLessThanOrEqual($base->width * 2 / 3) + ->and($smallBox->x + $smallBox->width / 2)->toBe($base->width / 2.0); + $text = array_find($large->textLayers, fn($layer) => $layer->id === 'menu-modal-message'); + expect(implode('', array_column($text->runs, 'text')))->toBe($message) + ->and($text->grid->cellWidth)->toBe($theme->metrics->cellWidth)->and($text->grid->cellHeight)->toBe($theme->metrics->cellHeight); +})->with([false, true]); + +it('marks only single-confirmation Alert snapshots for the acknowledgement layout', function () { + expect(new AlertModal($this->game, 'Complete', 'Alert')->getModalPresentation()->singleConfirmation)->toBeTrue() + ->and(new ConfirmModal($this->game, 'Proceed?', 'Confirm')->getModalPresentation()->singleConfirmation)->toBeFalse() + ->and(new SelectModal($this->game, 'Choose', ['Only choice'])->getModalPresentation()->singleConfirmation)->toBeFalse(); + expect(fn() => new ModalPresentation('Invalid', 'Message', ['Yes', 'No'], 0, singleConfirmation: true)) + ->toThrow(InvalidArgumentException::class); +}); + +it('centers the Alert message and its modest acknowledgement button', function (bool $art, bool $showInputHints, string $message) { + $theme = new MenuPresentationCatalog($this->root, [...modalMenuTheme($art), 'showInputHints' => $showInputHints]); + $snapshot = new AlertModal($this->game, $message, 'Alert')->getModalPresentation(); + $frame = MenuModalPresentation::compose(new PresentationCanvas(1350, 720), $snapshot, $theme); + $layer = fn(string $id) => array_find($frame->textLayers, fn($text) => $text->id === $id); + $box = getModalMenuFrameBounds($frame); + $title = $layer('menu-modal-title'); + $text = $layer('menu-modal-message'); + $button = $layer('menu-modal-choice-0-text'); + $m = $theme->metrics; + $contentWidth = $box->width - 2 * $m->panelPadding; + $bodyTop = $title->y + $title->bounds->height + $m->sectionGap; + $bodyBottom = $button->clipRect->y - $m->sectionGap; + $hints = $layer('menu-modal-hints'); + $footer = $showInputHints ? $hints->bounds->height + $m->sectionGap : 0; + expect($button->clipRect->x + $button->clipRect->width / 2)->toBe($box->x + $box->width / 2) + ->and($button->clipRect->width)->toBe((float)(12 * $m->cellWidth)) + ->and($button->clipRect->width)->toBeLessThan($contentWidth) + ->and($button->clipRect->y + $button->clipRect->height + $footer)->toBe($box->y + $box->height - $m->panelPadding) + ->and($button->x + $button->bounds->width / 2)->toBe($button->clipRect->x + $button->clipRect->width / 2) + ->and($text->y + $text->bounds->height / 2)->toBe(($bodyTop + $bodyBottom) / 2) + ->and($bodyBottom - $bodyTop)->toBeGreaterThanOrEqual(3 * $m->cellHeight) + ->and(implode('', array_column($text->runs, 'text')))->toBe($message) + ->and($text->grid->cellWidth)->toBe($m->cellWidth)->and($text->grid->cellHeight)->toBe($m->cellHeight) + ->and(count($frame->textLayers))->toBeLessThanOrEqual(64); + foreach ($text->runs as $run) { + expect($text->x + ($run->column + mb_strlen($run->text) / 2) * $m->cellWidth) + ->toEqualWithDelta($box->x + $box->width / 2, $m->cellWidth / 2); + } + if (!$showInputHints) { + expect($hints)->toBeNull()->and(array_filter(array_column($frame->images, 'id'), + fn($id) => str_starts_with($id, 'menu-modal-hints')))->toBeEmpty(); + } +})->with([false, true])->with([false, true])->with(['Equipment optimized!', str_repeat('A complete longer message. ', 8)]); + +it('keeps a long acknowledgement label complete and centered inside its bounded button', function (bool $art) { + $theme = new MenuPresentationCatalog($this->root, [...modalMenuTheme($art), 'showInputHints' => false]); + $label = str_repeat('Acknowledge current result ', 4); + $frame = MenuModalPresentation::compose(new PresentationCanvas(1350, 720), + new ModalPresentation('Alert', 'Complete', [$label], 0, singleConfirmation: true), $theme); + $box = getModalMenuFrameBounds($frame); + $button = array_find($frame->textLayers, fn($text) => $text->id === 'menu-modal-choice-0-text'); + expect(implode('', array_column($button->runs, 'text')))->toBe($label) + ->and($box->width)->toBeLessThanOrEqual(900)->and($box->height)->toBeLessThan(720) + ->and($button->clipRect->width)->toBe($box->width - 2 * $theme->metrics->panelPadding) + ->and($button->y)->toBeGreaterThanOrEqual($button->clipRect->y) + ->and($button->y + $button->bounds->height)->toBeLessThanOrEqual($button->clipRect->y + $button->clipRect->height); + foreach ($button->runs as $run) { + expect($button->x + ($run->column + mb_strlen($run->text) / 2) * $button->grid->cellWidth) + ->toEqualWithDelta($button->clipRect->x + $button->clipRect->width / 2, $button->grid->cellWidth / 2); + } +})->with([false, true]); + +it('collapses only the hidden hint footer without changing Confirm or Select content layout', function (bool $art, string $type) { + $snapshot = match ($type) { + 'confirm' => new ConfirmModal($this->game, "First line\nSecond line", 'Confirm')->getModalPresentation(), + 'select' => new SelectModal($this->game, "First line\nSecond line", ['One', 'Two'], 'Select')->getModalPresentation(), + 'single-select' => new SelectModal($this->game, "First line\nSecond line", ['Only choice'], 'Select')->getModalPresentation(), + }; + $shownTheme = new MenuPresentationCatalog($this->root, modalMenuTheme($art)); + $hiddenTheme = new MenuPresentationCatalog($this->root, [...modalMenuTheme($art), 'showInputHints' => false]); + $base = new PresentationCanvas(1350, 720); + $shown = MenuModalPresentation::compose($base, $snapshot, $shownTheme); + $hidden = MenuModalPresentation::compose($base, $snapshot, $hiddenTheme); + $layer = fn(PresentationCanvas $frame, string $id) => array_find($frame->textLayers, fn($text) => $text->id === $id); + $shownBox = getModalMenuFrameBounds($shown); + $hiddenBox = getModalMenuFrameBounds($hidden); + $hints = $layer($shown, 'menu-modal-hints'); + expect($shownBox->height - $hiddenBox->height)->toBe($hints->bounds->height + $shownTheme->metrics->sectionGap) + ->and($layer($hidden, 'menu-modal-hints'))->toBeNull(); + foreach (['menu-modal-title', 'menu-modal-message', ...array_map(fn($index) => 'menu-modal-choice-' . $index . '-text', array_keys($snapshot->choices))] as $id) { + $before = $layer($shown, $id); + $after = $layer($hidden, $id); + expect($after->x)->toBe($before->x)->and($after->bounds->width)->toBe($before->bounds->width) + ->and($after->y - $hiddenBox->y)->toBe($before->y - $shownBox->y)->and($after->runs)->toEqual($before->runs); + } + $message = $layer($hidden, 'menu-modal-message'); + expect(array_column($message->runs, 'column'))->toBe([0, 0]); + $contentWidth = $hiddenBox->width - 2 * $hiddenTheme->metrics->panelPadding; + $button = $layer($hidden, 'menu-modal-choice-0-text'); + expect($button->clipRect->x)->toBe($hiddenBox->x + $hiddenTheme->metrics->panelPadding) + ->and($button->clipRect->width)->toBe($snapshot->vertical ? $contentWidth : ($contentWidth - $hiddenTheme->metrics->sectionGap) / 2); +})->with([false, true])->with(['confirm', 'select', 'single-select']); + +it('reserves moving cursors for vertical choice lists rather than alert or confirmation buttons', function (bool $art) { + $theme = new MenuPresentationCatalog($this->root, modalMenuTheme($art)); + $base = new PresentationCanvas(1350, 720); + foreach ([new ModalPresentation('', 'Complete.', ['OK'], 0, singleConfirmation: true), + new ModalPresentation('Confirm', 'Continue?', ['Cancel', 'Continue'], 0)] as $snapshot) { + $first = MenuModalPresentation::compose($base, $snapshot, $theme, 0); + $next = MenuModalPresentation::compose($base, $snapshot, $theme, 0.6); + expect($first->toArray())->toBe($next->toArray()) + ->and(array_filter($first->images, fn($image) => str_contains($image->id, '-cursor')))->toBeEmpty(); + $layers = [...$first->images, ...$first->textLayers]; + expect(array_filter($layers, fn($layer) => str_starts_with($layer->id, 'menu-modal-choice-0-focus')))->not->toBeEmpty(); + } + $list = new ModalPresentation('Choose', '', ['To Title', 'Exit'], 0, vertical: true); + $first = MenuModalPresentation::compose($base, $list, $theme, 0); + $next = MenuModalPresentation::compose($base, $list, $theme, 0.6); + $cursor = fn($frame) => array_find($frame->images, fn($image) => str_contains($image->id, '-cursor')); + expect($cursor($first))->not->toBeNull() + ->and($cursor($next)->destination->x)->toBeGreaterThan($cursor($first)->destination->x); +})->with([false, true]); + +it('batches only fully visible nonoverlapping text without changing paint positions or clipping', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1']); + $layers = []; + for ($i = 0; $i < 65; $i++) { + $layers[] = new CanvasTextLayer('text-' . $i, 30, 5, $i * 10, new RendererGridConfig(4, 1, 10, 10), + [new PresentationTextRun(0, 0, (string)$i, $theme->colors['text'])], new CanvasRectangle(5, $i * 10, 40, 10)); + } + $batched = MenuCanvasTextBatch::compact($layers); + expect($batched)->toHaveCount(64)->and(modalMenuForeground($batched))->toBe(modalMenuForeground($layers)); + $clipped = array_map(fn($text) => new CanvasTextLayer($text->id, $text->layer, $text->x, $text->y, + $text->grid, $text->runs, new CanvasRectangle($text->x, $text->y, 10, 10)), $layers); + expect(MenuCanvasTextBatch::compact($clipped))->toBe($clipped); + $overlapping = array_map(fn($text) => new CanvasTextLayer($text->id, $text->layer, 0, 0, $text->grid, $text->runs), $layers); + expect(MenuCanvasTextBatch::compact($overlapping))->toBe($overlapping); +}); + +it('stacks quantity chevrons to the right while independently centering the value and Continue', function (bool $art, int $amount) { + $data = [...modalMenuTheme(false), 'showInputHints' => false]; + if ($art) { + $data['icons'] = array_fill_keys(['navigation.previous', 'navigation.next', 'navigation.up', 'navigation.down'], 'art.png'); + } + $theme = new MenuPresentationCatalog($this->root, $data); + $snapshot = new ModalPresentation('Discard item', 'Potion', ['Continue'], 0, + quantity: new \Ichiloto\Engine\UI\Modal\QuantityPresentation(1, 99, $amount)); + $frame = MenuModalPresentation::compose(new PresentationCanvas(1350, 720), $snapshot, $theme); + $layers = array_column($frame->textLayers, null, 'id'); + $quantity = $layers['menu-modal-quantity']; + expect(implode('', array_column($quantity->runs, 'text')))->toBe($amount . ' / 99'); + $button = $layers['menu-modal-choice-0-text']; + expect($button->clipRect->x + $button->clipRect->width / 2)->toBe(675.0) + ->and($button->clipRect->width)->toBe(140.0) + ->and($quantity->clipRect->x + $quantity->clipRect->width / 2)->toBe(675.0) + ->and($button->clipRect->y - ($quantity->clipRect->y + $quantity->clipRect->height)) + ->toBe((float)($theme->metrics->sectionGap + $theme->metrics->cellHeight / 2)) + ->and(array_filter($frame->images, fn($image) => str_contains($image->id, 'cursor')))->toBeEmpty(); + $arrows = []; + foreach ([\Ichiloto\Engine\UI\Presentation\MenuDirection::UP, \Ichiloto\Engine\UI\Presentation\MenuDirection::DOWN] as $direction) { + $id = 'menu-modal-quantity-' . $direction->value; + $pieces = $art ? array_values(array_filter($frame->images, fn($image) => str_starts_with($image->id, $id))) : [$layers[$id]]; + expect($pieces)->toHaveCount(1); + $piece = $pieces[0]; + $enabled = $direction === \Ichiloto\Engine\UI\Presentation\MenuDirection::UP ? $amount < 99 : $amount > 1; + expect($piece->opacity)->toBe($enabled ? 1.0 : 0.35); + $arrows[$direction->value] = $art ? $piece->destination : $piece->clipRect; + if (!$art) { expect($piece->runs[0]->text)->toBe($direction->getGlyph(chevron: true)); } + } + expect($arrows['up']->x + $arrows['up']->width / 2)->toBe($arrows['down']->x + $arrows['down']->width / 2) + ->and($arrows['up']->x)->toBeGreaterThan($quantity->clipRect->x + $quantity->clipRect->width) + ->and($arrows['up']->y + $arrows['up']->height)->toBeLessThanOrEqual($arrows['down']->y) + ->and(($arrows['up']->y + $arrows['down']->y + $arrows['down']->height) / 2) + ->toBe($quantity->clipRect->y + $quantity->clipRect->height / 2); + expect(array_filter([...$frame->images, ...$frame->textLayers], fn($layer) => + str_starts_with($layer->id, 'menu-modal-quantity-previous') || str_starts_with($layer->id, 'menu-modal-quantity-next')))->toBeEmpty(); +})->with([false, true])->with([1, 50, 99]); + +it('fits the complete largest integer quantity without widening beyond the modal viewport', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false]); + $frame = MenuModalPresentation::compose(new PresentationCanvas(1350, 720), + new ModalPresentation('Quantity', 'Stack', ['Continue'], 0, + quantity: new \Ichiloto\Engine\UI\Modal\QuantityPresentation(1, PHP_INT_MAX, PHP_INT_MAX)), $theme); + $layers = array_column($frame->textLayers, null, 'id'); + $box = getModalMenuFrameBounds($frame); + foreach ($frame->textLayers as $layer) { + if (!str_starts_with($layer->id, 'menu-modal-quantity')) { continue; } + expect($layer->bounds->x)->toBeGreaterThanOrEqual($box->x) + ->and($layer->bounds->x + $layer->bounds->width)->toBeLessThanOrEqual($box->x + $box->width); + } +}); + +it('batches default focus edges without changing any filled pixel or the centered command label', function () { + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1']); + $base = new PresentationCanvas(450, 100, textLayers: array_map(fn($i) => new CanvasTextLayer('base-' . $i, 0, 0, 0, + new RendererGridConfig(1, 1, 10, 10), [new PresentationTextRun(0, 0, 'A', $theme->colors['text'])]), range(0, 60))); + $overlay = MenuRowPainter::compose(450, 100, 'menu-modal-choice', [new MenuRow('0', 'OK', kind: MenuRowKind::COMMAND, focused: true)], + new MenuRowLayout(new CanvasRectangle(10, 10, 360, 40)), $theme->rows); + $frame = MenuCanvas::overlay($base, $overlay, $theme); + $pixels = static function (array $layers): array { + $pixels = []; + foreach ($layers as $layer) { + if (!str_contains($layer->id, '-focus')) { continue; } + foreach ($layer->runs as $run) { + for ($x = 0; $x < mb_strlen($run->text) * $layer->grid->cellWidth; $x++) { + for ($y = 0; $y < $layer->grid->cellHeight; $y++) { + $pixels[($layer->x + $run->column * $layer->grid->cellWidth + $x) . ':' + . ($layer->y + $run->row * $layer->grid->cellHeight + $y)] = [$run->background->toArray(), $layer->opacity]; + } + } + } + } + ksort($pixels); + return $pixels; + }; + expect($pixels($frame->textLayers))->toBe($pixels($overlay->textLayers))->and(count($frame->textLayers))->toBeLessThanOrEqual(64); + $label = array_find($frame->textLayers, fn($text) => $text->id === 'menu-modal-choice-0-text'); + expect($label->x + $label->bounds->width / 2)->toBe(190.0); +}); diff --git a/tests/Unit/MenuPanelTransparencyTest.php b/tests/Unit/MenuPanelTransparencyTest.php new file mode 100644 index 00000000..6806e3eb --- /dev/null +++ b/tests/Unit/MenuPanelTransparencyTest.php @@ -0,0 +1,122 @@ + 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', gzcompress($pixels)) . $chunk('IEND', '')); +} + +function containsPanelTestPoint(CanvasRectangle $box, float $x, float $y): bool +{ + return $x >= $box->x && $x < $box->x + $box->width && $y >= $box->y && $y < $box->y + $box->height; +} + +/** Assert emitted paint leaves all four source-alpha cutouts exposed, including selected records. */ +function assertPanelCutouts(PresentationCanvas $canvas, string $id, int $sourceWidth, int $sourceHeight): void +{ + $pieces = array_column($canvas->images, null, 'id'); + foreach (['0-0', '0-2', '2-0', '2-2'] as $cell) { + $corner = $pieces[$id . '-' . $cell]; + $right = str_ends_with($cell, '2'); + $bottom = str_starts_with($cell, '2'); + foreach ([[0.5, 0.5], [2.5, 1.5]] as [$dx, $dy]) { + $x = $corner->destination->x + ($right ? $corner->destination->width - $dx : $dx); + $y = $corner->destination->y + ($bottom ? $corner->destination->height - $dy : $dy); + $source = $corner->sourceRect; + $sx = (int)floor($source->x + ($x - $corner->destination->x) * $source->width / $corner->destination->width); + $sy = (int)floor($source->y + ($y - $corner->destination->y) * $source->height / $corner->destination->height); + expect(min($sx, $sourceWidth - 1 - $sx) + min($sy, $sourceHeight - 1 - $sy))->toBeLessThan(6); + foreach ($canvas->textLayers as $layer) { + if ($layer->id === 'scene' || !containsPanelTestPoint($layer->clipRect ?? $layer->bounds, $x, $y)) { continue; } + foreach ($layer->runs as $run) { + if ($run->background === null) { continue; } + $paint = new CanvasRectangle($layer->x + $run->column * $layer->grid->cellWidth, + $layer->y + $run->row * $layer->grid->cellHeight, + max(1, mb_strlen($run->text)) * $layer->grid->cellWidth, $layer->grid->cellHeight); + expect(containsPanelTestPoint($paint, $x, $y), $id . ' cutout covered by ' . $layer->id)->toBeFalse(); + } + } + } + } + // The center comes from opaque source pixels, so underlying scene content stays covered. + $center = $pieces[$id . '-1-1']; + expect($center->sourceRect->x)->toBe(8)->and($center->sourceRect->y)->toBe(8) + ->and($center->sourceRect->width)->toBe($sourceWidth - 16) + ->and($center->sourceRect->height)->toBe($sourceHeight - 16) + ->and($center->opacity)->toBe(1.0); +} + +beforeEach(function () { + $this->panelRoot = sys_get_temp_dir() . '/ichiloto-panel-alpha-' . bin2hex(random_bytes(5)); + mkdir($this->panelRoot); +}); + +afterEach(function () { + foreach (glob($this->panelRoot . '/*') as $file) { unlink($file); } + rmdir($this->panelRoot); +}); + +it('preserves authored panel cutouts across frames save cards Info and stacked dialogs', function (array $color) { + writeChamferedPanelFixture($this->panelRoot . '/frame.png', 24, 32, $color); + $frameArt = ['asset' => 'frame.png', 'cuts' => [8, 8, 8, 8], 'borderWidths' => [8, 8, 8, 8]]; + $theme = new MenuPresentationCatalog($this->panelRoot, ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, + 'colors' => ['panel' => $color], 'frames' => ['panel' => $frameArt, 'quiet' => $frameArt]]); + $scene = new MenuCanvas($theme); + $scene->surface('scene', new CanvasRectangle(0, 0, 1350, 720), 'accent', 1); + $base = new PresentationCanvas(1350, 720, textLayers: array_values(array_filter($scene->finish()->textLayers, + fn($layer) => $layer->id === 'scene'))); + foreach ([[24, 32], [51, 43]] as [$width, $height]) { + writeChamferedPanelFixture($this->panelRoot . '/frame.png', $width, $height, $color); + foreach (['panel', 'quiet'] as $role) { + $view = new MenuCanvas($theme); + $view->frame('panel-test', new CanvasRectangle(100.5, 120.5, 450, 160), $role); + $canvas = MenuCanvas::overlay($base, $view->finish(), $theme); + assertPanelCutouts($canvas, 'panel-test', $width, $height); + } + $saves = SaveLoadMenuPresentation::compose([ + new SaveSlot(1, '', false, 'Field Post', 'Actor', 8, 360), + SaveSlot::empty(2, ''), + ], 0, $theme, new MenuInfoText()); + $saves = MenuCanvas::overlay($base, $saves, $theme); + foreach (['save-prompt', 'save-slot-1-frame', 'save-slot-2-frame', 'save-info'] as $id) { + assertPanelCutouts($saves, $id, $width, $height); + } + $dialog = MenuModalPresentation::compose($saves, + new ModalPresentation('Confirm', 'Continue?', ['OK', 'Cancel'], 0), $theme); + assertPanelCutouts($dialog, 'menu-modal-frame', $width, $height); + } +})->with([[[16, 29, 49]], [[230, 220, 200]]]); + +it('retains the opaque rectangular fallback when no frame artwork is supplied', function () { + $theme = new MenuPresentationCatalog($this->panelRoot, ['schema' => 'ichiloto.menu/1']); + $view = new MenuCanvas($theme); + $box = new CanvasRectangle(10, 20, 300, 140); + $view->frame('unskinned-panel', $box); + $canvas = $view->finish(); + $fill = array_find($canvas->textLayers, fn($layer) => $layer->id === 'unskinned-panel'); + expect($canvas->images)->toBeEmpty()->and($fill->clipRect)->toEqual($box)->and($fill->opacity)->toBe(1.0); + foreach ($fill->runs as $run) { expect($run->background)->toEqual($theme->colors['panel']); } +}); diff --git a/tests/Unit/MenuPresentationTest.php b/tests/Unit/MenuPresentationTest.php new file mode 100644 index 00000000..d2b98130 --- /dev/null +++ b/tests/Unit/MenuPresentationTest.php @@ -0,0 +1,499 @@ + PresentationColor::rgb(230, 237, 245), + 'selected' => PresentationColor::rgb(34, 63, 94), + 'accent' => PresentationColor::rgb(192, 154, 91), + 'focus' => PresentationColor::rgb(255, 255, 255), + 'disabled' => PresentationColor::rgb(99, 110, 122), + 'edge' => PresentationColor::rgb(82, 104, 129), + ]); +} + +/** Synthetic replaceable PNGs, not approval gates on project artwork. */ +function menuPresentationPng(string $path, int $width, int $height): void +{ + $chunk = static fn(string $type, string $bytes): string => pack('N', strlen($bytes)) . $type . $bytes + . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" + . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xFF\xFF\xFF\xFF", $width), $height))) + . $chunk('IEND', '')); +} + +beforeEach(function () { + $this->menuRoot = sys_get_temp_dir() . '/ichiloto-menu-rows-' . bin2hex(random_bytes(5)); + mkdir($this->menuRoot); + foreach (['staff', 'slot', 'unknown', 'cursor'] as $name) { + menuPresentationPng($this->menuRoot . '/' . $name . '.png', 20, 40); + } + $this->menuIcons = new MenuIconRegistry($this->menuRoot, [ + 'weapon.staff' => 'staff.png', 'slot.head' => 'slot.png', 'unknown' => 'unknown.png', + ], 'cursor.png'); +}); + +afterEach(function () { + foreach (glob($this->menuRoot . '/*') ?: [] as $file) { unlink($file); } + rmdir($this->menuRoot); +}); + +it('draws in-bounds separators only for real records and headings including named empty slots', function () { + $layout = new MenuRowLayout(new CanvasRectangle(20, 10, 400, 179), [new MenuRowColumn(10)]); + $rows = [ + new MenuRow('item', 'Tonic', [new MenuRowValue('2')]), + new MenuRow('head', 'Head:', [new MenuRowValue('')], icon: EquipmentSlotType::HEAD), + new MenuRow('heading', '', [new MenuRowValue('Current')], MenuRowKind::HEADING), + new MenuRow('command', 'Equip', kind: MenuRowKind::COMMAND), + ]; + $frame = MenuRowPainter::compose(500, 200, 'list', $rows, $layout, menuPresentationSkin(), $this->menuIcons); + $layers = array_column($frame->textLayers, null, 'id'); + expect($layout->capacity())->toBe(4)->and($layers)->not->toHaveKey('list-command-separator'); + foreach (['item', 'head', 'heading'] as $index => $id) { + $line = $layers['list-' . $id . '-separator']; + expect($line->clipRect->y)->toBe(49.0 + 40 * $index) + ->and($line->clipRect->height)->toBe(1.0) + ->and($line->opacity)->toBe($id === 'heading' ? 1.0 : 64 / 255); + } + expect(array_column($layers['list-head-text']->runs, 'text'))->toBe(['Head:']); + foreach ($frame->textLayers as $text) { + expect($text->clipRect->y + $text->clipRect->height)->toBeLessThanOrEqual(170.0); + } + expect(MenuRowPainter::compose(500, 200, 'empty', [], $layout, menuPresentationSkin())->textLayers)->toBe([]); + expect(fn() => new MenuRow('blank', ''))->toThrow(InvalidArgumentException::class, 'Blank space'); +}); + +it('keeps all numeric columns right aligned and comparison indicators independently supplied', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 120), [ + new MenuRowColumn(6), new MenuRowColumn(2, HorizontalAlignment::CENTER), new MenuRowColumn(6), + new MenuRowColumn(1, HorizontalAlignment::CENTER), + ], rowHeight: 34, cellWidth: 8, cellHeight: 20); + $mint = PresentationColor::rgb(150, 210, 180); + $rows = array_map(fn($index) => new MenuRow('stat-' . $index, 'HP', [ + new MenuRowValue(['9', '1,234', '10'][$index]), new MenuRowValue('>'), + new MenuRowValue(['10', '1,235', '9'][$index], $mint), new MenuRowValue('+', $mint), + ]), range(0, 2)); + $frame = MenuRowPainter::compose(400, 120, 'stats', $rows, $layout, menuPresentationSkin()); + $rightEdges = []; + foreach (array_filter($frame->textLayers, fn($text) => str_ends_with($text->id, '-text')) as $text) { + $rightEdges[] = array_map(fn($run) => $text->x + ($run->column + mb_strlen($run->text)) * $text->grid->cellWidth, + [$text->runs[1], $text->runs[3]]); + expect($text->runs[3]->foreground)->toEqual($mint)->and($text->runs[4]->text)->toBe('+'); + } + expect($rightEdges[0])->toBe($rightEdges[1])->toBe($rightEdges[2]); +}); + +it('centers the complete command label regardless of icons selection focus or cursor drift', function () { + $layout = new MenuRowLayout(new CanvasRectangle(21.5, 0, 401, 40)); + $row = new MenuRow('equip', 'Equip', kind: MenuRowKind::COMMAND, icon: WeaponType::STAFF, selected: true, focused: true); + $frames = array_map(fn($time) => MenuRowPainter::compose(450, 40, 'commands', [$row], $layout, + menuPresentationSkin(), $this->menuIcons, $time), [0, 0.6]); + $text = array_column($frames[0]->textLayers, null, 'id')['commands-equip-text']; + expect($text->x + $text->bounds->width / 2)->toBe(222.0) + ->and($frames[0]->textLayers)->toEqual($frames[1]->textLayers); + $first = array_column($frames[0]->images, null, 'id'); + $second = array_column($frames[1]->images, null, 'id'); + expect($first['commands-equip-icon-1-1'])->toEqual($second['commands-equip-icon-1-1']) + ->and($second['commands-equip-cursor-1-1']->destination->x - $first['commands-equip-cursor-1-1']->destination->x)->toBe(4.0); +}); + +it('uses steady focused buttons without cursors while retaining command artwork and centered labels', function () { + $art = new MenuRowArtwork('slot.png'); + $skins = [menuPresentationSkin(), new MenuRowSkin(menuPresentationSkin()->colors, + artwork: ['command.selected' => $art, 'command.focus' => $art], assetRoot: $this->menuRoot)]; + $layout = new MenuRowLayout(new CanvasRectangle(21.5, 0, 401, 40)); + foreach ($skins as $skin) { + $row = new MenuRow('ok', 'OK', kind: MenuRowKind::BUTTON, selected: true, focused: true); + $before = MenuRowPainter::compose(450, 40, 'action', [$row], $layout, $skin, $this->menuIcons, 0); + $after = MenuRowPainter::compose(450, 40, 'action', [$row], $layout, $skin, $this->menuIcons, 0.6); + $layers = [...$before->textLayers, ...$before->images]; + $label = array_find($before->textLayers, fn($text) => $text->id === 'action-ok-text'); + expect($before->toArray())->toBe($after->toArray()) + ->and(array_filter($layers, fn($layer) => str_contains($layer->id, '-cursor')))->toBeEmpty() + ->and(array_filter($layers, fn($layer) => str_contains($layer->id, '-separator')))->toBeEmpty() + ->and(array_filter($layers, fn($layer) => str_contains($layer->id, '-selected')))->not->toBeEmpty() + ->and(array_filter($layers, fn($layer) => str_contains($layer->id, '-focus')))->not->toBeEmpty() + ->and($label->x + $label->bounds->width / 2)->toBe(222.0); + } + expect(fn() => new MenuRow('button', 'OK', [new MenuRowValue('1')], MenuRowKind::BUTTON)) + ->toThrow(InvalidArgumentException::class); +}); + +it('preserves steady selection when focus moves and draws focus without inventing selection', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 80)); + $rows = [new MenuRow('marked', 'First', selected: true), new MenuRow('focus', 'Second', focused: true)]; + $frame = MenuRowPainter::compose(400, 80, 'list', $rows, $layout, menuPresentationSkin(), $this->menuIcons); + $layers = array_column($frame->textLayers, null, 'id'); + expect($layers)->toHaveKey('list-marked-selected')->not->toHaveKey('list-marked-focus-0') + ->toHaveKey('list-focus-focus-0')->not->toHaveKey('list-focus-selected'); + expect(array_column($frame->images, 'id'))->toBe(['list-focus-cursor-1-1']); +}); + +it('can suppress a record cursor without suppressing its selection or focus', function () { + $frame = MenuRowPainter::compose(400, 40, 'card', + [new MenuRow('actor', 'Actor', selected: true, focused: true, showCursor: false)], + new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)), menuPresentationSkin(), $this->menuIcons); + expect($frame->images)->toBeEmpty() + ->and(array_column($frame->textLayers, null, 'id'))->toHaveKeys(['card-actor-selected', 'card-actor-focus-0', 'card-actor-text']); +}); + +it('partitions straight frame edges without overlapping alpha while preserving corner artwork', function (int $density) { + menuPresentationPng($this->menuRoot . '/panel.png', 96, 96); + $art = new MenuRowArtwork('panel.png', 24, 24, 24, 24, $density, [8, 8, 8, 8]); + $box = new CanvasRectangle(10, 10, 300, 140); + $images = array_column($art->images($this->menuRoot, 'panel', $box, 10, 20), null, 'id'); + expect($images)->toHaveCount(13) + ->and($art->borderInsets($this->menuRoot, $box))->toBe(array_fill(0, 4, 8.0 / $density)); + foreach (['0-0', '0-2', '2-0', '2-2'] as $cell) { + expect($images['panel-' . $cell]->layer)->toBe(20); + } + foreach (['0-1', '1-0', '1-2', '2-1'] as $cell) { + $border = $images['panel-' . $cell . '-border']; + $backing = $images['panel-' . $cell . '-backing']; + $a = $border->clipRect; + $b = $backing->clipRect; + $overlap = max(0, min($a->x + $a->width, $b->x + $b->width) - max($a->x, $b->x)) + * max(0, min($a->y + $a->height, $b->y + $b->height) - max($a->y, $b->y)); + expect($border->layer)->toBe(20)->and($backing->layer)->toBe(10) + ->and($border->destination)->toEqual($backing->destination) + ->and($border->sourceRect)->toEqual($backing->sourceRect) + ->and($overlap)->toEqual(0) + ->and($a->width * $a->height + $b->width * $b->height) + ->toEqualWithDelta($border->destination->width * $border->destination->height, 0.000001); + } + CanvasImagePreflight::inspect(array_values($images), $this->menuRoot); + menuPresentationPng($this->menuRoot . '/panel.png', 9, 7); + expect($art->borderInsets($this->menuRoot, $box))->toBe([4.0 / $density, 3.0 / $density, 4.0 / $density, 3.0 / $density]); + CanvasImagePreflight::inspect($art->images($this->menuRoot, 'small', $box, 10, 20), $this->menuRoot); +})->with([1, 2]); + +it('preserves default frame behavior and validates decorative widths as authoring intent', function () { + $box = new CanvasRectangle(0, 0, 100, 100); + $art = new MenuRowArtwork('slot.png', 4, 4, 4, 4); + expect($art->borderInsets($this->menuRoot, $box))->toBeNull() + ->and($art->images($this->menuRoot, 'panel', $box, 10, 20))->toHaveCount(9); + foreach ([[1, 2], [-1, 0, 0, 0], [1.5, 2, 3, 4]] as $widths) { + expect(fn() => new MenuRowArtwork('slot.png', borderWidths: $widths))->toThrow(InvalidArgumentException::class); + } +}); + +it('keeps zero available equipment selectable unless the owner explicitly disables it', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40), [new MenuRowColumn(3)]); + foreach ([false, true] as $disabled) { + $row = new MenuRow('current', 'Current staff', [new MenuRowValue('0')], icon: WeaponType::STAFF, + selected: true, focused: true, disabled: $disabled); + $frame = MenuRowPainter::compose(400, 40, 'gear', [$row], $layout, menuPresentationSkin(), $this->menuIcons); + $text = array_column($frame->textLayers, null, 'id')['gear-current-text']; + expect(array_column($text->runs, 'text'))->toBe(['Current staff', '0']) + ->and($text->runs[0]->foreground)->toEqual(menuPresentationSkin()->colors[$disabled ? 'disabled' : 'text']) + ->and(count($frame->images))->toBe($disabled ? 1 : 2); + } +}); + +it('keeps reduced-motion frames identical without suppressing meaning or changing configuration', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)); + $rows = [new MenuRow('row', 'Choice', selected: true, focused: true)]; + $compose = fn($time) => MenuRowPainter::compose(400, 40, 'list', $rows, $layout, menuPresentationSkin(), + $this->menuIcons, time: $time, reducedMotion: true); + expect($compose(0)->toArray())->toBe($compose(0.6)->toArray()) + ->and($compose(0)->images)->toHaveCount(1); +}); + +it('uses explicit weapon and slot metadata and falls back only for unknown semantics', function () { + foreach (WeaponType::cases() as $type) { + expect($this->menuIcons->asset($type))->toBe($type === WeaponType::STAFF ? 'staff.png' : 'unknown.png'); + } + foreach (EquipmentSlotType::cases() as $slot) { + expect($this->menuIcons->asset($slot))->toBe($slot === EquipmentSlotType::HEAD ? 'slot.png' : 'unknown.png'); + } + expect($this->menuIcons->asset(null))->toBeNull() + ->and($this->menuIcons->asset('weapon.staff'))->toBe('staff.png') + ->and($this->menuIcons->asset('custom.unregistered'))->toBe('unknown.png'); + $rows = [new MenuRow('renamed', 'A completely new name', icon: WeaponType::STAFF), + new MenuRow('missing', 'Staff'), new MenuRow('unknown', 'Staff', icon: 'custom.unregistered')]; + $frame = MenuRowPainter::compose(500, 120, 'list', $rows, new MenuRowLayout(new CanvasRectangle(0, 0, 500, 120)), + menuPresentationSkin(), $this->menuIcons); + expect(array_column($frame->images, 'asset'))->toBe(['staff.png', 'unknown.png']); +}); + +it('requires no artwork registry and retains text and visible focus without fake glyph icons', function () { + $row = new MenuRow('row', 'Staff', icon: WeaponType::STAFF, selected: true, focused: true); + $frame = MenuRowPainter::compose(400, 40, 'list', [$row], new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)), menuPresentationSkin()); + expect($frame->images)->toBe([]) + ->and(array_column($frame->textLayers, null, 'id'))->toHaveKey('list-row-focus-0') + ->and(array_column($frame->textLayers, null, 'id')['list-row-text']->runs[0]->text)->toBe('Staff'); +}); + +it('reads replacement file dimensions on each composition and contains both wide and tall icons', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40), [new MenuRowColumn(3)]); + $row = new MenuRow('staff', 'Name', [new MenuRowValue('4')], icon: WeaponType::STAFF); + $previousText = null; + foreach ([[12, 60], [90, 15], [39, 39]] as [$width, $height]) { + menuPresentationPng($this->menuRoot . '/staff.png', $width, $height); + $frame = MenuRowPainter::compose(400, 40, 'list', [$row], $layout, menuPresentationSkin(), $this->menuIcons); + $image = $frame->images[0]; + expect($image->destination->width / $image->destination->height)->toEqualWithDelta($width / $height, 0.000001) + ->and(max($image->destination->width, $image->destination->height))->toEqualWithDelta(24.0, 0.000001) + ->and($image->destination->x + $image->destination->width / 2)->toEqualWithDelta(42.0, 0.000001) + ->and($image->destination->y + $image->destination->height / 2)->toEqualWithDelta(20.0, 0.000001); + CanvasImagePreflight::inspect($frame->images, $this->menuRoot); + if ($previousText !== null) { expect($frame->textLayers)->toEqual($previousText); } + $previousText = $frame->textLayers; + } +}); + +it('does not mask missing wrong-type or corrupt configured assets with an unknown icon', function () { + file_put_contents($this->menuRoot . '/bad.png', 'not a PNG'); + menuPresentationPng($this->menuRoot . '/wrong.jpg', 1, 1); + foreach (['missing.png', 'bad.png', 'wrong.jpg'] as $asset) { + $registry = new MenuIconRegistry($this->menuRoot, ['weapon.staff' => $asset, 'unknown' => 'unknown.png']); + expect(fn() => MenuRowPainter::compose(400, 40, 'list', [new MenuRow('row', 'Staff', icon: WeaponType::STAFF)], + new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)), menuPresentationSkin(), $registry))->toThrow(RuntimeException::class); + } +}); + +it('rejects truncation and partial rows while preserving the complete input for owner relayout', function () { + $label = str_repeat('Long name ', 30); + $row = new MenuRow('long', $label, [new MenuRowValue('123456789')]); + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 79), [new MenuRowColumn(3)]); + expect(fn() => MenuRowPainter::compose(400, 79, 'list', [$row], $layout, menuPresentationSkin())) + ->toThrow(InvalidArgumentException::class, 'must not be silently truncated') + ->and($row->label)->toBe($label)->and($row->values[0]->text)->toBe('123456789'); + expect(fn() => MenuRowPainter::compose(400, 79, 'list', [new MenuRow('a', 'A'), new MenuRow('b', 'B')], + $layout, menuPresentationSkin()))->toThrow(InvalidArgumentException::class, 'whole-row visible subset'); + expect(fn() => MenuRowPainter::compose(400, 79, 'list', [new MenuRow('value', 'HP', [new MenuRowValue('1000')])], + $layout, menuPresentationSkin()))->toThrow(InvalidArgumentException::class, 'wider columns'); +}); + +it('fits flat paint and text at fractional origins and odd-sized canvas edges', function () { + foreach ([513, 701, 1100] as $width) { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0.5, $width, 40.5)); + $frame = MenuRowPainter::compose($width, 41, 'edge', [new MenuRow('row', 'Value', selected: true, focused: true)], + $layout, menuPresentationSkin()); + foreach ($frame->textLayers as $text) { + $text->bounds->assertWithin($width, 41); + expect($text->clipRect->x)->toBeGreaterThanOrEqual(0.0) + ->and($text->clipRect->y)->toBeGreaterThanOrEqual(0.5) + ->and($text->clipRect->y + $text->clipRect->height)->toBeLessThanOrEqual(40.5); + } + } +}); + +it('leaves layer capacity for source-sized Items rows targets commands and summaries', function () { + $skin = menuPresentationSkin(); + $frames = []; + foreach ([['items', 12, 0, 0, 680, true], ['targets', 10, 700, 0, 380, false], + ['summary', 2, 700, 440, 380, false]] as [$id, $count, $x, $y, $width, $selected]) { + $rows = array_map(fn($i) => new MenuRow((string)$i, 'Label', [new MenuRowValue((string)$i)], + selected: $selected && $i === 0, focused: $selected && $i === 0), range(0, $count - 1)); + $frames[] = MenuRowPainter::compose(1100, 700, $id, $rows, + new MenuRowLayout(new CanvasRectangle($x, $y, $width, $count * 40), [new MenuRowColumn(6)]), $skin, $this->menuIcons); + } + $frames[] = MenuRowPainter::compose(1100, 700, 'commands', array_map(fn($i) => new MenuRow((string)$i, + 'Command', kind: MenuRowKind::COMMAND), range(0, 3)), new MenuRowLayout(new CanvasRectangle(0, 520, 300, 160)), $skin); + $frame = new PresentationCanvas(1100, 700, array_merge(...array_column($frames, 'images')), + textLayers: array_merge(...array_column($frames, 'textLayers'))); + expect(count($frame->textLayers))->toBeLessThanOrEqual(60); +}); + +it('validates row identity data columns and finite geometry at the boundary', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 80)); + foreach ([NAN, INF, -1] as $time) { + expect(fn() => MenuRowPainter::compose(400, 80, 'list', [], $layout, menuPresentationSkin(), time: $time)) + ->toThrow(InvalidArgumentException::class); + } + expect(fn() => MenuRowPainter::compose(400, 80, 'list', [new MenuRow('same', 'One'), new MenuRow('same', 'Two')], + $layout, menuPresentationSkin()))->toThrow(InvalidArgumentException::class, 'unique stable IDs'); + expect(fn() => new MenuRow('id', "Line\nBreak"))->toThrow(InvalidArgumentException::class); + expect(fn() => new MenuRow('id', 'Command', [new MenuRowValue('1')], MenuRowKind::COMMAND))->toThrow(InvalidArgumentException::class); + expect(fn() => new MenuRowLayout(new CanvasRectangle(0, 0, 80, 40), [new MenuRowColumn(6)]) + ->assertFits(new MenuRowMetrics()))->toThrow(InvalidArgumentException::class); + expect(fn() => new MenuRowColumn(0))->toThrow(InvalidArgumentException::class); + expect(new MenuIconRegistry($this->menuRoot, [])->asset('unregistered'))->toBeNull(); + expect(fn() => new MenuIconRegistry($this->menuRoot, ['unknown' => '../outside.png']))->toThrow(InvalidArgumentException::class); + expect(fn() => new MenuRowSkin([]))->toThrow(InvalidArgumentException::class); +}); + +it('detaches row columns values and palette from caller-owned reference aliases', function () { + $value = new MenuRowValue('1'); + $column = new MenuRowColumn(3); + $color = PresentationColor::rgb(1, 2, 3); + $row = new MenuRow('id', 'Quantity', [&$value]); + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40), [&$column]); + $colors = menuPresentationSkin()->colors; + $colors['text'] = &$color; + $skin = new MenuRowSkin($colors); + $value = new MenuRowValue('2'); + $column = new MenuRowColumn(9); + $color = PresentationColor::rgb(4, 5, 6); + expect($row->values[0]->text)->toBe('1')->and($layout->columns[0]->cells)->toBe(3) + ->and($skin->colors['text']->toArray())->toBe(PresentationColor::rgb(1, 2, 3)->toArray()); +}); + +it('uses the existing reduced-motion preference by default without mutating it', function () { + $previous = new ReflectionClass(ConfigStore::class)->getStaticProperties(); + try { + $settings = new PlaySettings(['accessibility' => ['reducedMotion' => true]]); + ConfigStore::put(ProjectConfig::class, $settings); + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)); + $rows = [new MenuRow('row', 'Choice', selected: true, focused: true)]; + $frame = fn($time) => MenuRowPainter::compose(400, 40, 'list', $rows, $layout, menuPresentationSkin(), $this->menuIcons, time: $time); + expect($frame(0)->toArray())->toBe($frame(0.6)->toArray()) + ->and($settings->get('accessibility.reducedMotion'))->toBeTrue(); + } finally { + foreach ($previous as $name => $value) { new ReflectionProperty(ConfigStore::class, $name)->setValue(null, $value); } + } +}); + +it('renders the same records and commands with a second art theme without changing owner state', function () { + foreach (['paper', 'mark', 'blocked', 'ring', 'button', 'button-mark', 'button-ring'] as $name) { + menuPresentationPng($this->menuRoot . '/' . $name . '.png', 60, 30); + } + menuPresentationPng($this->menuRoot . '/wide-icon.png', 72, 24); + menuPresentationPng($this->menuRoot . '/tall-cursor.png', 9, 27); + $metrics = new MenuRowMetrics(padding: 38, gapCells: 2, iconWidth: 36, iconHeight: 18, + cursorWidth: 9, cursorHeight: 27, cursorInset: 5, cursorTravel: 12, cursorPeriod: 2, + separatorWidth: 2, recordSeparatorOpacity: 0.6, headingSeparatorOpacity: 0.9, accentWidth: 5, focusWidth: 3); + $colors = array_combine(MenuRowSkin::COLORS, array_map(fn($i) => PresentationColor::rgb(180 - 15 * $i, 50 + 20 * $i, 30), range(0, 5))); + $theme = new MenuRowSkin($colors, $metrics, [ + 'normal' => new MenuRowArtwork('paper.png', 6, 4, 6, 4), + 'selected' => new MenuRowArtwork('mark.png', 6, 4, 6, 4), + 'disabled' => new MenuRowArtwork('blocked.png'), + 'focus' => new MenuRowArtwork('ring.png', 8, 5, 8, 5), + 'command.normal' => new MenuRowArtwork('button.png', 6, 4, 6, 4), + 'command.selected' => new MenuRowArtwork('button-mark.png', 6, 4, 6, 4), + 'command.focus' => new MenuRowArtwork('button-ring.png', 8, 5, 8, 5), + ], $this->menuRoot); + $registry = new MenuIconRegistry($this->menuRoot, ['weapon.staff' => 'wide-icon.png', 'unknown' => 'unknown.png'], 'tall-cursor.png'); + $rows = [ + new MenuRow('current', 'Renamed gear', [new MenuRowValue('0')], icon: WeaponType::STAFF, selected: true), + new MenuRow('candidate', 'Other gear', [new MenuRowValue('12')], icon: WeaponType::STAFF, focused: true), + new MenuRow('blocked', 'Unavailable', [new MenuRowValue('7')], disabled: true), + new MenuRow('equip-action', 'Equip', kind: MenuRowKind::COMMAND, icon: WeaponType::STAFF, selected: true, focused: true), + ]; + $before = serialize($rows); + $layout = new MenuRowLayout(new CanvasRectangle(20, 10, 600, 192), [new MenuRowColumn(5)], rowHeight: 48); + $first = MenuRowPainter::compose(640, 220, 'menu', $rows, $layout, menuPresentationSkin(), $this->menuIcons); + $second = MenuRowPainter::compose(640, 220, 'menu', $rows, $layout, $theme, $registry); + $later = MenuRowPainter::compose(640, 220, 'menu', $rows, $layout, $theme, $registry, time: 1); + $a = array_column($first->textLayers, null, 'id'); + $b = array_column($second->textLayers, null, 'id'); + $pictures = array_column($second->images, null, 'id'); + foreach ($rows as $row) { + expect(array_column($b['menu-' . $row->id . '-text']->runs, 'text')) + ->toBe(array_column($a['menu-' . $row->id . '-text']->runs, 'text')); + } + expect(serialize($rows))->toBe($before) + ->and([$rows[0]->selected, $rows[0]->focused, $rows[0]->disabled, $rows[0]->values[0]->text])->toBe([true, false, false, '0']) + ->and([$rows[1]->selected, $rows[1]->focused])->toBe([false, true]) + ->and($rows[3]->id)->toBe('equip-action') + ->and($b['menu-current-text']->runs[0]->foreground)->toEqual($colors['text']) + ->and($b['menu-blocked-text']->runs[0]->foreground)->toEqual($colors['disabled']) + ->and($b)->not->toHaveKey('menu-current-selected')->not->toHaveKey('menu-current-accent') + ->not->toHaveKey('menu-candidate-focus-0')->not->toHaveKey('menu-equip-action-separator') + ->and($b['menu-current-separator']->opacity)->toBe(0.6) + ->and($b['menu-current-separator']->clipRect->height)->toBe(2.0); + expect($pictures)->toHaveKey('menu-current-selected-1-1')->toHaveKey('menu-candidate-focus-1-1') + ->not->toHaveKey('menu-candidate-selected-1-1')->not->toHaveKey('menu-current-cursor-1-1') + ->toHaveKey('menu-blocked-disabled-1-1') + ->and($pictures['menu-equip-action-normal-1-1']->asset)->toBe('button.png') + ->and($pictures['menu-equip-action-selected-1-1']->asset)->toBe('button-mark.png') + ->and($pictures['menu-equip-action-focus-1-1']->asset)->toBe('button-ring.png') + ->and($pictures['menu-current-icon-1-1']->destination->width)->toBe(36.0) + ->and($pictures['menu-current-icon-1-1']->destination->height)->toBe(12.0) + ->and($pictures['menu-candidate-cursor-1-1']->destination->width)->toBe(9.0) + ->and($pictures['menu-candidate-cursor-1-1']->destination->height)->toBe(27.0); + foreach ([$a, $b] as $text) { + $label = $text['menu-equip-action-text']; + expect($label->x + $label->bounds->width / 2)->toBe(320.0); + } + $moving = array_column($later->images, null, 'id'); + expect($later->textLayers)->toEqual($second->textLayers) + ->and($moving['menu-candidate-cursor-1-1']->destination->x - $pictures['menu-candidate-cursor-1-1']->destination->x)->toBe(12.0); + expect(MenuRowPainter::compose(640, 220, 'menu', $rows, $layout, $theme, $registry, reducedMotion: true)->toArray()) + ->toBe(MenuRowPainter::compose(640, 220, 'menu', $rows, $layout, $theme, $registry, time: 1, reducedMotion: true)->toArray()); + CanvasImagePreflight::inspect($second->images, $this->menuRoot); +}); + +it('uses configurable primitive accents focus and compact spacing when state artwork is absent', function () { + $metrics = new MenuRowMetrics(padding: 4, gapCells: 0, iconWidth: 8, iconHeight: 8, + cursorWidth: 2, cursorHeight: 6, cursorInset: 1, cursorTravel: 0, cursorPeriod: 3, + separatorWidth: 0.5, recordSeparatorOpacity: 0.4, headingSeparatorOpacity: 0.8, accentWidth: 4, focusWidth: 2); + $skin = new MenuRowSkin(menuPresentationSkin()->colors, $metrics); + $frame = MenuRowPainter::compose(120, 40, 'compact', [ + new MenuRow('heading', 'Value', kind: MenuRowKind::HEADING, selected: true, focused: true), + new MenuRow('row', 'HP'), + ], new MenuRowLayout(new CanvasRectangle(0, 0, 120, 40), rowHeight: 20, cellWidth: 6, cellHeight: 12), $skin); + $text = array_column($frame->textLayers, null, 'id'); + expect($text['compact-heading-accent']->clipRect->width)->toBe(4.0) + ->and($text['compact-heading-focus-0']->clipRect->height)->toBe(2.0) + ->and($text['compact-heading-separator']->opacity)->toBe(0.8) + ->and($text['compact-row-separator']->opacity)->toBe(0.4) + ->and($text['compact-row-separator']->clipRect->height)->toBe(0.5); +}); + +it('keeps state artwork replaceable and reconciles cuts without stored source dimensions', function () { + menuPresentationPng($this->menuRoot . '/state.png', 50, 30); + $theme = new MenuRowSkin(menuPresentationSkin()->colors, artwork: [ + 'selected' => new MenuRowArtwork('state.png', 8, 6, 8, 6), + ], assetRoot: $this->menuRoot); + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)); + $rows = [new MenuRow('row', 'Stable', selected: true)]; + $originalText = null; + foreach ([[50, 30], [9, 7], [81, 49]] as [$width, $height]) { + menuPresentationPng($this->menuRoot . '/state.png', $width, $height); + $frame = MenuRowPainter::compose(400, 40, 'menu', $rows, $layout, $theme); + CanvasImagePreflight::inspect($frame->images, $this->menuRoot); + foreach ($frame->images as $image) { + expect($image->sourceRect->x + $image->sourceRect->width)->toBeLessThanOrEqual($width) + ->and($image->sourceRect->y + $image->sourceRect->height)->toBeLessThanOrEqual($height) + ->and($image->clipRect->toArray())->toBe($layout->viewport->toArray()); + } + expect(array_sum(array_map(fn($image) => $image->destination->width * $image->destination->height, $frame->images))) + ->toEqualWithDelta($layout->viewport->width * $layout->viewport->height, 0.000001); + if ($originalText !== null) { expect($frame->textLayers)->toEqual($originalText); } + $originalText = $frame->textLayers; + } + expect($theme->artwork['selected']->left)->toBe(8); +}); + +it('rejects invalid themes or overlapping motion gutters rather than silently dropping artwork', function () { + foreach ([fn() => new MenuRowMetrics(cursorPeriod: 0), fn() => new MenuRowMetrics(iconWidth: INF), + fn() => new MenuRowMetrics(recordSeparatorOpacity: 2), fn() => new MenuRowMetrics(padding: -1), + fn() => new MenuRowArtwork('../outside.png'), fn() => new MenuRowArtwork('art.png', left: -1)] as $invalid) { + expect($invalid)->toThrow(InvalidArgumentException::class); + } + expect(fn() => new MenuRowSkin(menuPresentationSkin()->colors, artwork: ['focus' => new MenuRowArtwork('ring.png')])) + ->toThrow(InvalidArgumentException::class); + $rows = [new MenuRow('row', 'Label', focused: true)]; + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 400, 40)); + $overlap = new MenuRowSkin(menuPresentationSkin()->colors, new MenuRowMetrics(padding: 20)); + expect(fn() => MenuRowPainter::compose(400, 40, 'list', $rows, $layout, $overlap, $this->menuIcons)) + ->toThrow(InvalidArgumentException::class, 'separate leading gutter'); + $missing = new MenuRowSkin(menuPresentationSkin()->colors, artwork: ['normal' => new MenuRowArtwork('missing.png')], assetRoot: $this->menuRoot); + expect(fn() => MenuRowPainter::compose(400, 40, 'list', $rows, $layout, $missing))->toThrow(RuntimeException::class); +}); diff --git a/tests/Unit/MenuTextWrapTest.php b/tests/Unit/MenuTextWrapTest.php new file mode 100644 index 00000000..2ba0bd1f --- /dev/null +++ b/tests/Unit/MenuTextWrapTest.php @@ -0,0 +1,83 @@ +toBe($expected) + ->and(MenuCanvas::wrap($text, $cells))->toBe($expected) + ->and($layout->lines($text, $cells))->toBe($expected) + ->and(implode('', $expected))->toBe($text); +})->with([ + ['Read the manual', 13, ['Read the ', 'manual']], + ['Use S-Potion now', 10, ['Use ', 'S-Potion ', 'now']], + ['One two three', 7, ['One two', ' three']], + ['abc def ghi', 7, ['abc def', ' ghi']], +]); + +it('preserves explicit paragraphs blank lines tabs and trailing whitespace', function () { + $source = "First\r\n\r\nSecond\rThird\titem \n"; + $expected = ['First', '', 'Second', 'Third item ', '']; + expect(MenuCanvas::wrap($source, 30))->toBe($expected) + ->and((new MenuRowLayout(new CanvasRectangle(0, 0, 300, 200), wrapText: true))->lines($source, 30))->toBe($expected) + ->and(MenuTextWrap::lines('', 8))->toBe(['']); +}); + +it('splits only overlong unbroken tokens using the Canvas codepoint budget', function () { + expect(MenuTextWrap::lines('Before abcdefghijk after', 6))->toBe(['Before', ' abcde', 'fghijk', ' after']); + $text = "\u{65E5}\u{672C}\u{8A9E}\u{6587}\u{7AE0} cafe\u{0301} \u{1F680}"; + $lines = MenuTextWrap::lines($text, 4); + expect(implode('', $lines))->toBe($text); + foreach ($lines as $line) { expect(mb_check_encoding($line, 'UTF-8'))->toBeTrue()->and(mb_strlen($line, 'UTF-8'))->toBeLessThanOrEqual(4); } + expect(MenuTextWrap::lines("a\u{00A0}b c", 4))->toBe(["a\u{00A0}b ", 'c']); +}); + +it('retains codepoints without lost whitespace or over-budget rows across narrow widths', function () { + $source = "manual S-Potion \u{65E5}\u{672C}\u{8A9E} cafe\u{0301} abcdefghijklmnop "; + foreach (range(1, 40) as $width) { + $lines = MenuTextWrap::lines($source, $width); + expect(implode('', $lines))->toBe($source); + foreach ($lines as $line) { expect(mb_strlen($line, 'UTF-8'))->toBeLessThanOrEqual($width); } + } +}); + +it('measures and paints wrapped labels values and centered commands through the same lines', function () { + $theme = new MenuPresentationCatalog(sys_get_temp_dir(), ['schema' => 'ichiloto.menu/1']); + $view = new MenuCanvas($theme, 500, 500); + $layout = new MenuRowLayout(new CanvasRectangle(20, 20, 240, 200), [new MenuRowColumn(5)], 40, 10, 24, true); + $row = new MenuRow('record', 'Use S-Potion', [new MenuRowValue('Cost now')]); + $height = $layout->heightFor($row, $theme->rows->metrics, false); + $view->rows('wrap', [$row], $layout); + $commandLayout = new MenuRowLayout(new CanvasRectangle(20, 250, 170, 160), rowHeight: 40, wrapText: true); + $view->rows('wrap', [new MenuRow('command', 'Read the manual', kind: MenuRowKind::BUTTON)], $commandLayout); + $view->prose('paragraph', 'Read the manual', new CanvasRectangle(280, 20, 130, 100)); + $canvas = $view->finish(); + $layers = array_column($canvas->textLayers, null, 'id'); + $record = $layers['wrap-record-text']; + expect($record->clipRect->height)->toBe((float)$height) + ->and(implode('', array_column($layers['paragraph']->runs, 'text')))->toBe('Read the manual'); + expect(array_column($layers['paragraph']->runs, 'text'))->toBe(['Read the ', 'manual']); + $command = $layers['wrap-command-text']; + foreach ($command->runs as $run) { + $center = $command->x + ($run->column + mb_strlen($run->text) / 2) * $command->grid->cellWidth; + expect(abs($center - ($command->clipRect->x + $command->clipRect->width / 2)))->toBeLessThanOrEqual($command->grid->cellWidth / 2); + } + expect(count($canvas->textLayers))->toBeLessThanOrEqual(64)->and($canvas->toArray()['width'])->toBe(500); +}); + +it('preserves the opt-in wrapping guard and invalid-width diagnostics', function () { + $layout = new MenuRowLayout(new CanvasRectangle(0, 0, 200, 100)); + expect(fn() => $layout->lines('Too long', 3))->toThrow(InvalidArgumentException::class) + ->and(fn() => MenuCanvas::wrap('Text', 0))->toThrow(RuntimeException::class) + ->and(fn() => MenuTextWrap::lines("\xFF", 5))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/ModalFieldRestorationTest.php b/tests/Unit/ModalFieldRestorationTest.php index cdeb54b4..1f2ac935 100644 --- a/tests/Unit/ModalFieldRestorationTest.php +++ b/tests/Unit/ModalFieldRestorationTest.php @@ -18,13 +18,20 @@ use Assegai\Collections\ItemList; use Ichiloto\Engine\UI\Interfaces\UIElementInterface; +final class FieldRestorationGameProbe extends Game +{ + public function __construct() {} + public function __destruct() {} +} + final class FieldRestorationModalProbe extends Modal { /** @var string[] */ public array $timeline = []; - public function __construct(EventManager $eventManager) + public function __construct(Game $game, EventManager $eventManager) { + $this->game = $game; $this->eventManager = $eventManager; } @@ -153,9 +160,9 @@ public function __construct( } it('erases a blocking modal before resuming and redrawing the scene', function () { - $game = (new ReflectionClass(Game::class))->newInstanceWithoutConstructor(); + $game = new FieldRestorationGameProbe(); $eventManager = EventManager::getInstance($game); - $modal = new FieldRestorationModalProbe($eventManager); + $modal = new FieldRestorationModalProbe($game, $eventManager); $listener = static function (ModalEvent $event) use ($modal): void { if ($event->modalEventType === ModalEventType::CLOSE) { $modal->recordSceneResume(); @@ -173,8 +180,8 @@ public function __construct( }); it('restores the field after non-blocking story dialogue is dismissed', function () { - $game = (new ReflectionClass(Game::class))->newInstanceWithoutConstructor(); - $modal = new FieldRestorationModalProbe(EventManager::getInstance($game)); + $game = new FieldRestorationGameProbe(); + $modal = new FieldRestorationModalProbe($game, EventManager::getInstance($game)); $scene = new FieldRestorationGameSceneProbe(); $presentation = new ModalEventPresentation($scene); $modalProperty = (new ReflectionClass(ModalEventPresentation::class))->getProperty('modal'); diff --git a/tests/Unit/ModalHighlightingTest.php b/tests/Unit/ModalHighlightingTest.php index f49f477e..51238750 100644 --- a/tests/Unit/ModalHighlightingTest.php +++ b/tests/Unit/ModalHighlightingTest.php @@ -9,6 +9,20 @@ use Ichiloto\Engine\Util\Config\ProjectConfig; use Ichiloto\Engine\Util\Interfaces\ConfigInterface; +beforeEach(function (): void { + $this->savedProjectConfig = ConfigStore::has(ProjectConfig::class) + ? ConfigStore::get(ProjectConfig::class) + : null; +}); + +afterEach(function (): void { + if ($this->savedProjectConfig !== null) { + ConfigStore::put(ProjectConfig::class, $this->savedProjectConfig); + } else { + ConfigStore::remove(ProjectConfig::class); + } +}); + class ModalArrayConfigStub implements ConfigInterface { public function __construct(private array $values = []) diff --git a/tests/Unit/ModalLifecycleTest.php b/tests/Unit/ModalLifecycleTest.php index c5ff9abb..93a26c31 100644 --- a/tests/Unit/ModalLifecycleTest.php +++ b/tests/Unit/ModalLifecycleTest.php @@ -346,6 +346,37 @@ public function pageLineCounts(): array } }); +it('left aligns prose and speech inside their independently positioned boxes', function (string $speaker, ?WindowPosition $position, WindowPosition $expected) { + $game = new ReflectionClass(Game::class)->newInstanceWithoutConstructor(); + $message = 'A practice blade and worn guard rest against the wall.'; + $modal = new PositionedTextBoxModalProbe($game, $message, $speaker, position: $position); + ob_start(); + $modal->prepareAndRender(); + ob_end_clean(); + [$rect, $window] = $modal->positions(); + [$width, $height] = $modal->size(); + expect($rect)->toEqual($expected->getCoordinates($width, $height))->and($window)->toEqual($rect); + $row = TerminalText::stripAnsi(Console::getBuffer()[(int)$rect->y + 1]); + expect(mb_strpos($row, 'A'))->toBe((int)$rect->x + 2); + + ob_start(); + $modal->renderCompletePage(); + ob_end_clean(); + $rows = array_map(TerminalText::stripAnsi(...), Console::getBuffer()); + $lines = explode("\n", wordwrap($message, $width - 4, "\n", true)); + expect(count($lines))->toBeGreaterThan(1); + + foreach ($lines as $index => $line) { + expect(mb_strpos($rows[(int)$rect->y + 1 + $index], $line))->toBe((int)$rect->x + 2); + } +})->with([ + ['', null, WindowPosition::TOP], + [' ', null, WindowPosition::TOP], + ['Kaelion', null, WindowPosition::BOTTOM], + ['', WindowPosition::MIDDLE, WindowPosition::MIDDLE], + ['Kaelion', WindowPosition::TOP, WindowPosition::TOP], +]); + it('measures wrapped dialogue before bottom anchoring it inside the screen', function () { $game = (new ReflectionClass(Game::class))->newInstanceWithoutConstructor(); $modal = new PositionedTextBoxModalProbe( diff --git a/tests/Unit/PlayerActionStateTest.php b/tests/Unit/PlayerActionStateTest.php index 9be3c3dc..dd396bc1 100644 --- a/tests/Unit/PlayerActionStateTest.php +++ b/tests/Unit/PlayerActionStateTest.php @@ -34,6 +34,8 @@ use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\Skills\BasicSkill; use Ichiloto\Engine\Entities\Skills\MagicSkill; +use Ichiloto\Engine\Entities\Skills\SpecialSkill; +use Ichiloto\Engine\Entities\Abilities\AbilityBook; use Ichiloto\Engine\Entities\Magic\Spellbook; use Ichiloto\Engine\Entities\Inventory\Items\Item; use Ichiloto\Engine\Entities\Inventory\Items\ItemScope as InventoryItemScope; @@ -65,6 +67,11 @@ public function updateContent(): void class BattleCharacterStatusWindowTargetingTestProxy extends BattleCharacterStatusWindow { + public function updateContent(): void + { + // These tests exercise state transitions, not terminal painting. + } + public function setAtbPercentages(array $atbPercentages): void { // Skip terminal rendering for active-time flow tests. @@ -274,6 +281,33 @@ function createTargetingTestScreen(): BattleScreenTargetingTestProxy return $screen; } +it('applies shared opening advantages only to the traditional first round', function (string $opening) { + $game = (new ReflectionClass(GameTargetingTestProxy::class))->newInstanceWithoutConstructor(); + $screen = createTargetingTestScreen(); + $party = new Party(); + $hero = new Character('Hero', 1, new Stats(currentHp: 100, speed: 7)); + $party->addMember($hero); + $enemy = createTargetingTestEnemy('Enemy'); + $troop = new Troop('Test', [$enemy]); + $engine = new class($game) extends TraditionalTurnBasedBattleEngine { + public function setState(\Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnState $state): void + { + $this->state = $state; + } + }; + $engine->configure(new TurnBasedBattleConfig($party, $troop, $screen, settings: ['opening' => [ + 'preemptiveChancePercent' => $opening === 'party' ? 100 : 0, + 'ambushChancePercent' => $opening === 'troop' ? 100 : 0, + ]])); + $context = new TurnStateExecutionContext($game, $party, $troop, $screen, []); + $state = $engine->turnInitState; + $state->update($context); + $turnActors = fn() => array_map(fn($turn) => $turn->battler, $context->getTurns()); + expect($turnActors())->toBe($opening === 'party' ? [$hero] : [$enemy]); + $state->update($context); + expect($turnActors())->toHaveCount(2)->toContain($hero, $enemy); +})->with(['party', 'troop']); + it('keeps authored enemy formations out of the player-party render zone', function () { $fieldWindow = (new ReflectionClass(BattleFieldWindowTargetingTestProxy::class))->newInstanceWithoutConstructor(); setTestProperty($fieldWindow, 'partyBattlerPositions', new PartyBattlerPositions()); @@ -512,15 +546,17 @@ public function executeEnemyTurnForTest(TurnStateExecutionContext $context, Enem ->and($engine->capturedTargets)->toBe([$enemy]); }); -it('requires separate confirmation for all-target commands and allows cancellation', function (bool $activeTime, ItemScopeSide $side, ItemScopeStatus $status, array $indexes, string $source) { +it('requires separate target confirmation for submenu commands and allows cancellation', function (bool $activeTime, ItemScopeSide $side, ItemScopeNumber $number, ItemScopeStatus $status, array $indexes, string $source) { $game = (new ReflectionClass(GameTargetingTestProxy::class))->newInstanceWithoutConstructor(); $audio = $this->createMock(AudioManager::class); setTestProperty($game, 'audioManager', $audio); - $skill = new MagicSkill('Group Command', 'Affects the selected group.', '', 5, 0, new ItemScope($side, ItemScopeNumber::ALL, $status)); - $item = new Item('Group Item', 'Affects the selected group.', '!', 10, 2, scope: new InventoryItemScope($side, ItemScopeNumber::ALL, $status)); + $skill = new MagicSkill('Targeted Magic', 'Affects the confirmed targets.', '', 5, 0, new ItemScope($side, $number, $status)); + $ability = new SpecialSkill('Targeted Skill', 'Affects the confirmed targets.', '', 5, 0, new ItemScope($side, $number, $status)); + $item = new Item('Targeted Item', 'Affects the confirmed targets.', '!', 10, 2, scope: new InventoryItemScope($side, $number, $status)); $party = new Party(); foreach (['Caster', 'Ally', 'Fallen Ally'] as $name) { - $party->addMember(new Character($name, 0, new Stats(currentHp: 120, currentMp: 30), spellbook: new Spellbook([$skill]))); + $party->addMember(new Character($name, 0, new Stats(currentHp: 120, currentMp: 30), + spellbook: new Spellbook([$skill]), abilityBook: new AbilityBook([$ability]))); } $party->inventory->addItems($item); $members = $party->battlers->toArray(); @@ -536,14 +572,16 @@ public function executeEnemyTurnForTest(TurnStateExecutionContext $context, Enem setTestProperty($screen, 'battleScene', $scene); setTestProperty($screen->fieldWindow, 'battleScreen', $screen); $context = new TurnStateExecutionContext($game, $party, $troop, $screen, []); - $turn = new Turn($members[0]); - $context->setTurns([$turn, new Turn($members[1])]); + setTestProperty($engine, 'turnStateExecutionContext', $context); + $actorIndex = $side === ItemScopeSide::USER ? 1 : 0; + $turn = new Turn($members[$actorIndex]); + $context->setTurns([$turn, new Turn($members[1 - $actorIndex])]); $state = $activeTime ? new ActiveTimeFlowState($engine) : new PlayerActionState($engine); - setTestProperty($state, 'activeCharacterIndex', 0); + setTestProperty($state, 'activeCharacterIndex', $actorIndex); setTestProperty($state, 'selectionMode', 'submenu'); - $command = $source === 'item' ? 'Item' : 'Magic'; - $options = BattleCommandCatalog::buildOptions($members[0], $party, $command); - expect($options)->toHaveCount(1)->and($options[0]->targetNumber)->toBe(ItemScopeNumber::ALL); + $command = match ($source) { 'item' => 'Item', 'skill' => 'Skill', default => 'Magic' }; + $options = BattleCommandCatalog::buildOptions($members[$actorIndex], $party, $command); + expect($options)->toHaveCount(1)->and($options[0]->targetNumber)->toBe($number); $option = $options[0]; $action = $option->action; $screen->commandContextWindow->setItems($options, $command); @@ -559,9 +597,9 @@ public function executeEnemyTurnForTest(TurnStateExecutionContext $context, Enem InputManager::handleInput(); new ReflectionMethod($state, 'handleActions')->invoke($state, $context); }; - $assertUncommitted = function () use ($turn, $engine, $members, $enemies, $action, $item): void { + $assertUncommitted = function () use ($turn, $engine, $members, $enemies, $action, $item, $actorIndex): void { expect($turn->action)->toBeNull()->and($turn->targets)->toBe([]) - ->and($members[0]->stats->currentMp)->toBe(30) + ->and($members[$actorIndex]->stats->currentMp)->toBe(30) ->and($members[1]->stats->currentHp)->toBe(120) ->and($enemies[0]->stats->currentHp)->toBe(30) ->and($item->quantity)->toBe(2) @@ -573,17 +611,26 @@ public function executeEnemyTurnForTest(TurnStateExecutionContext $context, Enem $press(KeyCode::ENTER); $assertUncommitted(); - $pool = $side === ItemScopeSide::ALLY ? $members : $enemies; + $focusSide = $side === ItemScopeSide::USER ? ItemScopeSide::ALLY : $side; + $pool = $focusSide === ItemScopeSide::ALLY ? $members : $enemies; $expected = array_map(fn(int $index) => $pool[$index], $indexes); $screen->fieldWindow->renderTargetIndicators(); expect(new ReflectionProperty($state, 'selectionMode')->getValue($state))->toBe('target') - ->and($screen->fieldWindow->getFocusedIndexes($side))->toBe($indexes) + ->and($screen->fieldWindow->getFocusedIndexes($focusSide))->toBe($indexes) ->and($screen->fieldWindow->renderedFocus)->toBe($expected); + if ($side === ItemScopeSide::USER) { + foreach ([-1, 1] as $step) { + new ReflectionMethod($state, 'cycleTarget')->invoke($state, $context, $step); + expect($screen->fieldWindow->getFocusedIndexes(ItemScopeSide::ALLY))->toBe([$actorIndex]) + ->and($screen->fieldWindow->getFocusedIndexes(ItemScopeSide::ENEMY))->toBe([]); + $assertUncommitted(); + } + } $press(KeyCode::c); $assertUncommitted(); expect(new ReflectionProperty($state, 'selectionMode')->getValue($state))->toBe('submenu') ->and($screen->commandContextWindow->getActiveItem())->toBe($option) - ->and($screen->fieldWindow->getFocusedIndexes($side))->toBe([]); + ->and($screen->fieldWindow->getFocusedIndexes($focusSide))->toBe([]); $press(KeyCode::ENTER); $assertUncommitted(); @@ -596,10 +643,13 @@ public function executeEnemyTurnForTest(TurnStateExecutionContext $context, Enem new ReflectionProperty(InputManager::class, 'eventManager')->setValue(null, $oldEvents); } })->with([false, true])->with([ - 'living allies' => [ItemScopeSide::ALLY, ItemScopeStatus::ALIVE, [0, 1]], - 'fallen allies' => [ItemScopeSide::ALLY, ItemScopeStatus::DEAD, [2]], - 'any allies' => [ItemScopeSide::ALLY, ItemScopeStatus::ANY, [0, 1, 2]], - 'living enemies' => [ItemScopeSide::ENEMY, ItemScopeStatus::ALIVE, [0, 1]], - 'fallen enemies' => [ItemScopeSide::ENEMY, ItemScopeStatus::DEAD, [2]], - 'any enemies' => [ItemScopeSide::ENEMY, ItemScopeStatus::ANY, [0, 1, 2]], -])->with(['skill', 'item']); + 'living allies' => [ItemScopeSide::ALLY, ItemScopeNumber::ALL, ItemScopeStatus::ALIVE, [0, 1]], + 'fallen allies' => [ItemScopeSide::ALLY, ItemScopeNumber::ALL, ItemScopeStatus::DEAD, [2]], + 'any allies' => [ItemScopeSide::ALLY, ItemScopeNumber::ALL, ItemScopeStatus::ANY, [0, 1, 2]], + 'living enemies' => [ItemScopeSide::ENEMY, ItemScopeNumber::ALL, ItemScopeStatus::ALIVE, [0, 1]], + 'fallen enemies' => [ItemScopeSide::ENEMY, ItemScopeNumber::ALL, ItemScopeStatus::DEAD, [2]], + 'any enemies' => [ItemScopeSide::ENEMY, ItemScopeNumber::ALL, ItemScopeStatus::ANY, [0, 1, 2]], + 'one ally' => [ItemScopeSide::ALLY, ItemScopeNumber::ONE, ItemScopeStatus::ALIVE, [0]], + 'one enemy' => [ItemScopeSide::ENEMY, ItemScopeNumber::ONE, ItemScopeStatus::ALIVE, [0]], + 'caster only' => [ItemScopeSide::USER, ItemScopeNumber::ONE, ItemScopeStatus::ALIVE, [1]], +])->with(['magic', 'skill', 'item']); diff --git a/tests/Unit/PlayerGraphicalSpriteTest.php b/tests/Unit/PlayerGraphicalSpriteTest.php index 7a021ed8..a4a6309c 100644 --- a/tests/Unit/PlayerGraphicalSpriteTest.php +++ b/tests/Unit/PlayerGraphicalSpriteTest.php @@ -19,9 +19,9 @@ use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; use Ichiloto\Engine\Rendering\Sprites\GraphicalSpriteProjector; use Ichiloto\Engine\Rendering\Sprites\GraphicalSpriteProviderInterface; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; use Ichiloto\Engine\Rendering\Transport\RendererEvent; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; -use Ichiloto\Engine\Rendering\Transport\RendererMessageType; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\ProjectConfig; diff --git a/tests/Unit/PlayerPresentationConfigTest.php b/tests/Unit/PlayerPresentationConfigTest.php index ade37911..0b03c4ed 100644 --- a/tests/Unit/PlayerPresentationConfigTest.php +++ b/tests/Unit/PlayerPresentationConfigTest.php @@ -1,28 +1,28 @@ beginQuantitySelection($item, $target); - } - - public function selector(): ?QuantitySelector - { - return $this->quantitySelector; - } - - protected function updateQuantitySelectionContent(): void - { - // Rendering is deliberately excluded: this proxy verifies mode adoption - // of the shared interaction contract without constructing the full menu. - } -} +use Ichiloto\Engine\UI\Modal\QuantityModal; it('clamps quantities to the workflow bounds', function () { $selector = new QuantitySelector(maximum: 12, initial: 4); @@ -64,6 +37,18 @@ protected function updateQuantitySelectionContent(): void ->and($selector->quantity)->toBe(10); }); +it('saturates integer-extreme adjustments before arithmetic can overflow', function () { + $selector = new QuantitySelector(maximum: PHP_INT_MAX, initial: PHP_INT_MAX - 2); + expect($selector->adjustForAxes(0, 1))->toBeTrue() + ->and($selector->quantity)->toBe(PHP_INT_MAX) + ->and($selector->adjust(PHP_INT_MAX))->toBeFalse() + ->and($selector->adjust(PHP_INT_MIN))->toBeTrue() + ->and($selector->quantity)->toBe(1) + ->and($selector->adjust(PHP_INT_MIN))->toBeFalse() + ->and($selector->adjust(PHP_INT_MAX))->toBeTrue() + ->and($selector->quantity)->toBe(PHP_INT_MAX); +}); + it('rejects invalid quantity-selection contracts', function (int $minimum, int $maximum, int $step) { new QuantitySelector(maximum: $maximum, minimum: $minimum, coarseStep: $step); })->with([ @@ -72,18 +57,26 @@ protected function updateQuantitySelectionContent(): void 'non-positive coarse step' => [1, 10, 0], ])->throws(InvalidArgumentException::class); -it('uses the shared selector in both shops and field-item consumption', function () { +it('uses the shared selector in both shops and modal quantity selection', function () { $shopState = (new ReflectionClass(ShopState::class))->newInstanceWithoutConstructor(); $shopMode = new PurchaseConfirmationMode($shopState); $shopSelector = (new ReflectionProperty($shopMode, 'quantitySelector'))->getValue($shopMode); - $itemState = (new ReflectionClass(ItemMenuState::class))->newInstanceWithoutConstructor(); - $itemMode = new QuantitySelectingItemMode($itemState); - $item = new Item('S-Potion', 'Restores HP.', '', 10, quantity: 14); - $target = new Character('Kaelion', 0, new Stats()); - $itemMode->start($item, $target); + $eventManager = new ReflectionProperty(\Ichiloto\Engine\Events\EventManager::class, 'instance'); + $previous = $eventManager->getValue(); + try { + $game = new class extends Game { + public function __construct() {} + public function __destruct() {} + }; + $modal = new QuantityModal($game, 'Choose amount', 14); + $modalSelector = new ReflectionProperty($modal, 'selector')->getValue($modal); - expect($shopSelector)->toBeInstanceOf(QuantitySelector::class) - ->and($itemMode->selector())->toBeInstanceOf(QuantitySelector::class) - ->and($itemMode->selector()?->maximum)->toBe(14); + expect($shopSelector)->toBeInstanceOf(QuantitySelector::class) + ->and($modalSelector)->toBeInstanceOf(QuantitySelector::class) + ->and($modal->maximum)->toBe(14) + ->and($modal->quantity)->toBe(1); + } finally { + $eventManager->setValue(null, $previous); + } }); diff --git a/tests/Unit/QuestRewardPresentationTest.php b/tests/Unit/QuestRewardPresentationTest.php new file mode 100644 index 00000000..52757488 --- /dev/null +++ b/tests/Unit/QuestRewardPresentationTest.php @@ -0,0 +1,82 @@ +savedConfig = new ReflectionClass(ConfigStore::class)->getStaticProperties(); + $this->store = makeBareScene(ItemStore::class); + $this->item = new Item('Current Tonic', 'Reward definition.', '!', 10, id: 'item.tonic', aliases: ['Old Tonic']); + $this->store->set($this->item->id, $this->item); + ConfigStore::put(ItemStore::class, $this->store); +}); + +afterEach(function () { + foreach ($this->savedConfig as $key => $value) { new ReflectionProperty(ConfigStore::class, $key)->setValue(null, $value); } +}); + +it('describes stable and alias references with the same quantities as granting without changing definitions', function () { + $references = ['item.tonic', ['item' => 'Old Tonic', 'quantity' => '3.9', 'price' => 999], + ['item' => 'item.tonic', 'quantity' => null], ['item' => 'item.tonic', 'quantity' => 0], ['item' => 'item.tonic', 'quantity' => -2]]; + $quest = new Quest('rewards', 'Rewards', rewards: ['gold' => 20, 'experience' => 30, 'items' => $references]); + $before = $this->store->get('item.tonic'); + expect($quest->describeRewards())->toBe('20 G, 30 EXP, Current Tonic, Current Tonic x3, Current Tonic') + ->and(count($this->store->load($references)))->toBe(5) + ->and($this->store->get('item.tonic'))->toEqual($before) + ->and($quest->rewards['items'])->toBe($references); +}); + +it('bounds large-quantity snapshots by reward entry count without loading or instantiating items', function () { + $store = new class extends ItemStore { + public array $resolved = []; + public function __construct() {} + public function load(array $data): array { throw new LogicException('Display must not load rewards.'); } + public function instantiate(string $itemName, int $quantity = 1, string $context = 'instantiating inventory content'): array + { + throw new LogicException('Display must not instantiate rewards.'); + } + public function displayNameFor(string $reference, string $context = 'displaying inventory content'): string + { + $this->resolved[] = [$reference, $context]; + return parent::displayNameFor($reference, $context); + } + }; + $store->set($this->item->id, $this->item); + ConfigStore::put(ItemStore::class, $store); + $definitions = serialize(new ReflectionProperty($store, 'items')->getValue($store)); + $aliases = new ReflectionProperty($store, 'aliases')->getValue($store); + $rewards = ['items' => [['item' => 'item.tonic', 'quantity' => PHP_INT_MAX, 'price' => 999], + ['item' => 'Old Tonic', 'quantity' => 1000000000]]]; + $quest = new Quest('large', 'Large', rewards: $rewards); + foreach (range(1, 3) as $snapshot) { + expect($quest->describeRewards())->toBe('Current Tonic x' . PHP_INT_MAX . ', Current Tonic x1000000000') + ->and(count($store->resolved))->toBe($snapshot * count($rewards['items'])); + } + expect($store->resolved[0])->toBe(['item.tonic', 'describing rewards for quest "large"']) + ->and($store->resolved[1][0])->toBe('Old Tonic') + ->and(serialize(new ReflectionProperty($store, 'items')->getValue($store)))->toBe($definitions) + ->and(new ReflectionProperty($store, 'aliases')->getValue($store))->toBe($aliases) + ->and($quest->rewards)->toBe($rewards); +}); + +it('validates references before omitting zero or negative quantity rewards', function (int $quantity) { + $valid = new Quest('zero', 'Zero', rewards: ['items' => [['item' => 'Old Tonic', 'quantity' => $quantity]]]); + expect($valid->describeRewards())->toBe('') + ->and($this->store->load($valid->rewards['items']))->toBe([]) + ->and(fn() => new Quest('missing', 'Missing', rewards: ['items' => [['item' => 'missing', 'quantity' => $quantity]]])->describeRewards())->toThrow(NotFoundException::class) + ->and(fn() => new Quest('bad', 'Bad', rewards: ['items' => [['quantity' => $quantity]]])->describeRewards())->toThrow(RequiredFieldException::class); +})->with([0, -5]); + +it('retains loader diagnostics rather than hiding invalid structured rewards', function () { + expect(fn() => new Quest('bad', 'Bad', rewards: ['items' => [['quantity' => 2]]])->describeRewards())->toThrow(RequiredFieldException::class) + ->and(fn() => new Quest('missing', 'Missing', rewards: ['items' => [['item' => 'missing']]])->describeRewards())->toThrow(NotFoundException::class); + ConfigStore::remove(ItemStore::class); + expect(new Quest('legacy', 'Legacy', rewards: ['items' => ['Legacy name']])->describeRewards())->toBe('Legacy name') + ->and(fn() => new Quest('unloaded', 'Unloaded', rewards: ['items' => [['item' => 'item.tonic']]])->describeRewards())->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/RendererClientTest.php b/tests/Unit/RendererClientTest.php index e5294b1d..3a97509d 100644 --- a/tests/Unit/RendererClientTest.php +++ b/tests/Unit/RendererClientTest.php @@ -3,12 +3,12 @@ use Ichiloto\Engine\IO\Enumerations\KeyCode; use Ichiloto\Engine\IO\InputSources\RendererInputSource; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; use Ichiloto\Engine\Rendering\Transport\RendererEvent; -use Ichiloto\Engine\Rendering\Transport\RendererEventType; use Ichiloto\Engine\Rendering\Transport\RendererMessage; -use Ichiloto\Engine\Rendering\Transport\RendererMessageType; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Tests\Support\Input\FakeRendererTransport; diff --git a/tests/Unit/RendererLaunchTest.php b/tests/Unit/RendererLaunchTest.php index 28e82a2d..e321f1aa 100644 --- a/tests/Unit/RendererLaunchTest.php +++ b/tests/Unit/RendererLaunchTest.php @@ -16,9 +16,9 @@ use Ichiloto\Engine\Rendering\Launch\RendererUnavailableException; use Ichiloto\Engine\Rendering\Runtime\RendererRuntime; use Ichiloto\Engine\Rendering\Runtime\RendererRuntimeConfig; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; use Ichiloto\Engine\Scenes\SceneManager; use Ichiloto\Engine\Util\Config\ConfigStore; diff --git a/tests/Unit/RendererRuntimeTest.php b/tests/Unit/RendererRuntimeTest.php index 4127618e..7206a405 100644 --- a/tests/Unit/RendererRuntimeTest.php +++ b/tests/Unit/RendererRuntimeTest.php @@ -2,40 +2,40 @@ use Assegai\Collections\ItemList; use Ichiloto\Engine\Audio\AudioManager; -use Ichiloto\Engine\Audio\FieldMusicCatalog; use Ichiloto\Engine\Audio\CinematicMusicRequest; +use Ichiloto\Engine\Audio\FieldMusicCatalog; use Ichiloto\Engine\Core\Game; use Ichiloto\Engine\Core\Timers; +use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Events\Interfaces\ObserverInterface; use Ichiloto\Engine\Events\Interfaces\StaticObserverInterface; +use Ichiloto\Engine\Field\MapManager; +use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Enumerations\KeyCode; use Ichiloto\Engine\IO\InputManager; use Ichiloto\Engine\IO\InputSources\RendererInputSource; use Ichiloto\Engine\IO\InputSources\TerminalInputSource; -use Ichiloto\Engine\IO\Enumerations\KeyCode; +use Ichiloto\Engine\Messaging\Notifications\NotificationManager; +use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Rendering\Runtime\RendererRuntime; use Ichiloto\Engine\Rendering\Runtime\RendererRuntimeConfig; use Ichiloto\Engine\Rendering\Runtime\RendererWindowClosed; +use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; +use Ichiloto\Engine\Rendering\Tiles\GraphicalTileDefinition; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; +use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; use Ichiloto\Engine\Rendering\Transport\RendererEvent; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; -use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; -use Ichiloto\Engine\Messaging\Notifications\NotificationManager; -use Ichiloto\Engine\Scenes\SceneManager; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Scenes\Game\States\FieldState; -use Ichiloto\Engine\Field\Player; -use Ichiloto\Engine\Field\MapManager; -use Ichiloto\Engine\Rendering\Tiles\GraphicalTileDefinition; -use Ichiloto\Engine\Rendering\Sprites\DirectionalGraphicalSpriteSet; -use Ichiloto\Engine\Rendering\Camera; -use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\Scenes\SceneManager; use Ichiloto\Engine\UI\UIManager; -use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Config\AppConfig; -use function Tests\Support\Rendering\graphicalSpriteData; +use Ichiloto\Engine\Util\Config\ConfigStore; use Tests\Support\Input\FakeRendererTransport; +use function Tests\Support\Rendering\graphicalSpriteData; require_once __DIR__ . '/../Support/Input/FakeRendererTransport.php'; require_once __DIR__ . '/../Support/Rendering/GraphicalSpriteFixtures.php'; @@ -131,6 +131,19 @@ public function render(?int $x = null, ?int $y = null): void {} ->and(ob_get_contents())->toBe(''); }); +it('updates native activation state without consuming semantic input', function () { + $this->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), __DIR__, + protocol: RendererProtocolVersion::V2, requiredCapabilities: ['window_activation']), $this->transport); + $this->transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"ready","capabilities":["window_activation"]}'), + RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":false}')]; + $this->runtime->start('Focus', 12, 4); + expect($this->runtime->windowActive)->toBeFalse(); + $this->transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":true}'), + RendererEvent::fromJson('{"protocol":2,"type":"key","key":"up"}')]; + $this->runtime->pump(); + expect($this->runtime->windowActive)->toBeTrue()->and(InputManager::getInputSource()->poll())->toBe(KeyCode::UP); +}); + it('runs a real PHP-only peer through explicit v1 and v2 runtime lifecycles', function ($protocol) { $capture = tempnam(sys_get_temp_dir(), 'runtime-version-'); $process = new RendererProcessConfig([PHP_BINARY, __DIR__ . '/../Fixtures/Renderer/renderer-stub.php', 'ready_key', $capture]); @@ -335,8 +348,8 @@ public function render(?int $x = null, ?int $y = null): void {} it('uses the same field eligibility for terrain and Player and clears tiles on scene replacement', function () { $this->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['fixture']), __DIR__, - requiredCapabilities:['tile_batches']), $this->transport); - $this->transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"ready","capabilities":["tile_batches"]}')]; + requiredCapabilities:['tile_batches', 'sprite_source_rect']), $this->transport); + $this->transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"ready","capabilities":["tile_batches","sprite_source_rect"]}')]; $this->runtime->start('Tiles',12,4); $scene = makeBareScene(GameScene::class); $camera = new Camera($scene,12,4,worldSpace:array_fill(0,4,array_fill(0,12,';'))); @@ -355,11 +368,18 @@ public function render(?int $x = null, ?int $y = null): void {} $field = makeBareScene(FieldState::class); new ReflectionProperty(GameScene::class,'fieldState')->setValue($scene,$field); new ReflectionProperty(GameScene::class,'state')->setValue($scene,$field); + $stage = new \Ichiloto\Engine\Cutscenes\Cinematics\CinematicStageManager($scene); + new ReflectionProperty(GameScene::class,'cinematicStage')->setValue($scene,$stage); + $cast = $stage->add(['id' => 'runner', 'sprite' => '@', 'x' => 3, 'y' => 2, + 'sprites2d' => ['asset' => 'runner.png', 'width' => 56, 'height' => 56, 'layer' => 100, + 'sourceRect' => ['x' => 256, 'y' => 0, 'width' => 256, 'height' => 256]]]); Console::recomposeFrame(fn()=>$map->render()); $terminal = Console::snapshot(); expect($this->runtime->present($scene))->toBeTrue() ->and($this->transport->sent[0]->payload['tileBatches'][0]['cells'])->toHaveCount(48) ->and($this->transport->sent[0]->payload['textLayers'][0]['runs'])->toBe([]) + ->and($this->transport->sent[0]->payload['sprites'][0]['id'])->toBe('staged:runner') + ->and($this->transport->sent[0]->payload['sprites'][0]['sourceRect']['x'])->toBe(256) ->and(Console::snapshot())->toEqual($terminal); Console::withLayer('dialogue',fn()=>Console::write('Talk',0,3),1020); expect($this->runtime->present($scene))->toBeTrue() @@ -369,8 +389,8 @@ public function render(?int $x = null, ?int $y = null): void {} new ReflectionProperty($cinematic,'active')->setValue($cinematic, makeBareScene(\Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition::class)); $this->runtime->present($scene); - expect(end($this->transport->sent)->payload)->not->toHaveKey('tileBatches'); - expect(iterator_to_array($scene->getGraphicalSpriteProviders()))->toBe([]); + expect(end($this->transport->sent)->payload['tileBatches'][0]['cells'])->toHaveCount(48); + expect(iterator_to_array($scene->getGraphicalSpriteProviders()))->toBe([$player, $cast]); new ReflectionProperty($cinematic,'active')->setValue($cinematic,null); $this->runtime->present($scene); expect(end($this->transport->sent)->payload['tileBatches'][0]['cells'])->toHaveCount(48); diff --git a/tests/Unit/RendererTransportCompatibilityTest.php b/tests/Unit/RendererTransportCompatibilityTest.php new file mode 100644 index 00000000..32f952a8 --- /dev/null +++ b/tests/Unit/RendererTransportCompatibilityTest.php @@ -0,0 +1,47 @@ +toBeTrue()->and($legacy::cases())->toBe($current::cases()); + foreach ($legacy::cases() as $case) { + expect($case)->toBeInstanceOf($current)->and(unserialize(serialize($case)))->toBe($case); + } +})->with([ + [RendererEventType::class, Enumerations\RendererEventType::class], + [RendererMessageType::class, Enumerations\RendererMessageType::class], + [RendererProtocolVersion::class, Enumerations\RendererProtocolVersion::class], + [RendererTransportState::class, Enumerations\RendererTransportState::class], +]); + +it('accepts a custom transport written against the previous public namespace', function () { + $transport = new class implements RendererTransportInterface { + public function start(RendererSessionConfig $session): void {} + public function isRunning(): bool { return false; } + public function send(RendererMessage $message): void {} + public function pollEvents(float $waitSeconds = 0.0): array { return []; } + public function shutdown(): ?int { return 0; } + public function getState(): RendererTransportState { return RendererTransportState::STOPPED; } + public function getExitCode(): ?int { return 0; } + public function getDiagnostics(): string { return ''; } + }; + $config = new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), __DIR__, + protocol: RendererProtocolVersion::V2); + $runtime = new RendererRuntime($config, $transport); + expect($transport)->toBeInstanceOf(Interfaces\RendererTransportInterface::class) + ->and($transport->getState())->toBe(Enumerations\RendererTransportState::STOPPED) + ->and($runtime->getAssetRoot())->toBe(__DIR__); +}); diff --git a/tests/Unit/RendererTransportTest.php b/tests/Unit/RendererTransportTest.php index 16aee284..cc876fb9 100644 --- a/tests/Unit/RendererTransportTest.php +++ b/tests/Unit/RendererTransportTest.php @@ -1,5 +1,10 @@ messages[] = $message; + } +} + +beforeEach(function () { + $this->consoleGameBefore = new ReflectionProperty(Console::class, 'game')->getValue(); + $this->modalBefore = new ReflectionProperty(ModalManager::class, 'instance')->getValue(); + $this->configBefore = new ReflectionClass(ConfigStore::class)->getStaticProperties(); + $game = new class extends Game { + public function __construct() {} + public function __destruct() {} + }; + new ReflectionProperty(Console::class, 'game')->setValue(null, $game); + $this->alerts = new ShopPurchaseAlerts(); + new ReflectionProperty(ModalManager::class, 'instance')->setValue(null, $this->alerts); + putSceneAudioConfig([]); +}); + +afterEach(function () { + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->consoleGameBefore); + new ReflectionProperty(ModalManager::class, 'instance')->setValue(null, $this->modalBefore); + foreach ($this->configBefore as $name => $value) { + new ReflectionProperty(ConfigStore::class, $name)->setValue(null, $value); + } +}); + +it('keeps buy sell and rebuy stacks independent of catalogue quantities and other parties', function (string $class, int $prototypeQuantity) { + $prototype = new $class('Staff or supply', '', '', 650, $prototypeQuantity, id: 'shop.definition'); + $store = new ReflectionClass(ItemStore::class)->newInstanceWithoutConstructor(); + $store->set($prototype->id, $prototype); + $merchandise = $store->get($prototype->id); + $shop = new Shop([$merchandise]); + $party = new Party(); + $party->credit(8750); + $other = new Party(); + $other->credit(8750); + + $shop->sell($merchandise, 1, $party); + $first = $party->inventory->all->toArray()[0]; + $shop->sell($merchandise, 1, $other); + $otherStack = $other->inventory->all->toArray()[0]; + expect($first)->not->toBe($merchandise)->not->toBe($prototype)->not->toBe($otherStack) + ->and($first->quantity)->toBe(1)->and($first->id)->toBe($prototype->id); + $shop->buy($first, 1, $party); + expect($party->inventory->isEmpty)->toBeTrue()->and($first->quantity)->toBe(0) + ->and($otherStack->quantity)->toBe(1)->and($merchandise->quantity)->toBe($prototypeQuantity); + $shop->sell($merchandise, 1, $party); + $rebought = $party->inventory->all->toArray()[0]; + expect($rebought)->not->toBe($first)->not->toBe($otherStack)->not->toBe($merchandise) + ->and($rebought->quantity)->toBe(1)->and($rebought->id)->toBe($prototype->id) + ->and($party->accountBalance)->toBe(7775) + ->and($store->get($prototype->id)->quantity)->toBe($prototypeQuantity) + ->and($prototype->quantity)->toBe($prototypeQuantity) + ->and($shop->inventory->all->toArray())->toBe([$merchandise]) + ->and($this->alerts->messages)->toBe([]); + if ($rebought instanceof Weapon) { + $actor = new Character('Buyer', 0, new Stats()); + $party->addMember($actor); + $actor->optimizeEquipment($party->inventory, $party); + expect($actor->equipment[0]->equipment)->toBe($rebought); + } +})->with([Item::class, Weapon::class])->with([0, 1, 7]); + +it('admits exactly the requested units and preserves an existing stack even at slot capacity', function (string $class) { + $party = new Party(); + new ReflectionProperty(Party::class, 'inventory')->setValue($party, new Inventory(capacity: 1)); + $party->credit(1000); + $merchandise = new $class('Stackable', '', '', 25, 7, id: 'shop.stack'); + $shop = new Shop([$merchandise], traderBuyRate: 0.8); + $shop->sell($merchandise, 3, $party); + $owned = $party->inventory->all->toArray()[0]; + expect($owned->quantity)->toBe(3)->and($party->inventory->isFull)->toBeTrue(); + $shop->sell($merchandise, 2, $party); + expect($party->inventory->all->toArray())->toBe([$owned]) + ->and($owned->quantity)->toBe(5)->and($merchandise->quantity)->toBe(7) + ->and($party->accountBalance)->toBe(900)->and($this->alerts->messages)->toBe([]); +})->with([Item::class, Weapon::class]); + +it('does not charge or partially admit an over-capacity purchase', function (string $class, bool $existing) { + $party = new Party(); + $party->credit(1000); + $merchandise = new $class('Limited stack', '', '', 1, 7, id: 'shop.limited'); + if ($existing) { + $owned = clone $merchandise; + $owned->quantity = $owned->maxQuantity - 1; + $party->addItems($owned); + } + $before = $party->inventory->all->toArray(); + (new Shop([$merchandise]))->sell($merchandise, $existing ? 2 : $merchandise->maxQuantity + 1, $party); + expect($party->accountBalance)->toBe(1000)->and($party->inventory->all->toArray())->toBe($before) + ->and($merchandise->quantity)->toBe(7) + ->and($this->alerts->messages)->toBe(['Not enough space in inventory!']); + if ($existing) { + expect($owned->quantity)->toBe($owned->maxQuantity - 1); + } +})->with([Item::class, Weapon::class, Armor::class, Accessory::class])->with([false, true]); + +it('rejects a new stack when inventory slots are full without charging', function (string $class) { + $party = new Party(); + new ReflectionProperty(Party::class, 'inventory')->setValue($party, new Inventory(capacity: 1)); + $held = new Item('Existing', '', '', 1, 2); + $party->addItems($held); + $party->credit(1000); + $merchandise = new $class('New stack', '', '', 100); + (new Shop([$merchandise]))->sell($merchandise, 1, $party); + expect($party->accountBalance)->toBe(1000)->and($party->inventory->all->toArray())->toBe([$held]) + ->and($held->quantity)->toBe(2)->and($merchandise->quantity)->toBe(1) + ->and($this->alerts->messages)->toBe(['Not enough space in inventory!']); +})->with([Item::class, Weapon::class]); + +it('ignores non-positive purchase quantities before any mutation or alert', function (int $quantity) { + $party = new Party(); + $party->credit(1000); + $merchandise = new Item('Supply', '', '', 100, 7); + (new Shop([$merchandise]))->sell($merchandise, $quantity, $party); + expect($party->accountBalance)->toBe(1000)->and($party->inventory->isEmpty)->toBeTrue() + ->and($merchandise->quantity)->toBe(7)->and($this->alerts->messages)->toBe([]); +})->with([0, -1]); + +it('leaves inventory and catalogue unchanged when the party cannot afford the quoted purchase', function () { + $party = new Party(); + $party->credit(50); + $merchandise = new Item('Supply', '', '', 30, 7); + (new Shop([$merchandise]))->sell($merchandise, 2, $party); + expect($party->accountBalance)->toBe(50)->and($party->inventory->isEmpty)->toBeTrue() + ->and($merchandise->quantity)->toBe(7)->and($this->alerts->messages)->toBe(['Not enough Gold!']); +}); diff --git a/tests/Unit/ShopStateTest.php b/tests/Unit/ShopStateTest.php new file mode 100644 index 00000000..9f218cb8 --- /dev/null +++ b/tests/Unit/ShopStateTest.php @@ -0,0 +1,64 @@ +addItems($issued, $key, $locked, $empty, $weapon, $free); + $scene = (new ReflectionClass(GameScene::class))->newInstanceWithoutConstructor(); + new ReflectionProperty(GameScene::class, 'party')->setValue($scene, $party); + $state = new ShopState(new SceneStateContext($scene)); + + expect($state->sellableItems)->toBe([$weapon, $free]) + ->and($free->isSellable)->toBeTrue()->and($free->sellValue)->toBe(0); + $shop = new Shop(); + $shop->buy($weapon, 1, $party); + expect($state->sellableItems)->toBe([$free]); + $shop->buy($free, 1, $party); + expect($state->sellableItems)->toBe([]) + ->and($party->inventory->all->toArray())->toBe([$issued, $key, $locked, $empty]); +}); + +it('uses action-appropriate quantity help for buying and selling', function (bool $selling) { + $before = new ReflectionClass(ConfigStore::class)->getStaticProperties(); + try { + putSceneAudioConfig([]); + $state = new ReflectionClass(ShopState::class)->newInstanceWithoutConstructor(); + $info = new class extends InfoPanel { + public string $text = ''; + public function __construct() {} + public function setText(string $text): void { $this->text = $text; } + }; + new ReflectionProperty(ShopState::class, 'infoPanel')->setValue($state, $info); + new ReflectionProperty(ShopState::class, 'mainPanel')->setValue($state, + new ReflectionClass(ShopMainPanel::class)->newInstanceWithoutConstructor()); + $mode = new class($state) extends PurchaseConfirmationMode { + public function updateWindowContent(): void {} + }; + $mode->previousMode = $selling ? new ShopInventorySelectionMode($state) : new ShopMerchandiseSelectionMode($state); + $mode->enter(); + expect($info->text)->toBe('Use the arrow keys to adjust the quantity of the item to ' . ($selling ? 'sell.' : 'purchase.')); + } finally { + foreach ($before as $name => $value) { + new ReflectionProperty(ConfigStore::class, $name)->setValue(null, $value); + } + } +})->with([false, true]); diff --git a/tests/Unit/ShopTest.php b/tests/Unit/ShopTest.php index 2a246f43..fd475d10 100644 --- a/tests/Unit/ShopTest.php +++ b/tests/Unit/ShopTest.php @@ -1,6 +1,7 @@ toBe(8) // (int) round(15 * 1 * 0.5) ->and($party->inventory->getQuantityByName('Potion'))->toBe(1); }); + +it('enforces the owned sale policy even when a same-id offer spoofs its metadata', function (string $class, bool $key, bool $sellable) { + $party = new Party(); + $owned = new $class('Restricted item', '', '', 350, 2, isKeyItem: $key, + id: 'equipment.restricted', sellable: $sellable); + $party->addItems($owned); + $party->credit(25); + $offer = new Item('Ordinary item', '', '', 9999, 99, id: $owned->id, sellable: true); + $shop = new Shop(); + + foreach ([$owned, $offer] as $item) { + $shop->buy($item, 1, $party); + expect($party->accountBalance)->toBe(25) + ->and($owned->quantity)->toBe(2) + ->and($party->inventory->all->toArray())->toBe([$owned]); + } + expect($owned->isSellable)->toBeFalse()->and($owned->sellValue)->toBe(0); +})->with([ + 'unsellable consumable' => [Item::class, false, false], + 'unsellable issued weapon' => [Weapon::class, false, false], + 'key item with sale flag' => [Item::class, true, true], +]); + +it('uses the owned price while preserving shop rates and transaction rounding', function (int $price, int $quantity, float $rate, int $payout) { + $party = new Party(); + $owned = new Weapon('Conventional weapon', '', '', $price, 2, id: 'equipment.conventional'); + $party->addItems($owned); + $offer = new Item('Spoofed offer', '', '', 9999, 99, isKeyItem: true, + id: $owned->id, sellable: false); + + (new Shop(traderSellRate: $rate))->buy($offer, $quantity, $party); + + expect($party->accountBalance)->toBe($payout) + ->and($owned->quantity)->toBe(2 - $quantity) + ->and($offer->quantity)->toBe(99) + ->and($party->inventory->all->toArray())->toBe($quantity === 2 ? [] : [$owned]); +})->with([ + '650 at fifty percent' => [650, 1, 0.5, 325], + '550 at fifty percent' => [550, 1, 0.5, 275], + '700 at fifty percent' => [700, 1, 0.5, 350], + '325 at fifty percent' => [325, 1, 0.5, 163], + '275 at fifty percent' => [275, 1, 0.5, 138], + '350 at fifty percent' => [350, 1, 0.5, 175], + 'round the transaction once' => [325, 2, 0.5, 325], + 'custom shop rate' => [325, 1, 0.8, 260], + 'zero shop rate' => [325, 1, 0.0, 0], +]); + +it('rejects invalid quantities and unowned stable ids without side effects', function (int $quantity, string $offeredId) { + $party = new Party(); + $owned = new Item('Potion', '', '', 50, 2, id: 'item.potion'); + $party->addItems($owned); + (new Shop())->buy(new Item('Potion', '', '', 50, id: $offeredId), $quantity, $party); + expect($party->accountBalance)->toBe(0)->and($owned->quantity)->toBe(2) + ->and($party->inventory->all->toArray())->toBe([$owned]); +})->with([ + 'zero quantity' => [0, 'item.potion'], + 'negative quantity' => [-1, 'item.potion'], + 'same name but unowned id' => [1, 'item.other-potion'], +]); diff --git a/tests/Unit/SkillMenuPresentationTest.php b/tests/Unit/SkillMenuPresentationTest.php new file mode 100644 index 00000000..fc64768d --- /dev/null +++ b/tests/Unit/SkillMenuPresentationTest.php @@ -0,0 +1,539 @@ +entries++; } +} + +function skillMenuPng(string $path, int $width = 53, int $height = 37): void +{ + $chunk = static fn($type, $bytes) => pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAA\xBB\xCC\xFF", $width), $height))) . $chunk('IEND', '')); +} + +function skillMenuTheme(bool $alternative = false): array +{ + $base = ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, 'cursor' => 'cursor.png', + 'icons' => ['skill.ability' => 'ability.png', 'skill.magic' => 'magic.png'], + 'portraits' => ['actor.primary' => 'portrait.png']]; + if (!$alternative) { return $base; } + $art = ['asset' => 'surface.png', 'cuts' => [3, 4, 3, 4]]; + return [...$base, 'colors' => ['text' => [43, 30, 10], 'background' => [237, 230, 210], 'panel' => [245, 235, 225]], + 'metrics' => ['cellWidth' => 8, 'cellHeight' => 18, 'rowHeight' => 34, 'panelPadding' => 16, 'sectionGap' => 8, 'portraitSize' => 88], + 'rowMetrics' => ['padding' => 24, 'iconWidth' => 32, 'iconHeight' => 12, 'cursorWidth' => 10, 'cursorHeight' => 20, + 'cursorInset' => 6, 'cursorTravel' => 3, 'cursorPeriod' => 2, 'separatorWidth' => 1.5], + 'rowArtwork' => ['normal' => $art, 'selected' => $art, 'focus' => ['asset' => 'focus.png'], + 'command.selected' => $art, 'command.focus' => ['asset' => 'focus.png']], + 'frames' => ['panel' => $art, 'quiet' => $art, 'portrait' => $art]]; +} + +function skillMenuText(PresentationCanvas $canvas): array +{ + $text = []; + foreach ($canvas->textLayers as $layer) { + $runs = array_filter($layer->runs, fn($run) => $run->foreground !== null); + if ($runs !== []) { $text[$layer->id] = implode('', array_column($runs, 'text')); } + } + return $text; +} + +function skillMenuKey(AbilityMenuState|MagicMenuState $state, KeyCode $key): void +{ + InputManager::setInputSource(new FakeInputSource($key)); + InputManager::handleInput(); + $state->execute(); +} + +function skillMenuOwner(object $fixture, bool $magic): AbilityMenuState|MagicMenuState +{ + $state = $magic ? new MagicMenuState(new SceneStateContext($fixture->scene)) + : new AbilityMenuState(new SceneStateContext($fixture->scene)); + new ReflectionProperty($fixture->scene, 'state')->setValue($fixture->scene, $state); + $state->enter(); + return $state; +} + +function skillMenuRuntime(object $fixture, ?array $theme, ?array $caps = null): void +{ + if ($theme !== null) { file_put_contents($fixture->root . '/Data/Presentation/menus.php', 'transport = new FakeRendererTransport(); + $fixture->transport->batches = [[RendererEvent::fromJson(json_encode(['type' => 'ready', 'protocol' => 2, 'capabilities' => $caps]))]]; + $fixture->runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['not-launched']), $fixture->root, + requiredCapabilities: $caps), $fixture->transport); + $fixture->runtime->start('Skill menu fixture', 135, 36); + $fixture->game->useRendererRuntime($fixture->runtime); +} + +beforeEach(function () { + $this->savedStatics = []; + foreach ([Console::class, InputManager::class, ActionHints::class, ConfigStore::class, Debug::class, ModalManager::class, AudioManager::class, Time::class, + \Ichiloto\Engine\Events\EventManager::class] as $class) { + $this->savedStatics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + $this->root = sys_get_temp_dir() . '/ichiloto-skill-menu-' . bin2hex(random_bytes(5)); + mkdir($this->root . '/Data/Presentation', 0777, true); + foreach (['portrait', 'cursor', 'surface', 'focus', 'ability', 'magic'] as $name) { skillMenuPng($this->root . '/' . $name . '.png'); } + Debug::configure(['log_directory' => $this->root . '/logs']); + ConfigStore::put(PlaySettings::class, new PlaySettings(['width' => 135, 'height' => 36])); + ConfigStore::put(ProjectConfig::class, new SceneAudioConfigStub()); + $token = new Item('Training Token', 'Learning cost.', '!', 1, 3, id: 'token.training'); + $store = makeBareScene(ItemStore::class); + $store->set($token->id, $token); + ConfigStore::put(ItemStore::class, $store); + Console::syncDimensions(135, 36); + Console::setTerminalOutputEnabled(false); + new ReflectionProperty(Time::class, 'time')->setValue(null, 600.0); + $this->game = new SkillMenuPresentationGame(); + new ReflectionProperty(AudioManager::class, 'instance')->setValue(null, null); + $audio = new class extends AudioManager { + public function __construct() {} + public function playSystemSound(\Ichiloto\Engine\Audio\Enumerations\SystemSound $sound): void {} + }; + new ReflectionProperty($this->game, 'audioManager')->setValue($this->game, $audio); + new ReflectionProperty(Console::class, 'game')->setValue(null, $this->game); + $manager = makeBareScene(SceneManager::class); + new ReflectionProperty($manager, 'game')->setValue($manager, $this->game); + $this->scene = makeBareScene(GameScene::class); + new ReflectionProperty($this->scene, 'sceneManager')->setValue($this->scene, $manager); + $this->flags = new GameState(); + new ReflectionProperty($this->scene, 'gameState')->setValue($this->scene, $this->flags); + $ui = makeBareScene(UIManager::class); + $ui->locationHUDWindow = makeBareScene(LocationHUDWindow::class); + new ReflectionProperty($this->scene, 'uiManager')->setValue($this->scene, $ui); + $this->spell = new MagicSkill('Alpha Restore', 'Restores one eligible ally. Complete authored description.', 'NOT A NATIVE ICON', 3, 0, + new ItemScope(ItemScopeSide::ALLY, ItemScopeNumber::ONE), Occasion::ALWAYS, effects: [new HPRecoverSkillEffect('25', variance: 0.0)]); + $this->ability = new SpecialSkill('Alpha Guard', 'Protect one ally. Complete authored description.', 'NOT A NATIVE ICON', 7, 0, + new ItemScope(ItemScopeSide::ALLY, ItemScopeNumber::ONE), Occasion::BATTLE_SCREEN); + $this->learnSpell = new LearnableSpell(new MagicSkill('Zeta Spell', 'Full learned spell description.', '?', 12, 0, + new ItemScope(ItemScopeSide::ENEMY, ItemScopeNumber::ALL), Occasion::BATTLE_SCREEN), + new SpellLearningRequirement(10, 2, 120, ['token.training' => 2], ['trial.complete']), 2, note: 'Owner archive'); + $this->learnAbility = new LearnableAbility(new SpecialSkill('Zeta Ability', 'Full learned ability description.', '?', 14, 0, + new ItemScope(ItemScopeSide::ENEMY, ItemScopeNumber::ALL), Occasion::BATTLE_SCREEN), + new AbilityLearningRequirement(10, 300, 120, ['token.training' => 2], ['trial.complete']), note: 'Owner mentor'); + $this->actor = new Character('Primary Actor', 100, new Stats(), abilityBook: new AbilityBook([$this->ability], [$this->learnAbility]), + spellbook: new Spellbook([$this->spell], [$this->learnSpell]), actorId: 'actor.primary'); + $this->ally = new Character('Other Actor', 0, new Stats(), actorId: 'actor.other'); + foreach ([$this->actor, $this->ally] as $actor) { + $actor->stats->totalHp = 100; + $actor->stats->totalMp = 20; + $actor->stats->currentHp = 30; + $actor->stats->currentMp = 20; + } + $this->party = new Party(); + $this->party->addMember($this->actor); + $this->party->addMember($this->ally); + $this->party->accountBalance = 300; + $this->party->inventory->addItems(clone $token); + new ReflectionProperty($this->scene, 'party')->setValue($this->scene, $this->party); + $this->returnState = new SkillMenuPresentationReturnState(new SceneStateContext($this->scene)); + new ReflectionProperty($this->scene, 'mainMenuState')->setValue($this->scene, $this->returnState); + $modalManager = makeBareScene(ModalManager::class); + new ReflectionProperty($modalManager, 'modals')->setValue($modalManager, new Stack(ModalInterface::class)); + new ReflectionProperty($this->game, 'modalManager')->setValue($this->game, $modalManager); + InputManager::setBindings(array_map(fn($key) => ['keys' => [$key]], ['confirm' => KeyCode::ENTER, 'cancel' => KeyCode::ESCAPE, + 'up' => KeyCode::UP, 'down' => KeyCode::DOWN, 'left' => KeyCode::LEFT, 'right' => KeyCode::RIGHT, + 'character_next' => KeyCode::Q, 'character_previous' => KeyCode::E])); + $this->runtime = null; + ob_start(); +}); + +afterEach(function () { + $this->runtime?->shutdown(); + ob_end_clean(); + foreach ($this->savedStatics as $class => $properties) { + foreach ($properties as $name => $value) { new ReflectionProperty($class, $name)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +it('keeps scrolled terminal rows contiguous and selects the actual owner index', function (string $class) { + $panel = new $class('List', '', new Vector2(0, 0), 32, 6, new DefaultBorderPack()); + $entries = array_map(fn($i) => 'Entry ' . $i, range(0, 29)); + $panel->setEntries($entries, 25); + $content = $panel->getContent(); + expect(array_keys($content))->toBe([0, 1, 2, 3]); + expect(array_map(fn($line) => trim(TerminalText::stripAnsi($line)), $content))->toBe(['Entry 22', 'Entry 23', 'Entry 24', 'Entry 25']); + expect($content[3])->toBe(SelectionStyle::apply(str_pad('Entry 25', 28))); + $panel->setEntries($entries, 0); + expect(trim(TerminalText::stripAnsi($panel->getContent()[0])))->toBe('Entry 0'); +})->with([MagicListPanel::class, AbilityListPanel::class]); + +it('projects both real skill owners and every tab through two themes without mutating outcomes', function (bool $magic, bool $alternative) { + $state = skillMenuOwner($this, $magic); + $theme = new MenuPresentationCatalog($this->root, skillMenuTheme($alternative)); + $before = [$this->party->accountBalance, $this->actor->stats->currentMp, $this->learnSpell->isLearned, $this->learnAbility->isLearned]; + for ($tab = 0; $tab < 3; $tab++) { + $content = $state->getPresentationContent(); + $frame = SkillMenuPresentation::compose($content, $theme); + $text = skillMenuText($frame); + $flat = implode('', $text); + expect($text['skill-identity'])->toBe(($magic ? 'Magic' : 'Abilities') . $this->actor->name . 'Role: ' . $this->actor->role->name) + ->and($text['skill-resource-1-text'])->toBe('HP30 / ' . $this->actor->effectiveStats->totalHp) + ->and($text['skill-resource-2-text'])->toBe('MP20 / ' . $this->actor->effectiveStats->totalMp) + ->and($text['skill-description'])->toBe($content->description) + ->and($flat)->not->toContain('NOT A NATIVE ICON'); + foreach ($content->fields as $label => $value) { expect($flat)->toContain($label)->toContain($value); } + foreach ($content->tabs as $index => $label) { + $layers = array_column($frame->textLayers, null, 'id'); + $layer = $layers['skill-tab-' . $index . '-text']; + expect($text[$layer->id])->toBe($label) + ->and($layer->x + $layer->grid->columns * $layer->grid->cellWidth / 2)->toBe($layer->clipRect->x + $layer->clipRect->width / 2); + } + expect(array_filter($frame->images, fn($image) => str_starts_with($image->id, 'skill-tab-') && str_ends_with($image->id, '-cursor')))->toBeEmpty(); + if ($tab === 1) { expect($flat)->toContain('trial.complete [Needed]')->toContain('Training Token 3/2')->toContain('Gold 300/120')->toContain('EXP 100/10'); } + $ids = [...array_column($frame->images, 'id'), ...array_column($frame->textLayers, 'id')]; + expect(count(array_unique($ids)))->toBe(count($ids))->and(count($frame->textLayers))->toBeLessThanOrEqual(64); + expect($frame->toArray()['width'])->toBe(1350); + skillMenuKey($state, KeyCode::RIGHT); + } + expect([$this->party->accountBalance, $this->actor->stats->currentMp, $this->learnSpell->isLearned, $this->learnAbility->isLearned])->toBe($before); +})->with([false, true])->with([false, true]); + +it('agrees with learning flags in terminal and canvas and only spends costs on owner confirmation', function (bool $magic) { + $state = skillMenuOwner($this, $magic); + skillMenuKey($state, KeyCode::RIGHT); + $learnable = $magic ? $this->learnSpell : $this->learnAbility; + $theme = new MenuPresentationCatalog($this->root, skillMenuTheme()); + expect($state->getPresentationContent()->fields['Status'])->toBe($magic ? 'In Progress' : 'Locked'); + skillMenuKey($state, KeyCode::ENTER); + expect($learnable->isLearned)->toBeFalse()->and($this->party->accountBalance)->toBe(300); + $this->flags->recordStoryEvent('trial.complete'); + $state->resume(); + $content = $state->getPresentationContent(); + expect($content->fields['Status'])->toBe('Ready')->and($content->detailText)->toContain('trial.complete [Done]'); + $terminal = new ReflectionMethod($state, 'buildLearnDetailLines')->invoke($state); + expect(implode(' ', $terminal))->toContain('Ready')->toContain('trial.complete [Done]'); + for ($redraw = 0; $redraw < 3; $redraw++) { SkillMenuPresentation::compose($state->getPresentationContent(), $theme); } + expect($this->party->inventory->getQuantity('token.training'))->toBe(3)->and($this->party->accountBalance)->toBe(300); + skillMenuKey($state, KeyCode::ENTER); + expect($learnable->isLearned)->toBeTrue()->and($this->party->accountBalance)->toBe(180) + ->and($this->party->inventory->getQuantity('token.training'))->toBe(1) + ->and($state->getPresentationContent()->fields['Status'])->toBe('Learned'); + skillMenuKey($state, KeyCode::ENTER); + expect($this->party->accountBalance)->toBe(180)->and($this->party->inventory->getQuantity('token.training'))->toBe(1); +})->with([false, true]); + +it('keeps optional spell flag arguments backward compatible and reflects all required events', function () { + expect($this->learnSpell->getStatusLabel($this->actor, $this->party))->toBe('In Progress'); + $requirement = $this->learnSpell->requirement; + expect($requirement->describeProgress($this->actor, $this->party, 2))->toContain('trial.complete [Needed]'); + $requirement->requiredEvents[] = 'second.flag'; + expect($requirement->describeProgress($this->actor, $this->party, 2, ['trial.complete'])) + ->toContain('trial.complete [Done]')->toContain('second.flag [Needed]'); + expect($this->learnSpell->getStatusLabel($this->actor, $this->party, ['trial.complete']))->toBe('In Progress') + ->and($this->learnSpell->getStatusLabel($this->actor, $this->party, ['trial.complete', 'second.flag']))->toBe('Ready'); +}); + +it('keeps sort selection distinct from focus until the same owner applies it', function (bool $magic) { + $state = skillMenuOwner($this, $magic); + skillMenuKey($state, KeyCode::RIGHT); + skillMenuKey($state, KeyCode::RIGHT); + skillMenuKey($state, KeyCode::DOWN); + $content = $state->getPresentationContent(); + expect($content->rows[0]->selected)->toBeTrue()->and($content->rows[0]->focused)->toBeFalse() + ->and($content->rows[1]->selected)->toBeFalse()->and($content->rows[1]->focused)->toBeTrue(); + $frame = SkillMenuPresentation::compose($content, new MenuPresentationCatalog($this->root, skillMenuTheme(true))); + $layers = array_column($frame->textLayers, null, 'id'); + foreach ([0, 1] as $index) { + $label = $layers['skill-entry-' . $index . '-text']; + expect($label->x + $label->grid->columns * $label->grid->cellWidth / 2)->toBe($label->clipRect->x + $label->clipRect->width / 2); + expect($layers)->not->toHaveKey('skill-entry-' . $index . '-separator'); + } + skillMenuKey($state, KeyCode::ENTER); + expect($state->getPresentationContent()->summary['Current Order'])->toBe('Z-A') + ->and($state->getPresentationContent()->rows[1]->selected)->toBeTrue(); + skillMenuKey($state, KeyCode::RIGHT); + expect($state->getPresentationContent()->tabIndex)->toBe(0); +})->with([false, true]); + +it('cycles stable actor roles using semantic bindings and existing Tab aliases then returns to Main', function (bool $magic) { + $state = skillMenuOwner($this, $magic); + skillMenuKey($state, KeyCode::Q); + expect($state->character)->toBe($this->ally); + $theme = new MenuPresentationCatalog($this->root, skillMenuTheme()); + $canvas = SkillMenuPresentation::compose($state->getPresentationContent(), $theme); + expect(array_filter($canvas->images, fn($image) => $image->asset === 'portrait.png'))->toBeEmpty(); + expect(implode('', skillMenuText($canvas)))->toContain($magic ? 'No learned magic.' : 'No learned abilities.'); + skillMenuKey($state, KeyCode::TAB); + expect($state->character)->toBe($this->actor); + skillMenuKey($state, KeyCode::SHIFT_TAB); + expect($state->character)->toBe($this->ally); + skillMenuKey($state, KeyCode::E); + expect($state->character)->toBe($this->actor); + skillMenuKey($state, KeyCode::ESCAPE); + expect($this->scene->state)->toBe($this->returnState)->and($this->returnState->entries)->toBe(1); +})->with([false, true]); + +it('retains target selection and owner resources across redraw cancel and confirmation', function (bool $alternative) { + $state = skillMenuOwner($this, true); + $theme = new MenuPresentationCatalog($this->root, skillMenuTheme($alternative)); + skillMenuKey($state, KeyCode::ENTER); + $content = $state->getPresentationContent(); + expect($content->targeting)->toBeTrue()->and(array_column($content->rows, 'label'))->toBe(['Primary Actor', 'Other Actor']); + skillMenuKey($state, KeyCode::DOWN); + skillMenuKey($state, KeyCode::Q); + $content = $state->getPresentationContent(); + expect($content->index)->toBe(1)->and($content->character)->toBe($this->actor)->and($content->fields['Target'])->toBe('Other Actor'); + for ($i = 0; $i < 3; $i++) { + $canvas = SkillMenuPresentation::compose($content, $theme); + expect(implode('', skillMenuText($canvas)))->toContain('CasterPrimary Actor')->toContain('TargetOther Actor'); + expect(count($canvas->textLayers))->toBeLessThanOrEqual(64); + } + skillMenuKey($state, KeyCode::ESCAPE); + expect($state->getPresentationContent()->targeting)->toBeFalse()->and($this->actor->stats->currentMp)->toBe(20) + ->and($this->ally->stats->currentHp)->toBe(30)->and($this->scene->state)->toBe($state); + skillMenuKey($state, KeyCode::ENTER); + skillMenuKey($state, KeyCode::DOWN); + skillMenuKey($state, KeyCode::ENTER); + expect($this->actor->stats->currentMp)->toBe(17)->and($this->ally->stats->currentHp)->toBe(55) + ->and($state->getPresentationContent()->targeting)->toBeFalse() + ->and($state->getPresentationContent()->status)->toBe('Primary Actor cast Alpha Restore on Other Actor.'); + SkillMenuPresentation::compose($state->getPresentationContent(), $theme); +})->with([false, true]); + +it('keeps field casting failures inspectable without spending MP', function () { + $state = skillMenuOwner($this, true); + $this->actor->stats->currentMp = 0; + skillMenuKey($state, KeyCode::ENTER); + expect($state->getPresentationContent()->targeting)->toBeFalse() + ->and($state->getPresentationContent()->status)->toBe('Not enough MP for Alpha Restore.'); + $theme = new MenuPresentationCatalog($this->root, skillMenuTheme()); + expect(implode('', skillMenuText(SkillMenuPresentation::compose($state->getPresentationContent(), $theme)))) + ->toContain('Not enough MP for Alpha Restore.')->toContain('3 MP'); + expect($this->actor->stats->currentMp)->toBe(0)->and($this->ally->stats->currentHp)->toBe(30); +}); + +it('submits an actual menu canvas with a supported modal and restores it without consuming menu input', function (bool $magic) { + skillMenuRuntime($this, skillMenuTheme()); + $state = skillMenuOwner($this, $magic); + $before = $state->getPresentationContent(); + expect($this->runtime->present($this->scene))->toBeTrue(); + $messages = array_filter($this->transport->sent, fn($message) => isset($message->payload['canvas'])); + expect($messages)->not->toBeEmpty(); + $modal = new AlertModal($this->game, 'Owner notice.', 'Notice'); + $stack = new ReflectionProperty($this->game->modalManager, 'modals')->getValue($this->game->modalManager); + $stack->push($modal); + new ReflectionProperty($modal, 'isShowing')->setValue($modal, true); + expect($this->runtime->present($this->scene))->toBeTrue(); + $canvas = $this->scene->getPresentationCanvas(); + expect(implode('', skillMenuText($canvas)))->toContain('Owner notice.')->toContain($this->actor->name); + $baseLayers = array_filter($canvas->textLayers, fn($layer) => !str_starts_with($layer->id, 'menu-modal-')); + $modalLayers = array_filter($canvas->textLayers, fn($layer) => str_starts_with($layer->id, 'menu-modal-')); + expect(min(array_column($modalLayers, 'layer')))->toBeGreaterThan(max(array_column($baseLayers, 'layer'))); + new ReflectionProperty($modal, 'isShowing')->setValue($modal, false); + expect(implode('', skillMenuText($this->scene->getPresentationCanvas())))->not->toContain('Owner notice.'); + expect($state->getPresentationContent())->toEqual($before)->and($this->scene->state)->toBe($state); + $stack->pop(); + $prompt = new PromptModal($this->game, 'Real prompt', 'Prompt'); + $stack->push($prompt); + new ReflectionProperty($prompt, 'isShowing')->setValue($prompt, true); + expect($this->scene->getPresentationCanvas())->toBeNull()->and($state->getPresentationContent())->toEqual($before); + $stack->pop(); + expect($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class); +})->with([false, true]); + +it('keeps terminal owners operational when theme or capabilities are unavailable', function (bool $magic, string $reason) { + $theme = $reason === 'absent' ? null : skillMenuTheme(); + if ($reason === 'invalid') { $theme['portraits']['actor.primary'] = 'missing.png'; } + skillMenuRuntime($this, $theme, $reason === 'capabilities' ? [] : null); + $state = skillMenuOwner($this, $magic); + expect($this->scene->getPresentationCanvas())->toBeNull(); + skillMenuKey($state, KeyCode::RIGHT); + expect($state->getPresentationContent()->tabIndex)->toBe(1); + $list = new ReflectionProperty($state, 'listPanel')->getValue($state); + expect(implode('', $list->getContent()))->toContain($magic ? 'Zeta Spell' : 'Zeta Ability'); + if ($reason !== 'absent') { expect(file_get_contents($this->root . '/logs/error.log'))->toContain('Menu presentation degraded to terminal'); } +})->with([false, true])->with(['absent', 'capabilities', 'invalid']); + +it('keeps long lists source complete and follows the active row within a bounded canvas', function (bool $magic, bool $alternative) { + $skills = []; + $class = $magic ? MagicSkill::class : SpecialSkill::class; + for ($i = 0; $i < 70; $i++) { + $skills[] = new $class(sprintf('Skill %02d with a descriptive authored name', $i), 'Full source description for ' . $i, 'ASCII', 1234, 0, + new ItemScope(ItemScopeSide::ENEMY, ItemScopeNumber::ALL), $i % 2 ? Occasion::NEVER : Occasion::BATTLE_SCREEN); + } + new ReflectionProperty($this->actor, $magic ? 'spellbook' : 'abilityBook') + ->setValue($this->actor, $magic ? new Spellbook($skills) : new AbilityBook($skills)); + $state = skillMenuOwner($this, $magic); + skillMenuKey($state, KeyCode::UP); + $content = $state->getPresentationContent(); + expect($content->index)->toBe(69)->and($content->rows)->toHaveCount(70); + $frame = SkillMenuPresentation::compose($content, new MenuPresentationCatalog($this->root, skillMenuTheme($alternative))); + $text = skillMenuText($frame); + expect(implode('', $text))->toContain('Skill 69 with a descriptive authored name')->toContain('1234 MP')->toContain('Locked') + ->and($text['skill-entry-range'])->toEndWith('70 / 70')->and(count($frame->textLayers))->toBeLessThanOrEqual(64); + $terminal = new ReflectionProperty($state, 'listPanel')->getValue($state)->getContent(); + expect(implode('', $terminal))->toContain('Skill 69'); +})->with([false, true])->with([false, true]); + +it('uses stable portrait identity and decodes replacement artwork proportions on redraw', function () { + $state = skillMenuOwner($this, false); + $theme = new MenuPresentationCatalog($this->root, skillMenuTheme()); + $first = SkillMenuPresentation::compose($state->getPresentationContent(), $theme); + skillMenuPng($this->root . '/portrait.png', 21, 89); + new ReflectionProperty($this->actor, 'name')->setValue($this->actor, 'Renamed Actor'); + $second = SkillMenuPresentation::compose($state->getPresentationContent(), $theme); + $before = array_values(array_filter($first->images, fn($image) => $image->asset === 'portrait.png'))[0]; + $after = array_values(array_filter($second->images, fn($image) => $image->asset === 'portrait.png'))[0]; + expect($after->asset)->toBe($before->asset)->and($after->destination->height)->toBeGreaterThan($after->destination->width) + ->and($before->destination->width)->toBeGreaterThan($before->destination->height) + ->and(implode('', skillMenuText($second)))->toContain('Renamed Actor'); + $after->destination->assertWithin($second->width, $second->height); +}); + +it('pages complete description and status with fixed Info bounds and separate rebound hints', function (bool $magic) { + $state = skillMenuOwner($this, $magic); + $data = skillMenuTheme(); + $data['showInputHints'] = true; + InputManager::setBinding('confirm', [KeyCode::F10]); + InputManager::setBinding('cancel', [KeyCode::X]); + $skill = $magic ? $this->spell : $this->ability; + new ReflectionProperty($skill, 'description')->setValue($skill, str_repeat('Wrapped detail. ', 15)); + new ReflectionProperty($state, 'statusMessage')->setValue($state, str_repeat('Owner status. ', 8)); + $theme = new MenuPresentationCatalog($this->root, $data); + $seen = ''; + $infoBounds = null; + do { + $frame = SkillMenuPresentation::compose($state->getPresentationContent(), $theme); + $text = skillMenuText($frame); + $layers = array_column($frame->textLayers, null, 'id'); + $bounds = $layers['skill-info']->clipRect; + $infoBounds ??= $bounds; + expect($bounds)->toEqual($infoBounds)->and(implode('', $text))->toContain('F10', 'X'); + foreach (['skill-description', 'skill-status'] as $id) { + $seen .= $text[$id] ?? ''; + if (!isset($layers[$id])) { continue; } + foreach ($frame->textLayers as $layer) { + if (str_starts_with($layer->id, 'skill-hints-')) { + expect($layer->y)->toBeGreaterThanOrEqual($layers[$id]->y + $layers[$id]->bounds->height); + } + } + } + $page = $state->menuInfoText->lastPage; + expect($page->rows)->toBe(2); + $state->menuInfoText->advance(); + } while ($page->nextOffset !== 0); + expect($seen)->toBe($skill->description . $state->getPresentationContent()->status); +})->with([false, true]); + +it('keeps long Info readable and removes only graphical Magic source metadata', function (string $field) { + skillMenuRuntime($this, skillMenuTheme()); + $state = skillMenuOwner($this, true); + if ($field === 'description') { new ReflectionProperty($this->spell, 'description')->setValue($this->spell, str_repeat('Complete prose ', 500)); } + else { + $this->learnSpell->note = str_repeat('Source requirement ', 500); + skillMenuKey($state, KeyCode::RIGHT); + } + $content = $state->getPresentationContent(); + expect($this->scene->getPresentationCanvas())->toBeInstanceOf(PresentationCanvas::class) + ->and($this->scene->state)->toBe($state)->and($content->fields)->not->toHaveKey('Source'); + if ($field === 'description') { + expect($content->description)->toBe($this->spell->description) + ->and($state->menuInfoText->lastPage->source)->toBe($this->spell->description); + } else { expect($this->learnSpell->note)->toBe(str_repeat('Source requirement ', 500)); } +})->with(['description', 'source']); + +it('retains the neutral canvas without optional artwork and semantic hints remain live', function (bool $magic) { + $state = skillMenuOwner($this, $magic); + $theme = new MenuPresentationCatalog($this->root, ['schema' => 'ichiloto.menu/1']); + $canvas = SkillMenuPresentation::compose($state->getPresentationContent(), $theme); + expect($canvas->images)->toBeEmpty()->and(implode('', skillMenuText($canvas)))->toContain('Enter')->toContain('Escape'); + for ($tab = 0; $tab < 3; $tab++) { + skillMenuKey($state, KeyCode::RIGHT); + expect(SkillMenuPresentation::compose($state->getPresentationContent(), $theme))->toBeInstanceOf(PresentationCanvas::class); + } + InputManager::setBinding('confirm', []); + InputManager::setBinding('cancel', []); + $canvas = SkillMenuPresentation::compose($state->getPresentationContent(), $theme); + expect(implode('', skillMenuText($canvas)))->toContain('Unbound')->not->toContain('Escape'); + expect($state->getPresentationContent()->index)->toBe(0); +})->with([false, true]); + +it('retains every occasion and cost including field-ineligible learned skills', function (bool $magic) { + $class = $magic ? MagicSkill::class : SpecialSkill::class; + $skills = array_map(fn($occasion) => new $class($occasion->name, 'Owner description.', '?', 9, 0, + new ItemScope(ItemScopeSide::USER), $occasion), Occasion::cases()); + new ReflectionProperty($this->actor, $magic ? 'spellbook' : 'abilityBook') + ->setValue($this->actor, $magic ? new Spellbook($skills) : new AbilityBook($skills)); + $state = skillMenuOwner($this, $magic); + $content = $state->getPresentationContent(); + expect($content->rows)->toHaveCount(4); + $occasions = array_map(fn($row) => $row->values[0]->text, $content->rows); + expect($occasions)->toContain('Both')->toContain('Battle')->toContain('Field')->toContain('Locked'); + foreach ($content->rows as $row) { expect($row->values[1]->text)->toBe('9 MP'); } +})->with([false, true]); diff --git a/tests/Unit/SpriteSheetTransportTest.php b/tests/Unit/SpriteSheetTransportTest.php index 18b3fef5..6d330c31 100644 --- a/tests/Unit/SpriteSheetTransportTest.php +++ b/tests/Unit/SpriteSheetTransportTest.php @@ -6,13 +6,13 @@ use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererStartupException; use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; use Ichiloto\Engine\Rendering\Transport\RendererEvent; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Tests\Support\Input\FakeRendererTransport; diff --git a/tests/Unit/StyledConsolePresentationTest.php b/tests/Unit/StyledConsolePresentationTest.php index a9d0e38d..095721b7 100644 --- a/tests/Unit/StyledConsolePresentationTest.php +++ b/tests/Unit/StyledConsolePresentationTest.php @@ -11,9 +11,9 @@ use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\Presentation\StyledPresentationFrame; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererProtocolVersion; use Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; -use Ichiloto\Engine\Rendering\Transport\RendererProtocolVersion; use Tests\Support\Input\FakeRendererTransport; require_once __DIR__ . '/../Support/Input/FakeRendererTransport.php'; diff --git a/tests/Unit/TextViewportTest.php b/tests/Unit/TextViewportTest.php new file mode 100644 index 00000000..2c93241f --- /dev/null +++ b/tests/Unit/TextViewportTest.php @@ -0,0 +1,61 @@ +setText(implode("\n", array_map(fn($i) => 'Line ' . $i, range(0, 80)))); + $seen = []; + for ($i = 0; $i < 100; $i++) { + $page = $view->page(30, 4); + array_push($seen, ...$page->lines); + $view->scroll(1, $page); + } + expect(array_values(array_unique($seen)))->toBe(array_map(fn($i) => 'Line ' . $i, range(0, 80))) + ->and($view->page(30, 4)->range())->toBe('Lines 78-81 / 81'); + for ($i = 0; $i < 100; $i++) { $view->scroll(-1, $view->page(30, 4)); } + expect($view->page(30, 4)->first)->toBe(0); +}); + +it('preserves graphemes explicit blank lines and complete localized text within both cell budgets', function () { + $text = "\u{65E5}\u{672C}\u{8A9E} cafe\u{0301} \u{1F469}\u{200D}\u{1F4BB}\n\n" . str_repeat('Localized word ', 40); + $wrapped = TextViewport::wrap($text, 12); + $rebuilt = ''; + foreach ($wrapped as $index => [$offset, $line]) { + expect(TerminalText::displayWidth($line))->toBeLessThanOrEqual(12)->and(mb_strlen($line))->toBeLessThanOrEqual(12); + $next = $wrapped[$index + 1][0] ?? strlen($text); + $rebuilt .= substr($text, $offset, $next - $offset); + } + expect($rebuilt)->toBe($text)->and(array_column($wrapped, 1))->toContain(''); + expect(implode('', array_column($wrapped, 1)))->toContain("e\u{0301}")->toContain("\u{1F469}\u{200D}\u{1F4BB}"); +}); + +it('reflows around the source anchor without navigating during snapshot reads', function () { + $view = new TextViewport(); + $view->setText(str_repeat('abcdefghijklmnopqrstuvwxyz ', 20)); + for ($i = 0; $i < 6; $i++) { $view->scroll(1, $view->page(20, 3)); } + $before = clone $view; + $wide = $view->page(40, 5); + $narrow = $view->page(10, 2); + expect($view)->toEqual($before)->and($wide->first)->toBe(3)->and($narrow->first)->toBe(9); + $view->setText('Changed'); + $view->scroll(1, $wide); + expect($view->page(20, 3)->lines)->toBe(['Changed'])->and($view->page(20, 3)->first)->toBe(0); +}); + +it('keeps fitting words intact and makes shortened list previews explicit', function () { + expect(array_column(TextViewport::wrap('Read the manual with S-Potion', 13), 1))->toBe(['Read the ', 'manual with ', 'S-Potion']); + expect(TextViewport::preview('Short', 10))->toBe('Short') + ->and(TextViewport::preview('Long authored name', 10))->toEndWith('...') + ->and(TextViewport::preview("First\nSecond", 20))->toBe('First...'); +}); + +it('handles empty content and rejects geometry that cannot hold one complete grapheme', function () { + $view = new TextViewport(); + expect($view->page(20, 3)->lines)->toBe([''])->and($view->page(20, 3)->range())->toBe('Lines 1-1 / 1'); + expect(fn() => $view->page(0, 1))->toThrow(InvalidArgumentException::class); + expect(fn() => TextViewport::wrap("\u{65E5}", 1))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/TileBatchTransportTest.php b/tests/Unit/TileBatchTransportTest.php index 8e77ac6f..acb4075c 100644 --- a/tests/Unit/TileBatchTransportTest.php +++ b/tests/Unit/TileBatchTransportTest.php @@ -1,20 +1,20 @@ modals = new Stack(ModalInterface::class); } + public function showCredits(CreditsContent $content): void { $this->shown = $content; } +} + +final class CreditsLifecycleGame extends Game +{ + public function __construct() { $this->modalManager = new CreditsLifecycleModals(); } + public function __destruct() {} +} + +final class CreditsLifecycleMenu extends TitleMenu +{ + public function __construct() { $this->activeIndex = 3; } + public function render(?int $x = null, ?int $y = null): void {} +} + +final class CreditsLifecycleScene extends TitleScene +{ + public function __construct(Game $game) + { + $this->sceneManager = new ReflectionClass(SceneManager::class)->newInstanceWithoutConstructor(); + $this->sceneManager->game = $game; + $this->menu = new CreditsLifecycleMenu(); + $this->resetTitlePresentation(); + } + public function syncContinueAvailability(): void {} + public function renderHeader(): void {} + public function tickCredits(): void { $this->updateCredits(); } + public function advancePresentation(): void { $this->advanceTitlePresentation(); } + public function getSelectedIndex(): int { return $this->menu->activeIndex; } + public function getMenu(): TitleMenu { return $this->menu; } +} + +final class CreditsLifecycleConfig extends ProjectConfig +{ + protected function load(): array { return $this->options; } + public function persist(): void {} +} + +beforeEach(function () { + $this->statics = []; + foreach ([Console::class, ConfigStore::class, InputManager::class] as $class) { + $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); + } + Console::setTerminalOutputEnabled(false); + ConfigStore::put(ProjectConfig::class, new CreditsLifecycleConfig(['name' => 'Example game', + 'audio' => ['master_volume' => 0, 'music' => false, 'sfx' => false]])); + $this->root = sys_get_temp_dir() . '/credits-lifecycle-' . bin2hex(random_bytes(5)); + mkdir($this->root . '/assets/Data', 0777, true); + $this->cwd = getcwd(); + chdir($this->root); + $this->runtime = null; + $this->game = new CreditsLifecycleGame(); + $this->scene = new CreditsLifecycleScene($this->game); + $this->sections = [['title' => 'Design', 'lines' => ['Andrew Masiye']]]; +}); + +afterEach(function () { + $this->runtime?->shutdown(); + chdir($this->cwd); + if (file_exists($this->root . '/assets/Data/credits.php')) { unlink($this->root . '/assets/Data/credits.php'); } + rmdir($this->root . '/assets/Data'); + rmdir($this->root . '/assets'); + rmdir($this->root); + foreach ($this->statics as $class => $properties) { + foreach ($properties as $key => $value) { new ReflectionProperty($class, $key)->setValue(null, $value); } + } +}); + +function startCreditsTestRenderer(string $root, Game $game, ?FakeRendererTransport $transport = null, + array $caps = CreditsMenuPresentation::CAPABILITIES): RendererRuntime +{ + $transport ??= new FakeRendererTransport(); + $transport->batches[] = [RendererEvent::fromJson(json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => $caps]))]; + $runtime = new RendererRuntime(new RendererRuntimeConfig(new RendererProcessConfig(['never-launched']), $root, + requiredCapabilities: $caps), $transport); + $runtime->start('Credits test', 80, 24); + $game->useRendererRuntime($runtime); + return $runtime; +} + +it('routes terminal and reduced motion to the same centered alert content', function (bool $graphical) { + if ($graphical) { + $this->runtime = startCreditsTestRenderer($this->root, $this->game); + ConfigStore::put(ProjectConfig::class, new CreditsLifecycleConfig(['accessibility' => ['reducedMotion' => true]])); + } + $this->scene->openCredits($this->sections); + expect($this->game->modalManager->shown?->sections)->toBe($this->sections) + ->and(new ReflectionProperty(TitleScene::class, 'creditsPlayback')->getValue($this->scene))->toBeNull() + ->and($this->scene->getSelectedIndex())->toBe(3); +})->with([false, true]); + +it('rolls without title artwork and returns with focus intact on semantic dismissal', function (string $action) { + $this->runtime = startCreditsTestRenderer($this->root, $this->game); + $this->scene->openCredits($this->sections); + $clockProperty = new ReflectionProperty(TitleScene::class, 'creditsPlayback'); + $clock = $clockProperty->getValue($this->scene); + expect($clock)->toBeInstanceOf(CreditsPlayback::class)->and($this->game->modalManager->shown)->toBeNull(); + $frame = $this->scene->getPresentationCanvas(); + $offset = $clock->offset; + expect($frame)->not->toBeNull()->and($this->scene->getPresentationCanvas()->toArray())->toBe($frame->toArray()) + ->and($clock->offset)->toBe($offset); + new ReflectionProperty(InputManager::class, 'config')->setValue(null, [$action => ['keys' => [KeyCode::Q]]]); + InputManager::setInputSource(new FakeInputSource(KeyCode::Q)); + InputManager::handleInput(); + $this->scene->tickCredits(); + expect($clockProperty->getValue($this->scene))->toBeNull()->and($this->scene->getSelectedIndex())->toBe(3); + $this->scene->openCredits($this->sections); + expect($clockProperty->getValue($this->scene))->not->toBe($clock) + ->and($clockProperty->getValue($this->scene)->offset)->toBeLessThan(1.0); +})->with(['confirm', 'cancel', 'back']); + +it('returns automatically after the last line leaves the viewport', function () { + $this->runtime = startCreditsTestRenderer($this->root, $this->game); + $this->scene->openCredits($this->sections); + $property = new ReflectionProperty(TitleScene::class, 'creditsPlayback'); + $clock = $property->getValue($this->scene); + $now = hrtime(true) / 1e9; + for ($tick = 1; !$clock->finished; $tick++) { $clock->advance($now + $tick / 10); } + $this->scene->tickCredits(); + expect($property->getValue($this->scene))->toBeNull()->and($this->scene->getSelectedIndex())->toBe(3); +}); + +it('falls back when a menu-only renderer cannot report window activation', function () { + $this->runtime = startCreditsTestRenderer($this->root, $this->game, caps: MenuPresentationCatalog::CAPABILITIES); + $this->scene->openCredits($this->sections); + expect($this->game->modalManager->shown?->sections)->toBe($this->sections) + ->and(new ReflectionProperty(TitleScene::class, 'creditsPlayback')->getValue($this->scene))->toBeNull(); +}); + +it('pauses standalone credits on native focus loss and resumes without catching up', function () { + $transport = new FakeRendererTransport(); + $this->runtime = startCreditsTestRenderer($this->root, $this->game, $transport); + $this->scene->openCredits($this->sections); + $clock = new ReflectionProperty(TitleScene::class, 'creditsPlayback')->getValue($this->scene); + $transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":false}')]; + $this->runtime->pump(); + $this->scene->advancePresentation(); + $offset = $clock->offset; + $this->scene->advancePresentation(); + expect($this->runtime->windowActive)->toBeFalse()->and($clock->offset)->toBe($offset); + $transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":true}')]; + $this->runtime->pump(); + $this->scene->advancePresentation(); + expect($this->runtime->windowActive)->toBeTrue()->and($clock->offset)->toBe($offset) + ->and($this->game->modalManager->shown)->toBeNull(); +}); + +it('executes the real credits command and preserves fallback metadata for invalid authoring', function (string $source, array $expected) { + file_put_contents($this->root . '/assets/Data/credits.php', $source); + $menu = $this->scene->getMenu(); + $command = new ShowCreditsCommand($menu); + expect($command->execute())->toBe($command::FAILURE); + expect($command->execute(new MenuCommandExecutionContext([], new NullOutput(), $menu, $this->scene)))->toBe($command::SUCCESS) + ->and($this->game->modalManager->shown?->sections)->toBe($expected); +})->with([ + [" 'Design', 'lines' => ['Andrew Masiye']]];", [['title' => 'Design', 'lines' => ['Andrew Masiye']]]], + [" 'Invalid', 'lines' => [false]]];", [['title' => 'Example game', 'lines' => ['Made with the Ichiloto engine.']]]], +]); diff --git a/tests/Unit/TitlePresentationTest.php b/tests/Unit/TitlePresentationTest.php new file mode 100644 index 00000000..f402917b --- /dev/null +++ b/tests/Unit/TitlePresentationTest.php @@ -0,0 +1,327 @@ + pack('N', strlen($bytes)) . $type . $bytes . pack('N', crc32($type . $bytes)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xAB\xBC\xCD\xFF", $width), $height))) . $chunk('IEND', '')); + clearstatcache(true, $path); +} + +function getTitleTestData(): array +{ + return ['schema' => 'ichiloto.title/1', 'logo' => 'logo.png', + 'theme' => ['schema' => 'ichiloto.menu/1', 'showInputHints' => false, 'cursor' => 'bird.png'], + 'scenes' => [ + 'day' => ['background' => 'day.png', 'sprites' => [ + 'banner' => ['asset' => 'banner.png', 'grid' => [6, 4], 'frames' => 24, 'frameSeconds' => 0.2, + 'destination' => [70, 92, 114, 368]], + 'bird' => ['asset' => 'bird.png', 'grid' => [4, 1], 'frames' => 4, 'frameSeconds' => 0.16, + 'destination' => [785, 273, 8, 8], 'travel' => [240, 0], 'period' => 38, 'visibleSeconds' => 9, + 'phaseSeconds' => 8, 'frameOffset' => 2, 'opacity' => 0.72, 'fadeFraction' => 0.125, 'reducedVisible' => false], + ]], 'night' => ['background' => 'night.png'], + ]]; +} + +function getTitleTestText(PresentationCanvas $canvas): string +{ + return implode("\n", array_map(static fn($layer) => implode('', array_map(static fn($run) => + $run->foreground === null ? '' : $run->text, $layer->runs)), $canvas->textLayers)); +} + +function advanceTitleTestClock(TitlePlayback $clock, float $start, float $end, string $local, bool $reduced = false): void +{ + for ($tick = (int)round($start * 100) + 1; $tick <= (int)round($end * 100); $tick++) { + $clock->advance($tick / 100, new DateTimeImmutable($local), $reduced); + } +} + +final class TitlePresentationMemoryConfig extends ProjectConfig +{ + protected function load(): array { return $this->options; } + public function persist(): void {} +} + +final class TitlePresentationMenuProbe extends TitleMenu +{ + public function __construct() { $this->activeIndex = 3; } + public function render(?int $x = null, ?int $y = null): void {} +} + +final class TitlePresentationSceneProbe extends TitleScene +{ + public function __construct() { $this->menu = new TitlePresentationMenuProbe(); } + public function syncContinueAvailability(): void {} + public function renderHeader(): void {} + public function getSelectedIndex(): int { return $this->menu->activeIndex; } + public function initializePresentation(): void { $this->resetTitlePresentation(); } + public function releasePresentation(): void { $this->stopTitlePresentation(); } +} + +beforeEach(function () { + $this->statics = []; + foreach ([Console::class, ConfigStore::class] as $class) { $this->statics[$class] = new ReflectionClass($class)->getStaticProperties(); } + Console::setTerminalOutputEnabled(false); + ConfigStore::put(ProjectConfig::class, new TitlePresentationMemoryConfig(['audio' => ['master_volume' => 0, 'music' => false, 'sfx' => false]])); + $this->root = sys_get_temp_dir() . '/ichiloto-title-' . bin2hex(random_bytes(5)); + mkdir($this->root); + foreach (['day', 'night', 'logo', 'banner', 'bird'] as $file) { writeTitleTestPng($this->root . '/' . $file . '.png', 61, 41); } +}); + +afterEach(function () { + foreach ($this->statics as $class => $properties) { + foreach ($properties as $key => $value) { new ReflectionProperty($class, $key)->setValue(null, $value); } + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->root); +}); + +it('selects player-local day and night at exact boundaries', function (string $local, float $night) { + $clock = new TitlePlayback(); + $clock->advance(0, new DateTimeImmutable($local), false); + expect($clock->nightWeight)->toBe($night)->and($clock->entryOpacity)->toBe(0.0); +})->with([['2026-09-22 05:59:59+02:00', 1.0], ['2026-09-22 06:00:00+02:00', 0.0], + ['2026-09-22 17:59:59+02:00', 0.0], ['2026-09-22 18:00:00+02:00', 1.0]]); + +it('reconciles boundaries without replaying entry or catching up hidden time', function () { + $clock = new TitlePlayback(); + $clock->advance(0, new DateTimeImmutable('2026-09-22 17:59:59.950'), false); + advanceTitleTestClock($clock, 0, 0.05, '2026-09-22 18:00:00'); + advanceTitleTestClock($clock, 0.05, 1.05, '2026-09-22 18:00:01'); + expect($clock->nightWeight)->toBe(1.0)->and($clock->entryOpacity)->toBe(1.0); + $elapsed = $clock->elapsed; + $clock->setObscured(true, 1.05); + $clock->advance(20, new DateTimeImmutable('2026-09-23 10:00:00'), false); + expect($clock->elapsed)->toBe($elapsed)->and($clock->nightWeight)->toBe(1.0); + $clock->setObscured(false, 30); + $clock->advance(30, new DateTimeImmutable('2026-09-23 10:00:00'), false); + expect($clock->elapsed)->toBe($elapsed); + advanceTitleTestClock($clock, 30, 31, '2026-09-23 10:00:01'); + expect($clock->nightWeight)->toBeLessThan(0.00001)->and($clock->entryOpacity)->toBe(1.0); +}); + +it('initializes correctly after pre-entry suspension and shortens reduced-motion transitions', function () { + $clock = new TitlePlayback(); + $clock->setObscured(true, 0); + $clock->advance(1, new DateTimeImmutable('2026-09-22 12:00:00'), true); + $clock->setObscured(false, 2); + $clock->advance(2, new DateTimeImmutable('2026-09-22 18:00:00'), true); + expect($clock->nightWeight)->toBe(1.0)->and($clock->entryOpacity)->toBe(1.0); + advanceTitleTestClock($clock, 2, 3, '2026-09-23 12:00:00', true); + advanceTitleTestClock($clock, 3, 3.16, '2026-09-23 12:00:00', true); + expect($clock->nightWeight)->toBeLessThan(0.00001); + expect(fn() => $clock->advance(0, null, true))->toThrow(InvalidArgumentException::class); +}); + +it('projects all five real button labels centered without any oscillating cursor', function () { + $catalog = new TitlePresentationCatalog($this->root, getTitleTestData()); + $clock = new TitlePlayback(); + $clock->advance(0, new DateTimeImmutable('2026-09-22 12:00:00'), false); + advanceTitleTestClock($clock, 0, 0.3, '2026-09-22 12:00:00'); + $commands = []; + foreach (['New Game', 'Load Game', 'Options', 'Credits', 'Exit'] as $index => $label) { + $commands[] = new MenuRow('command-' . $index, $label, kind: MenuRowKind::BUTTON, + selected: $index === 1, focused: $index === 1, disabled: $index === 1); + } + $frame = TitleMenuPresentation::compose($catalog, $clock, $commands); + foreach ($commands as $command) { expect(getTitleTestText($frame))->toContain($command->label); } + foreach ($frame->images as $image) { expect($image->id)->not->toContain('cursor'); } + foreach ($frame->textLayers as $layer) { + foreach ($layer->runs as $run) { + if ($run->foreground === null || !in_array($run->text, array_column($commands, 'label'), true)) { continue; } + $center = $layer->x + ($run->column + mb_strlen($run->text) / 2) * $layer->grid->cellWidth; + expect($center)->toBe(675.0); + } + } + $elapsed = $clock->elapsed; + $again = TitleMenuPresentation::compose($catalog, $clock, $commands); + expect($again)->toEqual($frame)->and($clock->elapsed)->toBe($elapsed); + $destination = TitleMenuPresentation::compose($catalog, $clock, $commands, false); + expect(array_column($destination->images, 'id'))->not->toContain('title-logo')->and($destination->textLayers)->toBe([]); +}); + +it('uses current artwork dimensions and correct bird phase fade and reduced-motion frame zero', function () { + $data = getTitleTestData(); + $sprite = $data['scenes']['day']['sprites']['bird']; + $bird = TitleSpritePresentation::getImage($this->root, 'bird', $sprite, 0, 1, false); + expect($bird->destination->x)->toBeGreaterThan(998)->and($bird->opacity)->toBeGreaterThan(0.63)->toBeLessThan(0.65); + expect($bird->sourceRect->x)->toBe(30); + expect(TitleSpritePresentation::getImage($this->root, 'bird', $sprite, 2, 1, false))->toBeNull(); + expect(TitleSpritePresentation::getImage($this->root, 'bird', $sprite, 0, 1, true))->toBeNull(); + $banner = $data['scenes']['day']['sprites']['banner']; + $original = TitleSpritePresentation::getImage($this->root, 'banner', $banner, 1, 1, true); + expect($original->sourceRect->x)->toBe(0)->and($original->sourceRect->width)->toBe(10); + writeTitleTestPng($this->root . '/banner.png', 73, 49); + $replacement = TitleSpritePresentation::getImage($this->root, 'banner', $banner, 1, 1, true); + expect($replacement->sourceRect->width)->toBe(12)->and($replacement->destination)->toEqual($original->destination); +}); + +it('keeps logo registration unchanged across shimmer boundaries and replacement artwork', function (int $width, int $height) { + writeTitleTestPng($this->root . '/logo.png', $width, $height); + $data = getTitleTestData(); + $data['gleam'] = [ + 'delay' => 1, 'period' => 4, 'duration' => 0.5, 'overscan' => 100, 'bandWidth' => 96, 'tilt' => 0.2, + 'regions' => [[0, 0, 1, 1]], + 'stops' => [ + ['offset' => 0, 'color' => [255, 245, 208], 'opacity' => 0], + ['offset' => 0.5, 'color' => [255, 252, 228], 'opacity' => 0.66], + ['offset' => 1, 'color' => [255, 245, 208], 'opacity' => 0], + ], + ]; + $catalog = new TitlePresentationCatalog($this->root, $data); + $clock = new TitlePlayback(); + $local = '2026-09-22 12:00:00'; + $clock->advance(0, new DateTimeImmutable($local), false); + $previous = 0.0; + $resting = null; + foreach ([[0.99, false], [1.01, true], [1.25, true], [1.51, false], + [4.99, false], [5.01, true], [5.25, true], [5.51, false]] as [$time, $active]) { + advanceTitleTestClock($clock, $previous, $time, $local); + $previous = $time; + $frame = TitleMenuPresentation::compose($catalog, $clock, []); + $logos = array_values(array_filter([...$frame->images, ...$frame->composites], + static fn($image) => $image->id === 'title-logo')); + expect($logos)->toHaveCount(1); + $logo = $logos[0]; + $resting ??= $logo; + expect($logo)->toBeInstanceOf($active ? CanvasComposite::class : CanvasImage::class) + ->and($logo->destination)->toEqual($resting->destination) + ->and($logo->layer)->toBe($resting->layer) + ->and($logo->opacity)->toBe(1.0); + if ($logo instanceof CanvasComposite) { + expect($logo->width)->toBe((int)ceil($resting->destination->width)) + ->and($logo->height)->toBe((int)ceil($resting->destination->height)); + } + } + $clock->advance($previous + 0.01, new DateTimeImmutable($local), true); + $reduced = TitleMenuPresentation::compose($catalog, $clock, []); + $logo = array_values(array_filter($reduced->images, static fn($image) => $image->id === 'title-logo'))[0]; + expect($logo->destination)->toEqual($resting->destination); +})->with([[61, 41], [97, 53], [53, 97]]); + +it('supports a distinct image-backed title theme without changing button geometry or label alignment', function () { + $data = getTitleTestData(); + $data['theme']['colors'] = ['text' => [30, 40, 50], 'panel' => [230, 240, 210], 'accent' => [140, 40, 10]]; + $data['theme']['metrics'] = ['cellWidth' => 12, 'cellHeight' => 26]; + foreach (['normal', 'selected', 'disabled', 'focus'] as $role) { + writeTitleTestPng($this->root . '/' . $role . '.png', 61, 41); + $data['theme']['rowArtwork']['command.' . $role] = ['asset' => $role . '.png', 'cuts' => [6, 6, 6, 6]]; + } + $catalog = new TitlePresentationCatalog($this->root, $data); + $clock = new TitlePlayback(); + $clock->advance(0, new DateTimeImmutable('2026-09-22 12:00:00'), true); + $commands = []; + foreach (['New Game', 'Load Game', 'Options', 'Credits', 'Exit'] as $index => $label) { + $commands[] = new MenuRow('command-' . $index, $label, kind: MenuRowKind::BUTTON, + selected: $index === 2, focused: $index === 2, disabled: $index === 1); + } + $frame = TitleMenuPresentation::compose($catalog, $clock, $commands); + expect(array_column($frame->images, 'asset'))->toContain('normal.png', 'selected.png', 'disabled.png', 'focus.png'); + foreach ($frame->images as $image) { expect($image->id)->not->toContain('cursor'); } + $labels = []; + foreach ($frame->textLayers as $layer) { + foreach ($layer->runs as $run) { + if ($run->foreground === null || !in_array($run->text, array_column($commands, 'label'), true)) { continue; } + $labels[] = $run->text; + $center = $layer->x + ($run->column + mb_strlen($run->text) / 2) * $layer->grid->cellWidth; + expect($center)->toBe(675.0); + expect($run->foreground)->toEqual($catalog->theme->colors[$run->text === 'Load Game' ? 'disabled' : 'text']); + } + } + expect($labels)->toBe(array_column($commands, 'label')); + $selected = array_values(array_filter($frame->images, fn($image) => $image->asset === 'selected.png')); + expect(min(array_map(fn($image) => $image->destination->x, $selected)))->toBe(531.0); + expect(max(array_map(fn($image) => $image->destination->x + $image->destination->width, $selected)))->toBe(819.0); + expect(min(array_map(fn($image) => $image->destination->y, $selected)))->toBe(498.0); + expect(max(array_map(fn($image) => $image->destination->y + $image->destination->height, $selected)))->toBe(542.0); +}); + +it('admits plain replaceable catalogs and rejects invalid paths registrations and budgets', function () { + expect(TitlePresentationCatalog::load($this->root))->toBeNull(); + mkdir($this->root . '/Data/Presentation', 0777, true); + file_put_contents($this->root . '/' . TitlePresentationCatalog::FILE, 'root))->toBe(TitlePresentationCatalog::CAPABILITIES); + expect(TitlePresentationCatalog::load($this->root))->toBeInstanceOf(TitlePresentationCatalog::class); + foreach ([['logo' => '../outside.png'], ['logoPlacement' => [0, 700, 532]], ['buttons' => [0, 0, 1000, 44, 8]], + ['scenes' => ['day' => ['background' => 'day.png']]], ['schema' => 'bad']] as $changes) { + expect(fn() => new TitlePresentationCatalog($this->root, array_replace(getTitleTestData(), $changes)))->toThrow(InvalidArgumentException::class); + } + $oversized = getTitleTestData(); + $oversized['scenes']['day']['sprites'] = array_fill(0, 33, $oversized['scenes']['day']['sprites']['banner']); + expect(fn() => new TitlePresentationCatalog($this->root, $oversized))->toThrow(InvalidArgumentException::class); + $invalid = getTitleTestData(); + $invalid['scenes']['day']['sprites']['banner']['frameSeconds'] = '0.2'; + expect(fn() => new TitlePresentationCatalog($this->root, $invalid))->toThrow(InvalidArgumentException::class); +}); + +it('shows all five save cards including empty incompatible and long-duration records with full-card selection', function () { + $theme = new MenuPresentationCatalog($this->root, getTitleTestData()['theme']); + $slots = [new SaveSlot(1, '', false, 'Happyville', 'Kaelion', 6, 360000), SaveSlot::empty(2, ''), + SaveSlot::incompatible(3, '', 'Old format'), SaveSlot::empty(4, ''), SaveSlot::empty(5, '')]; + $frame = SaveLoadMenuPresentation::compose($slots, 2, $theme, new MenuInfoText()); + $text = getTitleTestText($frame); + foreach (['File 1', 'File 2', 'File 3', 'File 4', 'File 5', 'Happyville', 'Kaelion Lv 6', '100:00:00', 'Old format'] as $label) { + expect($text)->toContain($label); + } + $selection = array_find($frame->textLayers, fn($layer) => $layer->id === 'save-slot-3-location-selected'); + expect($selection)->not->toBeNull()->and($selection->clipRect->height)->toBe(92.0); + expect($slots[2]->isLoadable)->toBeFalse(); +}); + +it('wraps long save details and keeps focused records in view without truncation', function () { + $theme = new MenuPresentationCatalog($this->root, getTitleTestData()['theme']); + $slots = array_map(fn($i) => new SaveSlot($i, '', false, str_repeat('Faraway meadow ', 15), + str_repeat('Companion ', 10), 99), range(1, 5)); + $frame = SaveLoadMenuPresentation::compose($slots, 4, $theme, new MenuInfoText(), 'Cannot load this file.'); + expect(getTitleTestText($frame))->toContain('File 5', 'Cannot load this file.')->not->toContain('...'); +}); + +it('shares settings controls while retaining exactly seven title options and a focused Back button', function () { + $manager = new TitleOptionsSettingsManager(); + $settings = $manager->getOptions(); + $theme = new MenuPresentationCatalog($this->root, getTitleTestData()['theme']); + foreach ([0, 7] as $active) { + $content = new SettingsMenuContent('Options', $settings, array_map($manager->getCurrentChoiceIndex(...), $settings), + $active, new MenuInfoText(), backLabel: 'Back', backFocused: $active === 7, bounds: new CanvasRectangle(225, 60, 900, 600)); + $frame = SettingsMenuPresentation::compose($content, $theme); + foreach ($settings as $setting) { expect(getTitleTestText($frame))->toContain($setting->label); } + expect(getTitleTestText($frame))->toContain('Back')->not->toContain('Battle Message Pace'); + } + expect($settings)->toHaveCount(7); +}); + +it('preserves title focus through modal suspension and cancels canvas ownership on stop', function () { + $scene = new TitlePresentationSceneProbe(); + $scene->initializePresentation(); + $scene->suspend(); + $scene->resume(); + expect($scene->getSelectedIndex())->toBe(3); + $scene->releasePresentation(); + expect($scene->getPresentationCanvas())->toBeNull(); +}); diff --git a/tests/Unit/WindowActivationTest.php b/tests/Unit/WindowActivationTest.php new file mode 100644 index 00000000..d27e7b6c --- /dev/null +++ b/tests/Unit/WindowActivationTest.php @@ -0,0 +1,52 @@ + 2, 'type' => 'window_activation', 'active' => $active])); + expect($event->type)->toBe(RendererEventType::WINDOW_ACTIVATION)->and($event->active)->toBe($active); + } + foreach ([['protocol' => 1, 'type' => 'window_activation', 'active' => true], + ['protocol' => 2, 'type' => 'key', 'key' => 'up', 'active' => true], + ['protocol' => 2, 'type' => 'window_activation'], + ...array_map(fn($active) => ['protocol' => 2, 'type' => 'window_activation', 'active' => $active], [null, 1, 'true'])] as $bad) { + expect(fn() => RendererEvent::fromJson(json_encode($bad)))->toThrow(RendererProtocolException::class); + } +}); + +it('requires activation negotiation before admitting an event batch atomically', function () { + foreach ([[], ['window_activation']] as $caps) { + $transport = new FakeRendererTransport(); + $client = new RendererClient($transport); + $client->start(new RendererSessionConfig('Focus', __DIR__, protocol: RendererProtocolVersion::V2, requiredCapabilities: $caps)); + $transport->batches[] = [RendererEvent::fromJson(json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => $caps])), + RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":false}')]; + if ($caps === []) { + expect(fn() => $client->pump())->toThrow(RendererProtocolException::class) + ->and($client->drainEvents())->toBe([])->and($client->supports('window_activation'))->toBeFalse(); + } else { + $client->pump(); + expect($client->supports('window_activation'))->toBeTrue()->and($client->drainEvents()[1]->active)->toBeFalse(); + $transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":true}')]; + $client->pump(); + expect($client->drainEvents()[0]->active)->toBeTrue(); + } + } +}); + +it('rejects activation before ready even when support was requested', function () { + $transport = new FakeRendererTransport(); + $client = new RendererClient($transport); + $client->start(new RendererSessionConfig('Focus', __DIR__, protocol: RendererProtocolVersion::V2, requiredCapabilities: ['window_activation'])); + $transport->batches[] = [RendererEvent::fromJson('{"protocol":2,"type":"window_activation","active":false}')]; + expect(fn() => $client->pump())->toThrow(RendererProtocolException::class)->and($client->drainEvents())->toBe([]); +}); diff --git a/tools/gpui-frame-smoke.php b/tools/gpui-frame-smoke.php index 13ccef2e..e3e80204 100644 --- a/tools/gpui-frame-smoke.php +++ b/tools/gpui-frame-smoke.php @@ -6,8 +6,8 @@ use Ichiloto\Engine\Rendering\Presentation\PresentationSprite; use Ichiloto\Engine\Rendering\Presentation\RendererPresentation; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; -use Ichiloto\Engine\Rendering\Transport\RendererEventType; use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; diff --git a/tools/gpui-input-smoke.php b/tools/gpui-input-smoke.php index 94e3e6bb..ed42cab5 100644 --- a/tools/gpui-input-smoke.php +++ b/tools/gpui-input-smoke.php @@ -3,8 +3,8 @@ use Ichiloto\Engine\IO\InputSources\RendererInputSource; use Ichiloto\Engine\Rendering\RendererClient; +use Ichiloto\Engine\Rendering\Transport\Enumerations\RendererEventType; use Ichiloto\Engine\Rendering\Transport\ProcessRendererTransport; -use Ichiloto\Engine\Rendering\Transport\RendererEventType; use Ichiloto\Engine\Rendering\Transport\RendererProcessConfig; use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; diff --git a/tools/gpui-transport-smoke.php b/tools/gpui-transport-smoke.php index ebe861ce..79bb28ed 100644 --- a/tools/gpui-transport-smoke.php +++ b/tools/gpui-transport-smoke.php @@ -1,9 +1,9 @@ #!/usr/bin/env php