diff --git a/.ralph.json b/.ralph.json deleted file mode 100644 index 3aa99ed1..00000000 --- a/.ralph.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "model_source": "remote", - "model": { - "remote": { - "intake": "opencode/claude-opus-4.7", - "planning": "opencode/gpt-5.5", - "implementation": "opencode-go/qwen3.6-plus", - "audit": "opencode-go/glm-5.1" - }, - "local": { - "intake": "qwen3", - "planning": "qwen3", - "implementation": "qwen3", - "audit": "qwen3" - } - }, - "timeout": { - "pi_stream": { - "remote": 900, - "local": 60 - } - } -} diff --git a/.ralph/event.pending b/.ralph/event.pending deleted file mode 100644 index 2e62aa43..00000000 --- a/.ralph/event.pending +++ /dev/null @@ -1,8 +0,0 @@ -{ - "event_type": "error", - "timestamp": "2026-06-06T23:39:42.203308+00:00", - "work_item_ids": [ - "CG-0MP12WBH70045LNE" - ], - "title": "Shared Renderer" -} diff --git a/README.md b/README.md index 3366d83a..0d7e51cc 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ tableau-card-engine/ | Sushi Go! | `example-games/sushi-go/` | Card drafting game (human vs. AI). Pick and pass hands over 3 rounds, collect sets of sushi dishes, and score the most points | | Feudalism | `example-games/feudalism/` | Engine-building card game (human vs. AI). Collect gem tokens, purchase development cards for bonuses, attract nobles, and reach 15 prestige to win | | Lost Cities | `example-games/lost-cities/` | Two-player expedition card game (human vs. AI). Bet on up to 5 colored expeditions across a 3-round match with investment multipliers, ascending-play rules, and cumulative scoring | -| Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns | +| Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns. Tutorial overlay zones are defined in a separate SLL layout file (`main-street-tutorial.layout.json`) composed with the base layout. | | Scenario: Tutorial | `example-games/main-street/scenes/MainStreetTutorialScene.ts` | Guided introduction to Main Street. Non-interactive tutorial overlays walk through the market, street placement, synergies, events, and scoring. Easy difficulty, 25 turns. Accessible from the Game Selector. | More games are planned: Coloretto. diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 25192542..569f01a7 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -1345,6 +1345,105 @@ When adding a new example game, follow this pattern: - If zones/anchors are missing at runtime, look for `UNKNOWN_ZONE` / `UNKNOWN_ANCHOR` issues. - If scene behavior unexpectedly matches legacy coordinates, verify that the adapter sees a valid layout document and that the relevant zone names exist. +### Tutorial layout composition pattern + +Main Street uses a **tutorial-specific layout file** that complements the base layout with +bounding-box zones for tutorial highlight areas. This pattern allows the tutorial to define +zones that don't exist in the base scene layout (HUD strip, help button, investments row) while +reusing base layout zones through composition. + +#### File layout + +| File | Purpose | +|------|--------| +| `example-games/main-street/layouts/main-street.layout.json` | Canonical base layout (8 zones, position-only) | +| `example-games/main-street/layouts/main-street-tutorial.layout.json` | Tutorial-specific layout (7 zones, position + dimensions) | +| `example-games/main-street/scenes/MainStreetTutorialHints.ts` | Tutorial overlay manager | +| `example-games/main-street/TutorialFlow.ts` | T1-T10 step definitions with `TutorialHighlightZone` type | + +#### How composition works + +The tutorial layout is composed with the base layout using `composeResolvedLayouts()`: + +```typescript +import { composeResolvedLayouts } from '@ui'; +import type { ScreenLayoutDocument } from '@ui'; + +// Load both layout documents +const baseDoc = parseScreenLayoutDocument(baseLayoutJson) as ScreenLayoutDocument; +const tutorialDoc = parseScreenLayoutDocument(tutorialLayoutJson) as ScreenLayoutDocument; + +// Compose with sceneWins policy (tutorial zones override base zones on collision) +const resolved = composeResolvedLayouts( + baseDoc, + tutorialDoc, + { width: 1280, height: 720 }, // viewport + 1, // DPR + { policy: 'sceneWins' }, +); + +// Access tutorial-specific zones +const hudRect = resolved.zones.hud.rect; // { x, y, width, height } +const streetRect = resolved.zones.streetGrid.rect; + +// Access base zones alongside tutorial zones +const marketRect = resolved.zones.market.rect; // still available from base +``` + +#### Tutorial zone names + +The tutorial layout defines these zones (all use normalized coordinates with optional `w`/`h` dimensions): + +| Zone ID | Description | Uses dimensions | +|---------|-------------|-----------------| +| `hud` | HUD strip (top bar with coins, reputation, score) | Yes (full-width bounding box) | +| `marketBusinessRow` | Business card row in the market area | Yes | +| `streetGrid` | The 2×5 street grid for placing businesses | Yes (full-width) | +| `endTurnButton` | End Turn action button area | Yes | +| `incidentQueue` | Scrollable incident cards queue | Yes | +| `investmentsRow` | Investment/upgrade card row | Yes | +| `helpButton` | Help/settings button area | Yes | + +Zones that return `null` for highlighting (no bounding box needed): +- `center-modal` — centered overlay +- `completion-modal` — centered completion dialog + +#### Schema extension for dimensions + +The `NormalizedRect` type and JSON Schema were extended with optional `w` (width) and `h` (height) +fields. These are **fully backward-compatible** — existing position-only zones continue to work +without modification. When `w` and `h` are present, `getZoneRect()` returns a `PixelRect` with +`width` and `height` set. + +```typescript +// Position-only (existing pattern) +interface PositionOnlyRect { + x: number; // 0-1 normalized + y: number; // 0-1 normalized +} + +// Dimensioned (new pattern for bounding boxes) +interface DimensionedRect { + x: number; + y: number; + w?: number; // optional width (0-1 normalized) + h?: number; // optional height (0-1 normalized) +} +``` + +#### Authoring a tutorial layout + +When creating a new tutorial layout file: + +1. **Copy the base layout** structure (`version`, `id`, `baseViewport`, `requiredZones`) +2. **Define only the zones needed** for tutorial highlights (you don't need all base zones) +3. **Include `w` and `h`** for all zones that need bounding-box dimensions +4. **Use normalized coordinates** (0-1) — resolution is handled at runtime by `normalizedToPixels()` +5. **Add anchors** for each zone (used for tooltip positioning relative to the zone) +6. **Validate** with `validateScreenLayoutDocument()` and `composeResolvedLayouts()` before committing + +See `example-games/main-street/layouts/main-street-tutorial.layout.json` for a complete example. + ### Related follow-up scope - Tutorial-specific layout migration remains tracked separately in work item **Adapt tutorial system to use layout description (CG-0MP7IZ4RK008065O)**. diff --git a/example-games/feudalism/scenes/FeudalismRenderer.ts b/example-games/feudalism/scenes/FeudalismRenderer.ts index 7693bca1..9ee5ff02 100644 --- a/example-games/feudalism/scenes/FeudalismRenderer.ts +++ b/example-games/feudalism/scenes/FeudalismRenderer.ts @@ -14,9 +14,10 @@ import { import type { FeudalismSession } from '../FeudalismGame'; import { getInfluence, getBonuses } from '../FeudalismGame'; import { addCropIcon, cssColorToNumber } from './CropIconRenderer'; -import { FONT_FAMILY, GAME_W, createOverlayBackground } from '../../../src/ui'; +import { FONT_FAMILY, GAME_W, GAME_H, createOverlayBackground } from '../../../src/ui'; import type { SingleSelectionManager, SelectionController } from '../../../src/ui'; import { attachSelection, createSingleSelectionManager } from '../../../src/ui'; +import { createGameZone } from '../../../src/ui/Renderer'; import { PATRON_W, PATRON_H, PATRON_X, SUPPLY_TOKEN_R, SUPPLY_GAP, SUPPLY_TOTAL_H, SUPPLY_X, SUPPLY_Y, @@ -101,14 +102,14 @@ export class FeudalismRenderer { // ── Init ──────────────────────────────────────────────── createContainers(): void { - this.sectionBoxContainer = this.scene.add.container(0, 0); - this.marketContainer = this.scene.add.container(0, 0); - this.patronContainer = this.scene.add.container(0, 0); - this.supplyContainer = this.scene.add.container(0, 0); - this.playerContainer = this.scene.add.container(0, 0); - this.aiContainer = this.scene.add.container(0, 0); - this.actionContainer = this.scene.add.container(0, 0); - this.discardContainer = this.scene.add.container(0, 0); + this.sectionBoxContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'sectionBoxContainer'); + this.marketContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'marketContainer'); + this.patronContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'patronContainer'); + this.supplyContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'supplyContainer'); + this.playerContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'playerContainer'); + this.aiContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'aiContainer'); + this.actionContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'actionContainer'); + this.discardContainer = createGameZone(this.scene, 0, 0, GAME_W, GAME_H, 'discardContainer'); this.marketSelectionManager = createSingleSelectionManager(this.scene); } diff --git a/example-games/main-street/README.md b/example-games/main-street/README.md index dd726864..96e79cb4 100644 --- a/example-games/main-street/README.md +++ b/example-games/main-street/README.md @@ -5,6 +5,10 @@ Main Street now uses the shared **Screen Layout Language (SLL)** as its canonica ## Layout files and adapter - Canonical layout JSON: `example-games/main-street/layouts/main-street.layout.json` +- Tutorial layout JSON: `example-games/main-street/layouts/main-street-tutorial.layout.json` + - Defines 7 bounding-box zones for tutorial highlight areas (HUD, market, street, etc.) + - Uses optional `w`/`h` dimensions on `NormalizedRect` for zone extents + - Composed with the base layout via `composeResolvedLayouts()` in the tutorial system - Scene adapter: `example-games/main-street/scenes/MainStreetLayoutAdapter.ts` - Renderer entrypoint: `example-games/main-street/scenes/MainStreetRenderer.ts` @@ -35,9 +39,14 @@ npx vitest run tests/e2e/replay-main-street.e2e.test.ts --project unit ## Follow-up work -Tutorial-specific layout migration is tracked separately in: +The tutorial overlay system (`MainStreetTutorialHints.ts`) currently uses `zoneToAnchor()` with +per-zone pixel-math to compute highlight bounding boxes. A follow-up work item tracks migrating +this to resolve zones directly through the composed SLL layout: - **Adapt tutorial system to use layout description (CG-0MP7IZ4RK008065O)** + - Will refactor `zoneToAnchor()` to use `composeResolvedLayouts(baseLayout, tutorialLayout)` + - Replaces hardcoded pixel-math with SLL-resolved bounding boxes + - Zone names align with those in `main-street-tutorial.layout.json` ## Milestone 5: Tutorial, Onboarding, and Game Selector Integration diff --git a/example-games/main-street/TutorialFlow.ts b/example-games/main-street/TutorialFlow.ts index eaa0ad90..da7276da 100644 --- a/example-games/main-street/TutorialFlow.ts +++ b/example-games/main-street/TutorialFlow.ts @@ -14,17 +14,38 @@ /** * The zone of the screen that should be highlighted for a given step. + * + * @deprecated These zone names are transitional. They are currently used by + * `MainStreetTutorialHints.zoneToAnchor()` to compute bounding-box coordinates + * for tutorial highlight overlays. During the SLL migration (CG-0MP7IZ4RK008065O) + * these kebab-case values will be replaced by camelCase SLL zone IDs from + * `main-street-tutorial.layout.json` and resolution will switch to direct SLL + * lookups via `composeResolvedLayouts()` + `getZoneRect()`. + * + * Zone name mapping (transitional kebab-case → SLL camelCase): + * + * | TutorialHighlightZone | SLL tutorial zone ID | + * |----------------------|---------------------| + * | `hud` | `hud` | + * | `market-business-row` | `marketBusinessRow` | + * | `street-grid` | `streetGrid` | + * | `end-turn-button` | `endTurnButton` | + * | `incident-queue` | `incidentQueue` | + * | `investments-row` | `investmentsRow` | + * | `help-button` | `helpButton` | + * | `center-modal` | _(null — no highlight)_ | + * | `completion-modal` | _(null — no highlight)_ | */ export type TutorialHighlightZone = - | 'center-modal' + | 'centerModal' | 'hud' - | 'market-business-row' - | 'street-grid' - | 'end-turn-button' - | 'incident-queue' - | 'investments-row' - | 'help-button' - | 'completion-modal'; + | 'marketBusinessRow' + | 'streetGrid' + | 'endTurnButton' + | 'incidentQueue' + | 'investmentsRow' + | 'helpButton' + | 'completionModal'; /** * The type of player action expected to complete a step. @@ -66,7 +87,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Welcome to Main Street', body: 'Build the best Main Street in 20 turns. I\'ll guide your first few actions.', - highlightZone: 'center-modal', + highlightZone: 'centerModal', requiredAction: 'confirm', }, { @@ -82,7 +103,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Market Rows', body: 'Businesses go on your street. Investments are upgrades and events that shape your strategy.', - highlightZone: 'market-business-row', + highlightZone: 'marketBusinessRow', requiredAction: 'select-business', }, { @@ -90,7 +111,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Place a Business', body: 'Place this business in a highlighted slot. Adjacent matching types create synergy bonuses.', - highlightZone: 'street-grid', + highlightZone: 'streetGrid', requiredAction: 'place-business', }, { @@ -98,7 +119,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'End Turn', body: 'End Turn resolves income and incidents, then starts a new market day.', - highlightZone: 'end-turn-button', + highlightZone: 'endTurnButton', requiredAction: 'end-turn', }, { @@ -106,7 +127,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Incident Queue', body: 'Incidents are upcoming events. Watch this queue to plan ahead.', - highlightZone: 'incident-queue', + highlightZone: 'incidentQueue', requiredAction: 'acknowledge-queue', }, { @@ -114,7 +135,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Held Event Card', body: 'You can hold one event card and play it when timing is best.', - highlightZone: 'investments-row', + highlightZone: 'investmentsRow', requiredAction: 'buy-event', }, { @@ -122,7 +143,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Upgrade Concept', body: 'Upgrades improve an existing business. Strong upgrades compound over remaining turns.', - highlightZone: 'investments-row', + highlightZone: 'investmentsRow', requiredAction: 'apply-upgrade', }, { @@ -130,7 +151,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Help + Hint Tools', body: 'Need a refresher? Open Help anytime. Hint suggests one strong move per turn.', - highlightZone: 'help-button', + highlightZone: 'helpButton', requiredAction: 'open-help', }, { @@ -138,7 +159,7 @@ export const TUTORIAL_STEP_DEFS: readonly TutorialStepDef[] = [ title: 'Tutorial Complete', body: 'Great job! You\'re ready for a full run. Tutorial can be replayed from menu/settings.', - highlightZone: 'completion-modal', + highlightZone: 'completionModal', requiredAction: 'confirm-complete', }, ] as const; diff --git a/example-games/main-street/layouts/main-street-tutorial.layout.json b/example-games/main-street/layouts/main-street-tutorial.layout.json new file mode 100644 index 00000000..c93a9a74 --- /dev/null +++ b/example-games/main-street/layouts/main-street-tutorial.layout.json @@ -0,0 +1,96 @@ +{ + "version": 1, + "id": "main-street-tutorial", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "hud", + "marketBusinessRow", + "streetGrid", + "endTurnButton", + "incidentQueue", + "investmentsRow", + "helpButton" + ], + "zones": { + "hud": { + "rect": { + "x": 0, + "y": 0.05, + "w": 1, + "h": 0.038889 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.05 } + } + }, + "marketBusinessRow": { + "rect": { + "x": 0.015625, + "y": 0.111111, + "w": 0.575, + "h": 0.302778 + }, + "anchors": { + "topCenter": { "x": 0.5, "y": 0.125 } + } + }, + "streetGrid": { + "rect": { + "x": 0, + "y": 0.601389, + "w": 1, + "h": 0.255556 + }, + "anchors": { + "topCenter": { "x": 0.5, "y": 0.586111 } + } + }, + "endTurnButton": { + "rect": { + "x": 0.85625, + "y": 0.894444, + "w": 0.125, + "h": 0.058333 + }, + "anchors": { + "center": { "x": 0.926563, "y": 0.954167 } + } + }, + "incidentQueue": { + "rect": { + "x": 0.015625, + "y": 0.436111, + "w": 0.329688, + "h": 0.133333 + }, + "anchors": { + "topLeft": { "x": 0.085938, "y": 0.444444 } + } + }, + "investmentsRow": { + "rect": { + "x": 0.015625, + "y": 0.269444, + "w": 0.575, + "h": 0.130556 + }, + "anchors": { + "topCenter": { "x": 0.5, "y": 0.25 } + } + }, + "helpButton": { + "rect": { + "x": 0.90625, + "y": 0.894444, + "w": 0.078125, + "h": 0.058333 + }, + "anchors": { + "center": { "x": 0.890625, "y": 0.954167 } + } + } + } +} diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index 724dbe1b..6674915a 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -30,7 +30,11 @@ import { markHudTransient, clearTransientHud, } from '../../../src/ui'; -import { createSceneTitle, createSceneMenuButton } from '@ui/Renderer'; +import { + createSceneTitle, + createSceneMenuButton, + createGameZone, +} from '@ui/Renderer'; import { createActionButton } from '@ui/Renderer'; import { attachHudTooltipZone, @@ -79,7 +83,7 @@ export class MainStreetRenderer { public createContainers(): void { const s = this.scene; - s.hudContainer = s.add.container(0, 0); + s.hudContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'hudContainer'); // Ensure HUD container renders above gameplay containers by default. try { s.hudContainer.setDepth(1000); } catch (_) { /* ignore in tests */ } @@ -94,20 +98,34 @@ export class MainStreetRenderer { (s as any).hudOverlayContainer = s.hudContainer; } catch (_) { (s as any).hudOverlayContainer = undefined; } - s.streetContainer = s.add.container(0, 0); - s.marketContainer = s.add.container(0, 0); - s.incidentQueueContainer = s.add.container(0, 0); - s.handContainer = s.add.container(0, 0); - s.actionContainer = s.add.container(0, 0); + s.streetContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'streetContainer'); + s.marketContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'marketContainer'); + s.incidentQueueContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'incidentQueueContainer'); + s.handContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'handContainer'); + s.actionContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'actionContainer'); // Ensure depth ordering is applied after container creation. try { s.children?.depthSort?.(); } catch (_) { /* ignore */ } // Challenge Tracker panel - s.challengeContainer = s.add.container(s.layout.challengeX, s.layout.challengeY); + s.challengeContainer = createGameZone( + s, + s.layout.challengeX, + s.layout.challengeY, + s.layout.challengeW, + 0, + 'challengeContainer', + ); // Activity Log panel (persistent, not rebuilt each refresh) - s.logContainer = s.add.container(s.layout.logX, s.layout.logY); + s.logContainer = createGameZone( + s, + s.layout.logX, + s.layout.logY, + s.layout.logW, + s.layout.logH, + 'logContainer', + ); // Panel background const bg = s.add.graphics(); diff --git a/example-games/main-street/scenes/MainStreetTutorialHints.ts b/example-games/main-street/scenes/MainStreetTutorialHints.ts index b51b046f..5a3a01bf 100644 --- a/example-games/main-street/scenes/MainStreetTutorialHints.ts +++ b/example-games/main-street/scenes/MainStreetTutorialHints.ts @@ -18,6 +18,9 @@ */ import { FONT_FAMILY } from '../../../src/ui'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import { composeResolvedLayouts } from '../../../src/ui/screen-layout-compose'; +import { type LayoutViewport } from '../../../src/ui/screen-layout'; import { MARKET_BUSINESS_SLOTS, INCIDENT_QUEUE_SIZE } from '../MainStreetCards'; import { TUTORIAL_STEP_DEFS, @@ -25,6 +28,74 @@ import { type TutorialControllerState, type TutorialHighlightZone, } from '../TutorialFlow'; +import baseLayout from '../layouts/main-street.layout.json'; +import tutorialLayout from '../layouts/main-street-tutorial.layout.json'; + +// ── Pre-parse layouts at module load ────────────────────────── + +const baseParsed = parseScreenLayoutDocument(baseLayout); +if (!baseParsed.valid) { + throw new Error( + `Base layout is invalid: ${baseParsed.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, + ); +} + +const tutorialParsed = parseScreenLayoutDocument(tutorialLayout); +if (!tutorialParsed.valid) { + throw new Error( + `Tutorial layout is invalid: ${tutorialLayout ? tutorialLayout.id : '(unknown)'}: ${tutorialParsed.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, + ); +} + +const BASE_LAYOUT = baseParsed.layout; +const TUTORIAL_LAYOUT = tutorialParsed.layout; + +/** Null-zone values that do not need a highlight bounding box. */ +const NULL_ZONES: ReadonlySet = new Set([ + 'centerModal', + 'completionModal', +]); + +/** + * Resolve a tutorial highlight zone to pixel-space coordinates using SLL. + * + * Composes the base Main Street layout with the tutorial-specific layout + * (using `sceneWins` policy so tutorial zones override base zones where names + * collide), then looks up the requested zone in the composed result. + * + * Returns `{ x, y, w, h }` for known zones, or `null` for centered overlays + * (centerModal, completionModal) and unrecognized zones. + */ +function resolveZoneToAnchor( + zone: TutorialHighlightZone, + viewport: LayoutViewport, + dpr = 1, +): { x: number; y: number; w: number; h: number } | null { + if (NULL_ZONES.has(zone)) { + return null; + } + + const composed = composeResolvedLayouts( + BASE_LAYOUT, + TUTORIAL_LAYOUT, + viewport, + dpr, + { policy: 'sceneWins' }, + ); + + const resolvedZone = composed.zones[zone]; + if (!resolvedZone) { + return null; + } + + const rect = resolvedZone.rect; + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + w: Math.round(rect.width ?? 0), + h: Math.round(rect.height ?? 0), + }; +} // ── Tutorial step definitions ──────────────────────────────── @@ -639,69 +710,24 @@ export class MainStreetTutorialHints { /** * Maps a TutorialHighlightZone to screen-space coordinates. - * Returns null if the zone cannot be resolved. + * + * Resolves the highlight zone's bounding box from the composed SLL layout + * (base + tutorial layout merged via `composeResolvedLayouts`), replacing the + * previous hardcoded pixel-math. + * + * @param zone - The tutorial highlight zone identifier (camelCase SLL zone IDs). + * @param scene - The Phaser scene with layout properties. + * @returns Pixel-space bounding box `{ x, y, w, h }`, or `null` for centered + * overlays (centerModal, completionModal) and unrecognized zones. */ private zoneToAnchor( zone: TutorialHighlightZone, scene: any, ): { x: number; y: number; w: number; h: number } | null { - const l = scene.layout; - if (!l) return null; - - switch (zone) { - case 'center-modal': - return null; // overlay is already centred - case 'hud': { - // HUD strip is a Phaser rectangle centered at hudY with height 28. - // The strip's top edge is at hudY - 14. - return { x: 0, y: l.hudY - 14, w: l.gameW, h: 28 }; - } - case 'market-business-row': { - // Market has TWO rows: business (top) + investments (bottom). - // The renderer draws the section background aligned to the business row cards - // with +20px right padding. - const marketStartX = l.marketLabelW + 50; - const marketRight = marketStartX + (MARKET_BUSINESS_SLOTS - 1) * (l.marketCardW + l.marketCardGap) + l.marketCardW + 20; - return { - x: 20, - y: l.marketTop - 10, - w: marketRight - 20, - h: 2 * l.marketRowH + l.marketRowGap + 20, - }; - } - case 'street-grid': { - // Street grid spans the full width of the screen, two rows of slots. - const streetH = 2 * l.slotH + l.streetRowGap + 12; - return { x: 0, y: l.streetTop - 6, w: l.gameW, h: streetH }; - } - case 'end-turn-button': { - const rightX = l.gameW - 24; - return { x: rightX - l.actionButtonW - 20, y: l.actionY - 4, w: l.actionButtonW + 20, h: l.actionButtonH + 8 }; - } - case 'incident-queue': { - const totalW = l.queueLabelW + INCIDENT_QUEUE_SIZE * (l.queueCardW + l.queueCardGap) + 32; - return { x: 20, y: l.queueTop - 6, w: totalW, h: l.queueCardH + 16 }; - } - case 'investments-row': { - // Investments row is the second (bottom) market row. - // Uses same left alignment and right padding as business row. - const marketStartX = l.marketLabelW + 50; - const marketRight = marketStartX + (MARKET_BUSINESS_SLOTS - 1) * (l.marketCardW + l.marketCardGap) + l.marketCardW + 20; - return { - x: 20, - y: l.marketTop + l.marketRowH + l.marketRowGap, - w: marketRight - 20, - h: l.marketRowH, - }; - } - case 'help-button': { - return { x: l.gameW - 120, y: l.actionY - 4, w: 100, h: l.actionButtonH + 8 }; - } - case 'completion-modal': - return null; - default: - return null; - } + const layout = scene.layout ?? {}; + const gameW: number = layout.gameW ?? 1280; + const gameH: number = layout.gameH ?? 720; + return resolveZoneToAnchor(zone, { width: gameW, height: gameH }, 1); } // ── Private helpers ─────────────────────────────────────── diff --git a/src/ui/Renderer/index.ts b/src/ui/Renderer/index.ts index 78c591f1..bcd70059 100644 --- a/src/ui/Renderer/index.ts +++ b/src/ui/Renderer/index.ts @@ -425,3 +425,180 @@ export function createActionButton( return container; } +// --------------------------------------------------------------------------- +// Zone & Container Best Practices +// --------------------------------------------------------------------------- +// +// This section documents when and how to use createGameZone vs +// scene.add.container(), zone dimension guidelines, z-order conventions, +// and container naming patterns. The patterns below are established by the +// Main Street, Sushi Go, and Feudalism migrations. +// +// +// 1. When to use createGameZone +// ----------------------------- +// Use createGameZone for every layout-area / zone container that organises +// a distinct region of the game screen: +// +// • Street / board area • Market / shop area +// • Hand area • Action button area +// • Player tableau • AI opponent tableau +// • Discard / supply area • Incident queue / log +// • Patron / special zones • HUD container (with setDepth(1000)) +// +// These zone containers give every area of the screen a named, metadata-rich +// container (carrying __zoneWidth, __zoneHeight, and optional __zoneName) +// which makes debugging, testing, and selective refreshing straightforward. +// +// ✅ Correct – zone container: +// +// const streetZone = createGameZone(scene, 0, 0, layout.gameW, layout.gameH, 'streetContainer'); +// +// ❌ Avoid – raw scene.add.container for zone containers: +// +// const streetZone = scene.add.container(0, 0); // No zone metadata +// +// +// 2. When to keep scene.add.container() +// -------------------------------------- +// Per-card containers — individual wrappers that position a single card +// within a zone — MUST remain as raw scene.add.container() calls. These +// are transient, positioned by game logic, and do not benefit from zone +// metadata. +// +// ✅ Correct – per-card container: +// +// const cardContainer = scene.add.container(cardX, cardY); +// cardContainer.add(cardSprite); +// marketZone.add(cardContainer); +// +// ✅ Correct – per-card container in MainStreetRenderer: +// +// const cardContainer = s.add.container( +// Math.round(x + slotW / 2), +// Math.round(y + slotH / 2), +// ); +// +// Per-card containers appear in drawBusinessSlot, drawMarketCard, +// drawIncidentCard, drawHeldEventCard (Main Street), drawMarketCard +// (Feudalism), and similar per-card methods. +// +// +// 3. Zone dimension best practices +// -------------------------------- +// Zone width and height should reflect the logical bounds of the area +// the container covers. This helps with hit-testing, debugging overlays, +// and future layout tools. +// +// a) Full-screen zone +// Use GAME_W / GAME_H constants or layout dimensions: +// +// const marketZone = createGameZone(scene, 0, 0, GAME_W, GAME_H, 'marketContainer'); +// +// b) Layout-derived zone +// Compute from a layout object: +// +// const challengeZone = createGameZone( +// scene, +// layout.challengeX, +// layout.challengeY, +// layout.challengeW, +// 0, +// 'challengeContainer', +// ); +// +// c) Negative / zero dimensions +// createGameZone accepts zero or negative dimensions without error; +// they are stored as-is on the container. If you have a zone that +// will be sized later, pass 0 for width/height. +// +// +// 4. Z-order conventions +// ---------------------- +// Depth ordering should follow a consistent scheme across games: +// +// Depth Layer Examples +// ───── ───── ─────────────────────── +// 1000+ HUD / overlay hudContainer, persistent overlays +// 500+ Tooltips / popups tooltipManager panels +// 0 Gameplay containers street, market, hand, tableau, action +// < 0 Background / boards section boxes, grid lines +// +// • HUD containers: call setDepth(1000) after creation: +// +// const hud = createGameZone(scene, 0, 0, w, h, 'hudContainer'); +// hud.setDepth(1000); +// +// • Action buttons should be added to gameplay containers (depth 0) +// unless they need to float above other content (use a dedicated +// container with a higher depth). +// +// • After creating all zone containers, call depthSort() on the +// scene's children list to ensure Phaser applies depth ordering: +// +// try { scene.children?.depthSort?.(); } catch { /* ignore in tests */ } +// +// • If a game needs additional layers (e.g., a floating action panel +// above gameplay but below HUD), assign a depth between 1 and 999. +// +// • Test assertions for z-order live in the per-game ZOrder browser +// test files (tests/main-street/MainStreetZOrder.browser.test.ts, +// tests/sushi-go/SushiGoZOrder.browser.test.ts, +// tests/feudalism/FeudalismZOrder.browser.test.ts). +// +// +// 5. Container naming conventions +// ------------------------------- +// Zone names follow lowercase camelCase and should describe the zone's +// purpose, suffixed with "Container": +// +// Name Game +// ────── ──────────────── +// hudContainer Main Street +// streetContainer Main Street +// marketContainer Main Street, Feudalism +// incidentQueueContainer Main Street +// handContainer Main Street, Sushi Go +// actionContainer Main Street, Feudalism +// challengeContainer Main Street +// logContainer Main Street +// sectionBoxContainer Feudalism +// patronContainer Feudalism +// supplyContainer Feudalism +// playerContainer Feudalism +// aiContainer Feudalism +// discardContainer Feudalism +// playerTableauContainer Sushi Go +// aiTableauContainer Sushi Go +// +// +// 6. Transient vs persistent children +// ------------------------------------ +// Use markHudTransient() to tag HUD elements that are rebuilt every +// refresh cycle (score text, coin counts, background strips). Use +// clearTransientHud() to remove only those tagged children while +// leaving persistent elements (help panels, settings buttons) intact. +// +// See the markHudTransient / clearTransientHud JSDoc above for details +// and examples. +// +// +// 7. Summary checklist +// -------------------- +// When adding a new container to a game scene: +// +// [ ] Is this a layout zone (street, market, hand, action area, etc.)? +// → Use createGameZone with a descriptive name. +// +// [ ] Is this a per-card wrapper for a single card within a zone? +// → Use scene.add.container(x, y) — no zone metadata needed. +// +// [ ] Does this zone need to appear above / below other zones? +// → Set depth explicitly. HUD goes at 1000, gameplay at 0. +// +// [ ] Are the zone dimensions derived from layout constants or scene size? +// → Pass actual width/height; avoid 0 unless the zone is sized later. +// +// [ ] Does the container hold ephemeral content rebuilt each frame/turn? +// → Tag children with markHudTransient() and use clearTransientHud(). + diff --git a/src/ui/screen-layout-schema.ts b/src/ui/screen-layout-schema.ts index c4562543..f27b49fb 100644 --- a/src/ui/screen-layout-schema.ts +++ b/src/ui/screen-layout-schema.ts @@ -17,16 +17,21 @@ export interface NormalizedPoint { } /** - * Position-only zone rectangle in normalized (0-1) coordinates. + * Zone rectangle in normalized (0-1) coordinates. * - * Layout zones define **positioning only** (x, y). Card dimensions come - * entirely from per-game constants, not from layout zones. The optional - * `pixelOverride` provides exact pixel-position overrides for the anchor - * point (x, y only — no dimensions). + * Supports both **position-only** zones (x, y only) for traditional + * SLL consumers and **dimensioned** zones (x, y, w, h) for bounding-box + * use cases such as tutorial highlight areas. Card dimensions from + * per-game constants remain fully supported. + * + * The optional `pixelOverride` provides an exact pixel-position override + * for the top-left corner (x, y only — no dimensions). */ export interface NormalizedRect { x: number; y: number; + w?: number; + h?: number; pixelOverride?: PixelPoint; } @@ -106,6 +111,8 @@ export const SCREEN_LAYOUT_SCHEMA = { properties: { x: { type: 'number', minimum: 0, maximum: 1 }, y: { type: 'number', minimum: 0, maximum: 1 }, + w: { type: 'number', minimum: 0, maximum: 1 }, + h: { type: 'number', minimum: 0, maximum: 1 }, pixelOverride: { type: 'object', additionalProperties: false, @@ -187,7 +194,7 @@ export function validateScreenLayoutDocument( } } - // Position-only zones have no width/height to validate for overflow. + // Dimensioned zones: w and h are validated by the schema (minimum: 0). // Normalized x and y are already constrained to [0, 1] by the schema. return { diff --git a/src/ui/screen-layout.ts b/src/ui/screen-layout.ts index bef5437f..b34c97a7 100644 --- a/src/ui/screen-layout.ts +++ b/src/ui/screen-layout.ts @@ -2,6 +2,7 @@ import type { NormalizedPoint, NormalizedRect, PixelPoint, + PixelRect, ScreenLayoutDocument, } from './screen-layout-schema'; @@ -11,7 +12,7 @@ export interface LayoutViewport { } export interface ResolvedZone { - rect: PixelPoint; + rect: PixelRect; anchors: Record; } @@ -77,30 +78,46 @@ function reportIssue( } /** - * Resolve a position-only NormalizedRect to pixel coordinates. + * Resolve a NormalizedRect to pixel coordinates. * - * Zones define positioning only (x, y) — card dimensions come from - * per-game constants, not from layout zones. + * If `w` and `h` are present on the normalized rect, the result includes + * corresponding `width` and `height` pixel values. If absent, `width` and + * `height` are `undefined`, matching the traditional position-only zone + * behaviour. */ function resolveRect( rect: NormalizedRect, viewport: LayoutViewport, baseViewport: ScreenLayoutDocument['baseViewport'], dpr: number, -): PixelPoint { +): PixelRect { if (rect.pixelOverride) { const scaleX = (viewport.width * dpr) / baseViewport.width; const scaleY = (viewport.height * dpr) / baseViewport.height; - return { + const result: PixelRect = { x: rect.pixelOverride.x * scaleX, y: rect.pixelOverride.y * scaleY, }; + if (rect.w !== undefined) { + result.width = toPixels(rect.w, viewport.width, dpr); + } + if (rect.h !== undefined) { + result.height = toPixels(rect.h, viewport.height, dpr); + } + return result; } - return { + const result: PixelRect = { x: toPixels(rect.x, viewport.width, dpr), y: toPixels(rect.y, viewport.height, dpr), }; + if (rect.w !== undefined) { + result.width = toPixels(rect.w, viewport.width, dpr); + } + if (rect.h !== undefined) { + result.height = toPixels(rect.h, viewport.height, dpr); + } + return result; } /** @@ -131,6 +148,27 @@ function resolveAnchor( }; } +/** + * Resolve a full SLL layout document into pixel-space coordinates. + * + * Converts all zone rectangles and anchor points from normalized (0-1) + * coordinates to absolute pixel values based on the provided viewport + * and device pixel ratio. + * + * ### Dimension support + * + * Zone rectangles may include optional `w` (width) and `h` (height) + * fields. When present, the resulting {@link ResolvedZone.rect} will + * contain corresponding `width` and `height` values in pixels. When + * absent, `width` and `height` are `undefined`, preserving the + * traditional position-only zone behaviour for backward-compatible + * consumers. + * + * @param layout - The validated SLL layout document to resolve. + * @param viewport - The current viewport dimensions (logical pixels). + * @param dpr - Device pixel ratio, defaults to `1`. + * @returns A fully resolved layout with pixel-space zones and anchors. + */ export function normalizedToPixels( layout: ScreenLayoutDocument, viewport: LayoutViewport, @@ -175,8 +213,9 @@ export function normalizedToPixels( /** * Convert a pixel point back to normalized (0-1) coordinates. * - * Note: this is a position-only conversion. Layout zones do not carry - * dimensions — card sizes come from per-game constants. + * Returns a position-only NormalizedRect. Layout zones may carry + * optional dimensions (w, h), but this function focuses on position + * conversion only. */ export function pixelToNormalized( point: PixelPoint, @@ -190,10 +229,12 @@ export function pixelToNormalized( } /** - * Get the resolved pixel position for a layout zone. + * Get the resolved pixel rectangle for a layout zone. * - * Returns a PixelPoint (x, y) — zones are position-only. Card dimensions - * should come from per-game constants, not from layout zones. + * Returns a PixelRect with `x`/`y` always set. `width`/`height` are set + * when the zone defines `w`/`h` (optional dimension support); otherwise + * they are `undefined`, matching the traditional position-only zone + * behaviour. */ export function getZoneRect( layout: ScreenLayoutDocument, @@ -201,7 +242,7 @@ export function getZoneRect( viewport: LayoutViewport, dpr = 1, reportIssueHook?: ScreenLayoutIssueReporter, -): PixelPoint { +): PixelRect { const resolved = normalizedToPixels(layout, viewport, dpr); const zone = resolved.zones[zoneName]; diff --git a/tests/feudalism/FeudalismZOrder.browser.test.ts b/tests/feudalism/FeudalismZOrder.browser.test.ts new file mode 100644 index 00000000..f7cf5dbc --- /dev/null +++ b/tests/feudalism/FeudalismZOrder.browser.test.ts @@ -0,0 +1,192 @@ +/** + * Feudalism z-order browser tests. + * + * Validates that Feudalism's container depth ordering follows the expected + * convention: HUD / overlay elements > gameplay containers > UI overlay. + * + * Feudalism does NOT use explicit depth values on its gameplay containers + * (patron, market, supply, player, AI, action, discard) — it relies on + * Phaser's default creation-order depth sorting. The overlay system + * assigns depth 10–20 to its elements. + * + * Expected ordering (bottom → top): + * 1. sectionBoxContainer – background section boxes + * 2. marketContainer – market card displays + * 3. patronContainer – patron cards + * 4. supplyContainer – resource supply tokens + * 5. playerContainer – player area + * 6. aiContainer – AI area + * 7. actionContainer – action buttons + * 8. discardContainer – discard area + * 9. Overlay elements (depth 10–20) + * 10. HUD elements (depth ≥ 1000, when implemented) + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import Phaser from 'phaser'; +import { waitForScene } from '../helpers/waitForScene'; + +async function bootGame(): Promise { + let container = document.getElementById('game-container'); + if (container) container.remove(); + container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + const { createFeudalismGame } = await import( + '../../example-games/feudalism/createFeudalismGame' + ); + const game = createFeudalismGame(); + await waitForScene(game, 'FeudalismScene'); + return game; +} + +function destroyGame(game: Phaser.Game | null): void { + if (game) game.destroy(true, false); + const container = document.getElementById('game-container'); + if (container) container.remove(); +} + +function waitFrames(n: number): Promise { + return new Promise((resolve) => { + let remaining = n; + const tick = () => { + remaining--; + if (remaining <= 0) resolve(); + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +/** + * Get renderer containers from the FeudalismScene via the private + * feudRenderer field (test-only access). + */ +function getRendererContainers(scene: Phaser.Scene): Record { + const feudRenderer = (scene as any).feudRenderer; + return { + sectionBoxContainer: feudRenderer.sectionBoxContainer as Phaser.GameObjects.Container, + marketContainer: feudRenderer.marketContainer as Phaser.GameObjects.Container, + patronContainer: feudRenderer.patronContainer as Phaser.GameObjects.Container, + supplyContainer: feudRenderer.supplyContainer as Phaser.GameObjects.Container, + playerContainer: feudRenderer.playerContainer as Phaser.GameObjects.Container, + aiContainer: feudRenderer.aiContainer as Phaser.GameObjects.Container, + actionContainer: feudRenderer.actionContainer as Phaser.GameObjects.Container, + discardContainer: feudRenderer.discardContainer as Phaser.GameObjects.Container, + }; +} + +describe('Feudalism container z-order', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + destroyGame(game); + game = null; + }); + + it('all gameplay containers exist after boot', async () => { + game = await bootGame(); + const scene = game.scene.getScene('FeudalismScene')!; + const containers = getRendererContainers(scene); + + for (const [name, container] of Object.entries(containers)) { + expect(container, `${name} should exist`).toBeDefined(); + expect(container, `${name} should be a Container`).toBeInstanceOf(Phaser.GameObjects.Container); + } + }); + + it('gameplay containers use default depth (0) — rely on creation order', async () => { + game = await bootGame(); + const scene = game.scene.getScene('FeudalismScene')!; + const containers = getRendererContainers(scene); + + for (const [name, container] of Object.entries(containers)) { + expect((container as any).depth ?? 0, `${name} should use default depth`).toBe(0); + } + }); + + it('actionContainer is created after player/AI containers (renders on top)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('FeudalismScene')!; + const containers = getRendererContainers(scene); + const children = scene.children.list as Phaser.GameObjects.GameObject[]; + + const actionIdx = children.indexOf(containers.actionContainer); + const playerIdx = children.indexOf(containers.playerContainer); + const aiIdx = children.indexOf(containers.aiContainer); + + expect(actionIdx).toBeGreaterThan(playerIdx); + expect(actionIdx).toBeGreaterThan(aiIdx); + }); + + it('sectionBoxContainer is created before gameplay containers (renders underneath)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('FeudalismScene')!; + const containers = getRendererContainers(scene); + const children = scene.children.list as Phaser.GameObjects.GameObject[]; + + const sectionBoxIdx = children.indexOf(containers.sectionBoxContainer); + const marketIdx = children.indexOf(containers.marketContainer); + const playerIdx = children.indexOf(containers.playerContainer); + + expect(sectionBoxIdx).toBeLessThan(marketIdx); + expect(sectionBoxIdx).toBeLessThan(playerIdx); + }); + + it('overlay elements (depth 10+) render above gameplay containers', async () => { + game = await bootGame(); + await waitFrames(3); + const scene = game.scene.getScene('FeudalismScene') as any; + + // Trigger the discard overlay dialog which creates depth-11 elements + // directly via the renderer (the scene method is private). + const feudRenderer = (scene as any).feudRenderer; + feudRenderer.showDiscardDialog(1, () => {}); + await waitFrames(3); + + // Find all objects with depth >= 10 (overlay elements) + const overlayObjects: Phaser.GameObjects.GameObject[] = []; + function walk(parent: Phaser.GameObjects.GameObject[]) { + for (const child of parent) { + const d = (child as any).depth ?? 0; + if (d >= 10) overlayObjects.push(child); + if (child instanceof Phaser.GameObjects.Container && (child as any).list) { + walk((child as any).list); + } + } + } + walk(scene.children.list); + + // There should be at least one overlay object with depth >= 10 + expect(overlayObjects.length).toBeGreaterThan(0); + + // All gameplay containers have depth 0, so overlay objects should be above + const containers = getRendererContainers(scene); + for (const obj of overlayObjects) { + const objDepth = (obj as any).depth ?? 0; + for (const [name, container] of Object.entries(containers)) { + const cDepth = (container as any).depth ?? 0; + expect(objDepth, `Overlay object (depth ${objDepth}) should be above ${name} (depth ${cDepth})`) + .toBeGreaterThan(cDepth); + } + } + + // Clean up overlay + scene.overlayManager?.dismiss?.(); + }); + + it('zone metadata is set on Feudalism containers (created via createGameZone)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('FeudalismScene')!; + const containers = getRendererContainers(scene); + + // Feudalism containers are created via createGameZone, so they should have + // zone metadata properties (__zoneWidth, __zoneHeight, __zoneName). + for (const [name, container] of Object.entries(containers)) { + expect((container as any).__zoneWidth, `${name} should have __zoneWidth`).toBeDefined(); + expect((container as any).__zoneHeight, `${name} should have __zoneHeight`).toBeDefined(); + expect((container as any).__zoneName, `${name} should have __zoneName`).toBe(name); + } + }); +}); diff --git a/tests/fixtures/layouts/main-street-tutorial-dim.layout.json b/tests/fixtures/layouts/main-street-tutorial-dim.layout.json new file mode 100644 index 00000000..04e9fb2e --- /dev/null +++ b/tests/fixtures/layouts/main-street-tutorial-dim.layout.json @@ -0,0 +1,96 @@ +{ + "version": 1, + "id": "main-street-tutorial-dimensions", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "hud", + "marketBusinessRow", + "streetGrid", + "endTurnButton", + "incidentQueue", + "investmentsRow", + "helpButton" + ], + "zones": { + "hud": { + "rect": { + "x": 0, + "y": 0, + "w": 1, + "h": 0.04 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.02 } + } + }, + "marketBusinessRow": { + "rect": { + "x": 0.02, + "y": 0.12, + "w": 0.39, + "h": 0.11 + }, + "anchors": { + "center": { "x": 0.215, "y": 0.175 } + } + }, + "streetGrid": { + "rect": { + "x": 0.12, + "y": 0.53, + "w": 0.7, + "h": 0.35 + }, + "anchors": { + "center": { "x": 0.47, "y": 0.705 } + } + }, + "endTurnButton": { + "rect": { + "x": 0.87, + "y": 0.9, + "w": 0.1, + "h": 0.06 + }, + "anchors": { + "center": { "x": 0.92, "y": 0.93 } + } + }, + "incidentQueue": { + "rect": { + "x": 0.81, + "y": 0.12, + "w": 0.15, + "h": 0.35 + }, + "anchors": { + "topLeft": { "x": 0.81, "y": 0.12 } + } + }, + "investmentsRow": { + "rect": { + "x": 0.02, + "y": 0.24, + "w": 0.39, + "h": 0.08 + }, + "anchors": { + "center": { "x": 0.215, "y": 0.28 } + } + }, + "helpButton": { + "rect": { + "x": 0.93, + "y": 0.89, + "w": 0.07, + "h": 0.09 + }, + "anchors": { + "center": { "x": 0.965, "y": 0.935 } + } + } + } +} diff --git a/tests/main-street/MainStreetZOrder.browser.test.ts b/tests/main-street/MainStreetZOrder.browser.test.ts new file mode 100644 index 00000000..0262e9de --- /dev/null +++ b/tests/main-street/MainStreetZOrder.browser.test.ts @@ -0,0 +1,181 @@ +/** + * Main Street z-order browser tests. + * + * Validates that Main Street's container depth ordering follows the expected + * convention: HUD depth (1000) > all other zone containers > gameplay containers. + * + * Main Street explicitly sets HUD container depth to 1000. Other containers + * (street, market, hand, action, incident queue) use default depth (0) and + * rely on creation-order depth sorting. + * + * Expected ordering (bottom → top): + * 1. streetContainer – business cards on the street (depth 0) + * 2. marketContainer – market cards (depth 0) + * 3. incidentQueueContainer – incident queue (depth 0) + * 4. handContainer – player hand cards (depth 0) + * 5. actionContainer – action buttons (depth 0) + * 6. hudContainer – HUD overlays (depth 1000) + * 7. Game state overlays – depth 2000+ + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import Phaser from 'phaser'; +import { waitForScene } from '../helpers/waitForScene'; + +async function bootGame(): Promise { + let container = document.getElementById('game-container'); + if (container) container.remove(); + container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + const { createMainStreetGame } = await import('../../example-games/main-street/createMainStreetGame'); + const game = createMainStreetGame({ parent: 'game-container' }); + await waitForScene(game, 'MainStreetScene'); + return game; +} + +function destroyGame(game: Phaser.Game | null): void { + if (game) game.destroy(true, false); + const container = document.getElementById('game-container'); + if (container) container.remove(); +} + +function waitFrames(n: number): Promise { + return new Promise((resolve) => { + let remaining = n; + const tick = () => { + remaining--; + if (remaining <= 0) resolve(); + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +describe('Main Street container z-order', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + destroyGame(game); + game = null; + }); + + it('all expected containers exist after boot', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as any; + + expect(scene.hudContainer).toBeDefined(); + expect(scene.hudContainer).toBeInstanceOf(Phaser.GameObjects.Container); + + expect(scene.streetContainer).toBeDefined(); + expect(scene.streetContainer).toBeInstanceOf(Phaser.GameObjects.Container); + + expect(scene.marketContainer).toBeDefined(); + expect(scene.marketContainer).toBeInstanceOf(Phaser.GameObjects.Container); + + expect(scene.handContainer).toBeDefined(); + expect(scene.handContainer).toBeInstanceOf(Phaser.GameObjects.Container); + + expect(scene.actionContainer).toBeDefined(); + expect(scene.actionContainer).toBeInstanceOf(Phaser.GameObjects.Container); + }); + + it('hudContainer has depth ≥ 1000', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as any; + + const hudDepth = scene.hudContainer.depth ?? 0; + expect(hudDepth).toBeGreaterThanOrEqual(1000); + }); + + it('gameplay containers use default depth (0)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as any; + + const containers = [ + 'streetContainer', + 'marketContainer', + 'incidentQueueContainer', + 'handContainer', + 'actionContainer', + ]; + + for (const name of containers) { + if (scene[name]) { + expect((scene[name] as any).depth ?? 0, `${name} should use default depth`).toBe(0); + } + } + }); + + it('hudContainer depth is greater than all gameplay container depths', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as any; + + const hudDepth = scene.hudContainer.depth ?? 0; + expect(hudDepth).toBeGreaterThanOrEqual(1000); + + const gameplayContainers = [ + 'streetContainer', + 'marketContainer', + 'handContainer', + 'actionContainer', + ]; + + for (const name of gameplayContainers) { + if (scene[name]) { + const cDepth = (scene[name] as any).depth ?? 0; + expect(hudDepth, `hud depth (${hudDepth}) > ${name} depth (${cDepth})`).toBeGreaterThan(cDepth); + } + } + }); + + it('actionContainer is created after street/market containers (renders on top)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as any; + const children = scene.children.list as Phaser.GameObjects.GameObject[]; + + const actionIdx = children.indexOf(scene.actionContainer); + const streetIdx = children.indexOf(scene.streetContainer); + const marketIdx = children.indexOf(scene.marketContainer); + + expect(actionIdx).toBeGreaterThan(streetIdx); + expect(actionIdx).toBeGreaterThan(marketIdx); + }); + + it('zone containers have metadata from createGameZone', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as any; + + const containers: { name: string; expectedW?: number; expectedH?: number; expectedName?: string }[] = [ + { name: 'hudContainer', expectedW: undefined, expectedH: undefined, expectedName: 'hudContainer' }, + { name: 'streetContainer', expectedW: undefined, expectedH: undefined, expectedName: 'streetContainer' }, + { name: 'marketContainer', expectedW: undefined, expectedH: undefined, expectedName: 'marketContainer' }, + { name: 'handContainer', expectedW: undefined, expectedH: undefined, expectedName: 'handContainer' }, + { name: 'actionContainer', expectedW: undefined, expectedH: undefined, expectedName: 'actionContainer' }, + ]; + + for (const { name, expectedName } of containers) { + if (scene[name]) { + const zone = scene[name] as any; + expect(zone.__zoneName, `${name} should have __zoneName`).toBe(expectedName); + expect(zone.__zoneWidth, `${name} should have __zoneWidth`).toBeDefined(); + expect(zone.__zoneHeight, `${name} should have __zoneHeight`).toBeDefined(); + } + } + }); + + it('hudContainer has zone metadata and correct depth', async () => { + game = await bootGame(); + await waitFrames(3); + const scene = game.scene.getScene('MainStreetScene') as any; + + // hudContainer is created via createGameZone, so it should have zone metadata + expect((scene.hudContainer as any).__zoneName).toBe('hudContainer'); + expect((scene.hudContainer as any).__zoneWidth).toBeDefined(); + expect((scene.hudContainer as any).__zoneHeight).toBeDefined(); + + // But it should have the correct depth + expect(scene.hudContainer.depth).toBeGreaterThanOrEqual(1000); + }); +}); diff --git a/tests/main-street/tutorial-flow.test.ts b/tests/main-street/tutorial-flow.test.ts index 73bbbcc0..8a143011 100644 --- a/tests/main-street/tutorial-flow.test.ts +++ b/tests/main-street/tutorial-flow.test.ts @@ -37,9 +37,9 @@ describe('TUTORIAL_STEP_DEFS', () => { it('each step has a valid highlightZone', () => { const validZones = [ - 'center-modal', 'hud', 'market-business-row', 'street-grid', - 'end-turn-button', 'incident-queue', 'investments-row', - 'help-button', 'completion-modal', + 'centerModal', 'hud', 'marketBusinessRow', 'streetGrid', + 'endTurnButton', 'incidentQueue', 'investmentsRow', + 'helpButton', 'completionModal', ]; for (const step of TUTORIAL_STEP_DEFS) { expect(validZones).toContain(step.highlightZone); @@ -57,39 +57,39 @@ describe('TUTORIAL_STEP_DEFS', () => { } }); - it('T1 has confirm action and center-modal highlight', () => { + it('T1 has confirm action and centerModal highlight', () => { const t1 = TUTORIAL_STEP_DEFS[0]; expect(t1.id).toBe('T1'); expect(t1.requiredAction).toBe('confirm'); - expect(t1.highlightZone).toBe('center-modal'); + expect(t1.highlightZone).toBe('centerModal'); }); - it('T4 has place-business action and street-grid highlight', () => { + it('T4 has place-business action and streetGrid highlight', () => { const t4 = TUTORIAL_STEP_DEFS[3]; expect(t4.id).toBe('T4'); expect(t4.requiredAction).toBe('place-business'); - expect(t4.highlightZone).toBe('street-grid'); + expect(t4.highlightZone).toBe('streetGrid'); }); - it('T5 has end-turn action and end-turn-button highlight', () => { + it('T5 has end-turn action and endTurnButton highlight', () => { const t5 = TUTORIAL_STEP_DEFS[4]; expect(t5.id).toBe('T5'); expect(t5.requiredAction).toBe('end-turn'); - expect(t5.highlightZone).toBe('end-turn-button'); + expect(t5.highlightZone).toBe('endTurnButton'); }); - it('T9 has open-help action and help-button highlight', () => { + it('T9 has open-help action and helpButton highlight', () => { const t9 = TUTORIAL_STEP_DEFS[8]; expect(t9.id).toBe('T9'); expect(t9.requiredAction).toBe('open-help'); - expect(t9.highlightZone).toBe('help-button'); + expect(t9.highlightZone).toBe('helpButton'); }); it('T10 has confirm-complete action', () => { const t10 = TUTORIAL_STEP_DEFS[9]; expect(t10.id).toBe('T10'); expect(t10.requiredAction).toBe('confirm-complete'); - expect(t10.highlightZone).toBe('completion-modal'); + expect(t10.highlightZone).toBe('completionModal'); }); }); diff --git a/tests/main-street/tutorial-layout-resolution.test.ts b/tests/main-street/tutorial-layout-resolution.test.ts new file mode 100644 index 00000000..4b71da10 --- /dev/null +++ b/tests/main-street/tutorial-layout-resolution.test.ts @@ -0,0 +1,489 @@ +/** + * Tutorial layout resolution tests + * + * Verifies that SLL-resolved tutorial bounding boxes match current + * zoneToAnchor() outputs and that composition works correctly. + * + * @module tests/main-street/tutorial-layout-resolution + */ + +import { describe, expect, it } from 'vitest'; + +import type { ScreenLayoutDocument } from '../../src/ui/screen-layout-schema'; +import { + composeResolvedLayouts, + type ComposeResolvedLayoutsIssue, +} from '../../src/ui/screen-layout-compose'; +import { + getZoneRect, + ScreenLayoutMappingError, + type LayoutViewport, +} from '../../src/ui/screen-layout'; +import { + parseScreenLayoutDocument, + validateScreenLayoutDocument, +} from '../../src/ui/screen-layout-schema'; + +import baseLayout from '../../example-games/main-street/layouts/main-street.layout.json'; +import tutorialLayout from '../../example-games/main-street/layouts/main-street-tutorial.layout.json'; +import { + MARKET_BUSINESS_SLOTS, + INCIDENT_QUEUE_SIZE, +} from '../../example-games/main-street/MainStreetCards'; +import { + BASE_HUD_Y, + BASE_MARKET_CARD_W, + BASE_MARKET_CARD_H, + BASE_MARKET_ROW_GAP, + BASE_MARKET_CARD_GAP, + BASE_MARKET_LABEL_W, + BASE_QUEUE_CARD_W, + BASE_QUEUE_CARD_H, + BASE_QUEUE_CARD_GAP, + BASE_SLOT_H, + STREET_ROW_GAP, +} from '../../example-games/main-street/scenes/MainStreetConstants'; + +const VIEWPORT: LayoutViewport = { width: 1280, height: 720 }; + +function computeExpectedZoneBounds( + zone: string, + viewport: LayoutViewport = VIEWPORT, +): { x: number; y: number; w: number; h: number } | null { + const gameW = viewport.width; + const marketRowH = BASE_MARKET_CARD_H + 14; + + switch (zone) { + case 'hud': + return { x: 0, y: BASE_HUD_Y - 14, w: gameW, h: 28 }; + case 'marketBusinessRow': { + const marketStartX = BASE_MARKET_LABEL_W + 50; + const marketRight = + marketStartX + + (MARKET_BUSINESS_SLOTS - 1) * (BASE_MARKET_CARD_W + BASE_MARKET_CARD_GAP) + + BASE_MARKET_CARD_W + + 20; + return { + x: 20, + y: 90 - 10, + w: marketRight - 20, + h: 2 * marketRowH + BASE_MARKET_ROW_GAP + 20, + }; + } + case 'streetGrid': { + const streetH = 2 * BASE_SLOT_H + STREET_ROW_GAP + 12; + return { x: 0, y: 439 - 6, w: gameW, h: streetH }; + } + case 'endTurnButton': { + const rightX = gameW - 24; + return { + x: rightX - 140 - 20, + y: 648 - 4, + w: 140 + 20, + h: 34 + 8, + }; + } + case 'incidentQueue': { + const totalW = + BASE_MARKET_LABEL_W + + INCIDENT_QUEUE_SIZE * (BASE_QUEUE_CARD_W + BASE_QUEUE_CARD_GAP) + + 32; + return { + x: 20, + y: 320 - 6, + w: totalW, + h: BASE_QUEUE_CARD_H + 16, + }; + } + case 'investmentsRow': { + const marketStartX = BASE_MARKET_LABEL_W + 50; + const marketRight = + marketStartX + + (MARKET_BUSINESS_SLOTS - 1) * (BASE_MARKET_CARD_W + BASE_MARKET_CARD_GAP) + + BASE_MARKET_CARD_W + + 20; + return { + x: 20, + y: 90 + marketRowH + BASE_MARKET_ROW_GAP, + w: marketRight - 20, + h: marketRowH, + }; + } + case 'helpButton': + return { x: gameW - 120, y: 648 - 4, w: 100, h: 34 + 8 }; + case 'centerModal': + case 'completionModal': + return null; + default: + return null; + } +} + +function boundsAlmostEqual( + actual: { x: number; y: number; width?: number; height?: number }, + expected: { x: number; y: number; w: number; h: number }, +): void { + expect(actual.x).toBeCloseTo(expected.x, 0); + expect(actual.y).toBeCloseTo(expected.y, 0); + expect(actual.width).toBeCloseTo(expected.w, 0); + expect(actual.height).toBeCloseTo(expected.h, 0); +} + +function parseTutorialLayout(): ScreenLayoutDocument { + const validation = validateScreenLayoutDocument(tutorialLayout); + if (!validation.valid) { + throw new Error( + `Tutorial layout is invalid: ${validation.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, + ); + } + return tutorialLayout as ScreenLayoutDocument; +} + +function parseBaseLayout(): ScreenLayoutDocument { + const parsed = parseScreenLayoutDocument(baseLayout); + if (!parsed.valid) { + throw new Error( + `Base layout is invalid: ${parsed.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, + ); + } + return parsed.layout; +} + +const TUTORIAL_ZONE_NAMES = [ + 'hud', + 'marketBusinessRow', + 'streetGrid', + 'endTurnButton', + 'incidentQueue', + 'investmentsRow', + 'helpButton', +]; + +describe('Tutorial layout resolution', () => { + describe('schema validation', () => { + it('passes validation for the tutorial layout', () => { + const result = validateScreenLayoutDocument(tutorialLayout); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('accepts all 7 required zones', () => { + const layout = parseTutorialLayout(); + expect(layout.requiredZones.sort()).toEqual(TUTORIAL_ZONE_NAMES.sort()); + }); + + it('all tutorial zones have dimensions (w and h)', () => { + const layout = parseTutorialLayout(); + for (const zoneName of TUTORIAL_ZONE_NAMES) { + const zone = layout.zones[zoneName]; + expect(zone).toBeDefined(); + expect(zone!.rect.w).toBeDefined(); + expect(zone!.rect.h).toBeDefined(); + expect(zone!.rect.w! > 0).toBe(true); + expect(zone!.rect.h! > 0).toBe(true); + } + }); + }); + + describe('composeResolvedLayouts resolves all tutorial zones', () => { + it('resolves all 7 tutorial zones at 1280x720 @1x', () => { + const issues: ComposeResolvedLayoutsIssue[] = []; + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + { reportIssue: (issue) => issues.push(issue) }, + ); + + for (const zoneName of TUTORIAL_ZONE_NAMES) { + expect(resolved.zones[zoneName]).toBeDefined(); + } + }); + + it('returns viewport metadata matching the input', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + ); + + expect(resolved.viewport.width).toBe(1280); + expect(resolved.viewport.height).toBe(720); + expect(resolved.viewport.dpr).toBe(1); + expect(resolved.viewport.pixelWidth).toBe(1280); + expect(resolved.viewport.pixelHeight).toBe(720); + }); + + it('retains base layout zones alongside tutorial zones', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + ); + + const baseZones = [ + 'market', 'incidentQueue', 'street', 'hand', 'actions', + 'activityLog', 'challengePanel', 'endTurnButton', + ]; + for (const zoneName of baseZones) { + expect(resolved.zones[zoneName]).toBeDefined(); + } + }); + }); + + describe('pixel bounds match zoneToAnchor() reference', () => { + it('hud zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('hud'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.hud.rect, expected!); + }); + + it('marketBusinessRow zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('marketBusinessRow'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.marketBusinessRow.rect, expected!); + }); + + it('streetGrid zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('streetGrid'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.streetGrid.rect, expected!); + }); + + it('endTurnButton zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('endTurnButton'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.endTurnButton.rect, expected!); + }); + + it('incidentQueue zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('incidentQueue'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.incidentQueue.rect, expected!); + }); + + it('investmentsRow zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('investmentsRow'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.investmentsRow.rect, expected!); + }); + + it('helpButton zone matches zoneToAnchor() pixel math within 1px', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + const expected = computeExpectedZoneBounds('helpButton'); + expect(expected).not.toBeNull(); + boundsAlmostEqual(resolved.zones.helpButton.rect, expected!); + }); + + it('all tutorial zones match zoneToAnchor() within 1px tolerance', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), parseTutorialLayout(), VIEWPORT, 1, + ); + + for (const zoneName of TUTORIAL_ZONE_NAMES) { + const expected = computeExpectedZoneBounds(zoneName); + expect(expected).not.toBeNull(); + expect(resolved.zones[zoneName]).toBeDefined(); + boundsAlmostEqual(resolved.zones[zoneName]!.rect, expected!); + } + }); + }); + + describe('missing zones return null', () => { + it('centerModal returns null as expected', () => { + const result = computeExpectedZoneBounds('centerModal'); + expect(result).toBeNull(); + }); + + it('completionModal returns null as expected', () => { + const result = computeExpectedZoneBounds('completionModal'); + expect(result).toBeNull(); + }); + }); + + describe('unknown zone names throw ScreenLayoutMappingError', () => { + it('throws ScreenLayoutMappingError for an unknown zone name via getZoneRect', () => { + const layout = parseTutorialLayout(); + expect(() => + getZoneRect(layout, 'nonExistentZone', VIEWPORT, 1), + ).toThrowError(ScreenLayoutMappingError); + }); + + it('throws with UNKNOWN_ZONE code for unknown zones', () => { + const layout = parseTutorialLayout(); + let error: ScreenLayoutMappingError | undefined; + try { + getZoneRect(layout, 'phantomZone', VIEWPORT, 1); + } catch (e) { + if (e instanceof ScreenLayoutMappingError) { + error = e; + } + } + expect(error).toBeDefined(); + expect(error!.code).toBe('UNKNOWN_ZONE'); + expect(error!.zoneName).toBe('phantomZone'); + }); + + it('does not throw for known tutorial zone names', () => { + const layout = parseTutorialLayout(); + for (const zoneName of TUTORIAL_ZONE_NAMES) { + const rect = getZoneRect(layout, zoneName, VIEWPORT, 1); + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.y).toBeGreaterThanOrEqual(0); + } + }); + }); + + describe('sceneWins policy', () => { + it('tutorial zones overlay base zones when names collide', () => { + const issues: ComposeResolvedLayoutsIssue[] = []; + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + { policy: 'sceneWins', reportIssue: (issue) => issues.push(issue) }, + ); + + const collisionIssues = issues.filter( + (i) => i.code === 'ZONE_COLLISION', + ); + expect(collisionIssues.length).toBeGreaterThanOrEqual(1); + + const expectedEndTurn = computeExpectedZoneBounds('endTurnButton'); + if (expectedEndTurn) { + boundsAlmostEqual( + resolved.zones.endTurnButton.rect, + expectedEndTurn, + ); + } + + const expectedIncident = computeExpectedZoneBounds('incidentQueue'); + if (expectedIncident) { + boundsAlmostEqual( + resolved.zones.incidentQueue.rect, + expectedIncident, + ); + } + }); + + it('tutorial-only zones appear in the composed output', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + ); + + expect(resolved.zones.hud).toBeDefined(); + expect(resolved.zones.marketBusinessRow).toBeDefined(); + expect(resolved.zones.streetGrid).toBeDefined(); + expect(resolved.zones.investmentsRow).toBeDefined(); + expect(resolved.zones.helpButton).toBeDefined(); + }); + + it('sceneWins keeps base-only zones', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + ); + + expect(resolved.zones.market).toBeDefined(); + expect(resolved.zones.street).toBeDefined(); + expect(resolved.zones.hand).toBeDefined(); + expect(resolved.zones.actions).toBeDefined(); + expect(resolved.zones.activityLog).toBeDefined(); + expect(resolved.zones.challengePanel).toBeDefined(); + }); + }); + + describe('DPR and viewport scaling', () => { + it('scales tutorial zone bounds proportionally at 2x DPR', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 2, + ); + + expect(resolved.viewport.dpr).toBe(2); + expect(resolved.viewport.pixelWidth).toBe(2560); + expect(resolved.viewport.pixelHeight).toBe(1440); + + expect(resolved.zones.hud.rect.x).toBe(0); + expect(resolved.zones.hud.rect.y).toBe(72); + expect(resolved.zones.hud.rect.width).toBe(2560); + expect(resolved.zones.hud.rect.height).toBeCloseTo(56, 0); + }); + + it('handles different viewport sizes correctly', () => { + const smallViewport: LayoutViewport = { width: 800, height: 600 }; + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + smallViewport, + 1, + ); + + expect(resolved.zones.hud.rect.x).toBe(0); + expect(resolved.zones.hud.rect.y).toBeCloseTo(30, 0); + expect(resolved.zones.hud.rect.width).toBe(800); + expect(resolved.zones.hud.rect.height).toBeCloseTo(23.33, 0); + }); + }); + + describe('browser regression test', () => { + it('captures expected highlight positions for T1-T10 steps', () => { + const resolved = composeResolvedLayouts( + parseBaseLayout(), + parseTutorialLayout(), + VIEWPORT, + 1, + ); + + const expectedPositions: Record = { + marketBusinessRow: computeExpectedZoneBounds('marketBusinessRow')!, + incidentQueue: computeExpectedZoneBounds('incidentQueue')!, + streetGrid: computeExpectedZoneBounds('streetGrid')!, + endTurnButton: computeExpectedZoneBounds('endTurnButton')!, + investmentsRow: computeExpectedZoneBounds('investmentsRow')!, + helpButton: computeExpectedZoneBounds('helpButton')!, + hud: computeExpectedZoneBounds('hud')!, + }; + + for (const [zoneName, expected] of Object.entries(expectedPositions)) { + const actual = resolved.zones[zoneName]?.rect; + expect(actual).toBeDefined(); + expect(actual!.x).toBeCloseTo(expected.x, 0); + expect(actual!.y).toBeCloseTo(expected.y, 0); + expect(actual!.width).toBeCloseTo(expected.w, 0); + expect(actual!.height).toBeCloseTo(expected.h, 0); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/sushi-go/SushiGoZOrder.browser.test.ts b/tests/sushi-go/SushiGoZOrder.browser.test.ts new file mode 100644 index 00000000..2af13750 --- /dev/null +++ b/tests/sushi-go/SushiGoZOrder.browser.test.ts @@ -0,0 +1,142 @@ +/** + * Sushi Go z-order browser tests. + * + * Validates that hand and tableau containers have defined, non-overlapping + * depth ordering. Sushi Go relies on Phaser's default creation-order + * depth sorting for its containers (no explicit setDepth calls), so these + * tests verify the creation sequence produces the expected visual layering. + * + * Actual ordering (bottom → top) — determined by creation order: + * 1. handContainer – cards currently in the player's hand + * 2. playerTableauContainer – cards played to the player's tableau + * 3. aiTableauContainer – cards played to the AI's tableau + * + * Since Sushi Go does not assign explicit depth values to these containers, + * we verify that the creation order is stable and consistent, and that + * no containers share the same explicit depth that would cause z-fighting. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import Phaser from 'phaser'; +import { waitForScene } from '../helpers/waitForScene'; + +async function bootGame(): Promise { + let container = document.getElementById('game-container'); + if (container) container.remove(); + container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + const { createSushiGoGame } = await import('../../example-games/sushi-go/createSushiGoGame'); + const game = createSushiGoGame(); + await waitForScene(game, 'SushiGoScene'); + // Wait for ensureIconTextures().finally() to settle before returning, + // avoiding unhandled rejection on scene destroy. + await new Promise((r) => setTimeout(r, 200)); + return game; +} + +function destroyGame(game: Phaser.Game | null): void { + if (game) game.destroy(true, false); + const container = document.getElementById('game-container'); + if (container) container.remove(); +} + +function waitFrames(n: number): Promise { + return new Promise((resolve) => { + let remaining = n; + const tick = () => { + remaining--; + if (remaining <= 0) resolve(); + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +describe('Sushi Go container z-order', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + destroyGame(game); + game = null; + }); + + it('handContainer, playerTableauContainer, and aiTableauContainer exist after boot', async () => { + game = await bootGame(); + const scene = game.scene.getScene('SushiGoScene') as any; + + // In Sushi Go, containers are fields on the scene itself, not on a renderer. + expect(scene.handContainer).toBeDefined(); + expect(scene.handContainer).toBeInstanceOf(Phaser.GameObjects.Container); + + expect(scene.playerTableauContainer).toBeDefined(); + expect(scene.playerTableauContainer).toBeInstanceOf(Phaser.GameObjects.Container); + + expect(scene.aiTableauContainer).toBeDefined(); + expect(scene.aiTableauContainer).toBeInstanceOf(Phaser.GameObjects.Container); + }); + + it('containers are created in a consistent order', async () => { + game = await bootGame(); + const scene = game.scene.getScene('SushiGoScene') as any; + + // All three containers use default depth (0) in Sushi Go, so render order + // is determined by creation order. We verify the containers exist and + // are all present in the scene's children list. + const children = scene.children.list as Phaser.GameObjects.GameObject[]; + const handIdx = children.indexOf(scene.handContainer); + const playerTableauIdx = children.indexOf(scene.playerTableauContainer); + const aiTableauIdx = children.indexOf(scene.aiTableauContainer); + + expect(handIdx).toBeGreaterThanOrEqual(0); + expect(playerTableauIdx).toBeGreaterThanOrEqual(0); + expect(aiTableauIdx).toBeGreaterThanOrEqual(0); + + // Verify containers have distinct indices (no overlapping references) + expect(handIdx).not.toBe(playerTableauIdx); + expect(handIdx).not.toBe(aiTableauIdx); + expect(playerTableauIdx).not.toBe(aiTableauIdx); + }); + + it('depth is 0 on all gameplay containers (creation-order sorting)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('SushiGoScene') as any; + + // All gameplay containers use depth 0 (set by createGameZone which does + // not assign a depth), so render order is determined by creation order. + expect(scene.handContainer.depth).toBe(0); + expect(scene.playerTableauContainer.depth).toBe(0); + expect(scene.aiTableauContainer.depth).toBe(0); + }); + + it('hudContainer depth (1000) is above all gameplay containers', async () => { + game = await bootGame(); + await waitFrames(3); + const scene = game.scene.getScene('SushiGoScene') as any; + + if (scene.hudContainer) { + const hudDepth = scene.hudContainer.depth ?? 0; + expect(hudDepth).toBeGreaterThanOrEqual(1000); + + // Gameplay containers use depth 0 + expect(hudDepth).toBeGreaterThan((scene.handContainer as any).depth ?? 0); + expect(hudDepth).toBeGreaterThan((scene.playerTableauContainer as any).depth ?? 0); + expect(hudDepth).toBeGreaterThan((scene.aiTableauContainer as any).depth ?? 0); + } + }); + + it('zone metadata is set on containers created via createGameZone', async () => { + game = await bootGame(); + const scene = game.scene.getScene('SushiGoScene') as any; + + // After migration, Sushi Go containers are created via createGameZone, + // so they should carry zone metadata properties matching GAME_W x GAME_H. + expect((scene.handContainer as any).__zoneWidth).toBe(1280); + expect((scene.handContainer as any).__zoneHeight).toBe(720); + expect((scene.handContainer as any).__zoneName).toBe('handContainer'); + + expect((scene.playerTableauContainer as any).__zoneName).toBe('playerTableauContainer'); + expect((scene.aiTableauContainer as any).__zoneName).toBe('aiTableauContainer'); + }); +}); diff --git a/tests/ui/renderer.test.ts b/tests/ui/renderer.test.ts index 711c5a91..bcbfa77f 100644 --- a/tests/ui/renderer.test.ts +++ b/tests/ui/renderer.test.ts @@ -162,6 +162,38 @@ describe('createGameZone', () => { expect((zone as any).__zoneName).toBeUndefined(); }); + it('does not set __zoneName when name is explicitly undefined', () => { + const scene = createMockScene(); + const zone = createGameZone(scene, 50, 75, 200, 150, undefined); + expect((zone as any).__zoneWidth).toBe(200); + expect((zone as any).__zoneHeight).toBe(150); + expect((zone as any).__zoneName).toBeUndefined(); + }); + + it('treats empty string name as no-name (falsy)', () => { + const scene = createMockScene(); + const zone = createGameZone(scene, 0, 0, 100, 100, ''); + // Empty string is falsy, so __zoneName is not set + expect((zone as any).__zoneName).toBeUndefined(); + }); + + it('returns a container with expected interface methods', () => { + const scene = createMockScene(); + const zone = createGameZone(scene, 0, 0, 100, 100, 'testZone'); + expect(typeof (zone as any).setDepth).toBe('function'); + expect(typeof (zone as any).add).toBe('function'); + expect(typeof (zone as any).remove).toBe('function'); + expect(Array.isArray((zone as any).list)).toBe(true); + }); + + it('supports typical zone label conventions (hudContainer, streetContainer)', () => { + const scene = createMockScene(); + const hudZone = createGameZone(scene, 0, 0, 1280, 720, 'hudContainer'); + const streetZone = createGameZone(scene, 0, 200, 1280, 400, 'streetContainer'); + expect((hudZone as any).__zoneName).toBe('hudContainer'); + expect((streetZone as any).__zoneName).toBe('streetContainer'); + }); + it('stores negative dimensions as provided', () => { const scene = createMockScene(); const zone = createGameZone(scene, 0, 0, -10, -5); diff --git a/tests/ui/screen-layout-dimensions.test.ts b/tests/ui/screen-layout-dimensions.test.ts new file mode 100644 index 00000000..a9543172 --- /dev/null +++ b/tests/ui/screen-layout-dimensions.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, it } from 'vitest'; + +import type { + ScreenLayoutDocument, + PixelRect, +} from '../../src/ui/screen-layout-schema'; +import { getZoneRect } from '../../src/ui/screen-layout'; +import { + validateScreenLayoutDocument, + parseScreenLayoutDocument, +} from '../../src/ui/screen-layout-schema'; +import { + composeResolvedLayouts, + type ComposeResolvedLayoutsIssue, +} from '../../src/ui/screen-layout-compose'; + +const baseViewport = { width: 1280, height: 720 }; + +function expectPixelRectClose( + actual: PixelRect, + expectedX: number, + expectedY: number, + expectedWidth: number | null = null, + expectedHeight: number | null = null, +): void { + expect(actual.x).toBeCloseTo(expectedX, 6); + expect(actual.y).toBeCloseTo(expectedY, 6); + + if (expectedWidth !== null) { + expect(actual.width).toBeCloseTo(expectedWidth, 6); + } else if (expectedWidth === null && actual.width !== undefined) { + expect(actual.width).toBeUndefined(); + } + + if (expectedHeight !== null) { + expect(actual.height).toBeCloseTo(expectedHeight, 6); + } else if (expectedHeight === null && actual.height !== undefined) { + expect(actual.height).toBeUndefined(); + } +} + +describe('NormalizedRect dimension support (w/h)', () => { + it('accepts zones with optional w and h in JSON Schema', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'dim-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 0.3, + h: 0.4, + }, + }, + }, + }; + + const result = validateScreenLayoutDocument(layout); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('rejects zones with negative w or h', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'neg-dim-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: -1, + h: 0.4, + }, + }, + }, + }; + + const result = validateScreenLayoutDocument(layout); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('rejects zones with w or h greater than 1 (out-of-range)', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'oor-dim-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 999, + h: 0.4, + }, + }, + }, + }; + + const result = validateScreenLayoutDocument(layout); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('rejects zones with both w and h out-of-range', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'oor-both-dim-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 2, + h: 5, + }, + }, + }, + }; + + const result = validateScreenLayoutDocument(layout); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('accepts zones without w/h (backward-compatible)', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'no-dim-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['pos-only'], + zones: { + 'pos-only': { + rect: { + x: 0.1, + y: 0.2, + }, + }, + }, + }; + + const result = validateScreenLayoutDocument(layout); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('parses dimensioned zones into typed documents', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'parse-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 0.3, + h: 0.4, + }, + }, + }, + }; + + const result = parseScreenLayoutDocument(layout); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.layout.zones.boxed.rect.w).toBe(0.3); + expect(result.layout.zones.boxed.rect.h).toBe(0.4); + } + }); +}); + +describe('resolveRect with dimensions', () => { + it('returns PixelRect with width/height when w/h are present', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'dim-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 0.3, + h: 0.4, + }, + }, + }, + }; + + const rect = getZoneRect(layout, 'boxed', baseViewport, 1); + + expectPixelRectClose(rect, 128, 144, 384, 288); + }); + + it('returns PixelRect with undefined width/height when w/h are absent', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'pos-only', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['pos-only'], + zones: { + 'pos-only': { + rect: { + x: 0.1, + y: 0.2, + }, + }, + }, + }; + + const rect = getZoneRect(layout, 'pos-only', baseViewport, 1); + + expectPixelRectClose(rect, 128, 144, null, null); + }); + + it('scales dimensions with DPR', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'dim-dpr-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 0.3, + h: 0.4, + }, + }, + }, + }; + + const rect = getZoneRect(layout, 'boxed', baseViewport, 2); + + // x = 0.1 * 1280 * 2 = 256 + // y = 0.2 * 720 * 2 = 288 + // width = 0.3 * 1280 * 2 = 768 + // height = 0.4 * 720 * 2 = 576 + expectPixelRectClose(rect, 256, 288, 768, 576); + }); + + it('scales dimensions with different base viewport', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'dim-bv-test', + baseViewport: { width: 1920, height: 1080 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 0.3, + h: 0.4, + }, + }, + }, + }; + + const rect = getZoneRect(layout, 'boxed', baseViewport, 1); + + // x = 0.1 * 1280 = 128 + // y = 0.2 * 720 = 144 + // width = 0.3 * 1280 = 384 + // height = 0.4 * 720 = 288 + expectPixelRectClose(rect, 128, 144, 384, 288); + }); + + it('uses pixelOverride for position but still returns dimensions', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'dim-pixel-override', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + pixelOverride: { x: 100, y: 50 }, + w: 0.3, + h: 0.4, + }, + }, + }, + }; + + const rect = getZoneRect(layout, 'boxed', baseViewport, 1); + + expectPixelRectClose(rect, 100, 50, 384, 288); + }); +}); + +describe('ResolvedZone.rect type', () => { + it('getZoneRect returns PixelRect with optional width/height', () => { + const layout: ScreenLayoutDocument = { + version: 1, + id: 'type-test', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['boxed', 'pos-only'], + zones: { + boxed: { + rect: { + x: 0.1, + y: 0.2, + w: 0.3, + h: 0.4, + }, + }, + 'pos-only': { + rect: { + x: 0.1, + y: 0.2, + }, + }, + }, + }; + + const boxedRect = getZoneRect(layout, 'boxed', baseViewport, 1); + const posRect = getZoneRect(layout, 'pos-only', baseViewport, 1); + + // Dimensioned zone should have width and height + expect(boxedRect.width).toBe(384); + expect(boxedRect.height).toBe(288); + + // Position-only zone should have undefined width and height + expect(posRect.width).toBeUndefined(); + expect(posRect.height).toBeUndefined(); + }); +}); + +describe('composeResolvedLayouts with dimensioned zones', () => { + const desktopViewport = { width: 1280, height: 720 }; + + const baseLayout: ScreenLayoutDocument = { + version: 1, + id: 'base-layout', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['market', 'street'], + zones: { + market: { + rect: { x: 0.02, y: 0.12 }, + anchors: { center: { x: 0.215, y: 0.175 } }, + }, + street: { + rect: { x: 0.12, y: 0.53, w: 0.7, h: 0.35 }, + anchors: { center: { x: 0.47, y: 0.705 } }, + }, + }, + }; + + const sceneLayout: ScreenLayoutDocument = { + version: 1, + id: 'scene-layout', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['hud', 'helpButton'], + zones: { + hud: { + rect: { x: 0, y: 0, w: 1, h: 0.04 }, + anchors: { center: { x: 0.5, y: 0.02 } }, + }, + helpButton: { + rect: { x: 0.93, y: 0.89, w: 0.07, h: 0.09 }, + anchors: { center: { x: 0.965, y: 0.935 } }, + }, + }, + }; + + it('propagates dimensioned scene zones through composition (sceneWins)', () => { + const issues: ComposeResolvedLayoutsIssue[] = []; + const resolved = composeResolvedLayouts( + baseLayout, + sceneLayout, + desktopViewport, + 1, + { reportIssue: (issue) => issues.push(issue) }, + ); + + // Scene-only dimensioned zone should have width/height + const hudRect = resolved.zones['hud'].rect; + expect(hudRect.x).toBe(0); + expect(hudRect.y).toBe(0); + expect(hudRect.width).toBe(1280); + expect(hudRect.height).toBe(28.8); + + const helpRect = resolved.zones['helpButton'].rect; + expect(helpRect.width).toBeCloseTo(89.6, 4); + expect(helpRect.height).toBeCloseTo(64.8, 4); + + // Base dimensioned zone should keep its dimensions + const streetRect = resolved.zones['street'].rect; + expect(streetRect.width).toBeCloseTo(896, 6); + expect(streetRect.height).toBeCloseTo(252, 6); + + // Base position-only zone should keep undefined dimensions + const marketRect = resolved.zones['market'].rect; + expect(marketRect.width).toBeUndefined(); + expect(marketRect.height).toBeUndefined(); + }); + + it('propagates dimensioned zones through composition (baseWins)', () => { + const resolved = composeResolvedLayouts( + baseLayout, + sceneLayout, + desktopViewport, + 1, + { policy: 'baseWins' }, + ); + + // Base market zone (pos-only) should be preserved with undefined dims + const marketRect = resolved.zones['market'].rect; + expect(marketRect.width).toBeUndefined(); + expect(marketRect.height).toBeUndefined(); + + // Scene-only zones should still be included + const hudRect = resolved.zones['hud'].rect; + expect(hudRect.width).toBe(1280); + expect(hudRect.height).toBe(28.8); + }); + + it('propagates dimensioned zones through composition (namespace)', () => { + const resolved = composeResolvedLayouts( + baseLayout, + sceneLayout, + desktopViewport, + 1, + { policy: 'namespace', namespacePrefix: 'scene' }, + ); + + // Scene zones should be namespaced and keep dimensions + const hudRect = resolved.zones['scene:hud'].rect; + expect(hudRect.width).toBe(1280); + expect(hudRect.height).toBe(28.8); + + const helpRect = resolved.zones['scene:helpButton'].rect; + expect(helpRect.width).toBeCloseTo(89.6, 4); + expect(helpRect.height).toBeCloseTo(64.8, 4); + + // Base zones should be preserved + const streetRect = resolved.zones['street'].rect; + expect(streetRect.width).toBeCloseTo(896, 6); + expect(streetRect.height).toBeCloseTo(252, 6); + }); + + it('preserves pixelOverride position while adding dimensions in composed zones', () => { + const baseWithPixelOverride: ScreenLayoutDocument = { + version: 1, + id: 'base-pixel-override', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['activityLog'], + zones: { + activityLog: { + rect: { + x: 0.81, + y: 0.12, + pixelOverride: { x: 1036, y: 86 }, + w: 0.15, + h: 0.35, + }, + }, + }, + }; + + const resolved = composeResolvedLayouts( + baseWithPixelOverride, + sceneLayout, + desktopViewport, + 1, + ); + + const logRect = resolved.zones['activityLog'].rect; + // pixelOverride position is respected + expect(logRect.x).toBe(1036); + expect(logRect.y).toBe(86); + // dimensions are scaled from normalized + expect(logRect.width).toBeCloseTo(192, 4); + expect(logRect.height).toBeCloseTo(252, 4); + }); + + it('handles mixed dimensioned and position-only zones in both base and scene', () => { + const baseMixed: ScreenLayoutDocument = { + version: 1, + id: 'base-mixed', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['dimZone', 'posZone'], + zones: { + dimZone: { + rect: { x: 0.1, y: 0.1, w: 0.2, h: 0.3 }, + }, + posZone: { + rect: { x: 0.4, y: 0.4 }, + }, + }, + }; + + const sceneMixed: ScreenLayoutDocument = { + version: 1, + id: 'scene-mixed', + baseViewport: { width: 1280, height: 720 }, + requiredZones: ['dimScene', 'posScene'], + zones: { + dimScene: { + rect: { x: 0.6, y: 0.6, w: 0.1, h: 0.1 }, + }, + posScene: { + rect: { x: 0.8, y: 0.8 }, + }, + }, + }; + + const resolved = composeResolvedLayouts( + baseMixed, + sceneMixed, + desktopViewport, + 1, + ); + + // Dimensioned zones keep dimensions + expect(resolved.zones['dimZone'].rect.width).toBe(256); + expect(resolved.zones['dimZone'].rect.height).toBe(216); + expect(resolved.zones['dimScene'].rect.width).toBe(128); + expect(resolved.zones['dimScene'].rect.height).toBe(72); + + // Position-only zones keep undefined dimensions + expect(resolved.zones['posZone'].rect.width).toBeUndefined(); + expect(resolved.zones['posZone'].rect.height).toBeUndefined(); + expect(resolved.zones['posScene'].rect.width).toBeUndefined(); + expect(resolved.zones['posScene'].rect.height).toBeUndefined(); + }); +});