diff --git a/data/mastervega-tutorial.json b/data/mastervega-tutorial.json new file mode 100644 index 0000000..7d7efcf --- /dev/null +++ b/data/mastervega-tutorial.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "vars": ["species"], + "confirmSkip": { + "body": "Are you sure you want to skip the entire tutorial?", + "confirmLabel": "Skip tutorial", + "cancelLabel": "Keep learning" + }, + "steps": [ + { + "id": "intro", + "kind": "modal", + "voice": "vega/tutorial-intro-01", + "body": "All alone. Your species, the {species}, has spent its entire existence on a single planet.... but no more. Recent advances have given you the ability to explore and colonize nearby stars. It is time for your species to begin your exploration of the universe. Soon, you will find you're not alone afterall. But for now, you have prepared a scout ship and a colony ship with a singular mission: Find a habitable planet nearby to begin your expansion.", + "highlights": [], + "buttons": [ + { "action": "skip", "label": "Skip tutorial" }, + { "action": "next", "label": "Next" } + ] + }, + { + "id": "your-fleet", + "kind": "callout", + "voice": null, + "anchor": "homeFleet", + "highlights": ["homeFleet", "homeStar"], + "calloutText": "This is your fleet. A Scout Ship and Colony Ship. Click here to select them.", + "advanceOn": "hotspot", + "buttons": [] + }, + { + "id": "ship-profiles", + "kind": "callout", + "voice": null, + "anchor": "fleetShipProfiles", + "highlights": ["fleetShipProfiles"], + "calloutText": "These are the ships in your fleet. Click on a ship's profile picture or video to view details of that ship.", + "advanceOn": "shipDetailClosed", + "buttons": [] + }, + { + "id": "ship-counts", + "kind": "callout", + "voice": null, + "anchor": "fleetCountControls", + "highlights": ["fleetCountControls"], + "calloutText": "You can remove or increase or decrease the number of ships in this fleet by clicking one of these buttons.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + } + ] +} diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md index 576f040..de8be8b 100644 --- a/docs/mastervega-build-plan.md +++ b/docs/mastervega-build-plan.md @@ -1772,6 +1772,71 @@ Mirror-match-bias and species-spread assertions (verifier sections 5/11) both still passed at their existing tolerances — not re-measured as a standalone figure here. +## Guided tutorial (2026-08-28) + +An in-game guided tutorial that opens on every brand-new game (never a +resumed/loaded one) and is re-triggerable from the ☰ menu ("Replay tutorial", +greyed once the starting fleet has moved/split). Phase 1 ships the framework +plus four steps: a centred intro modal, a "select your fleet" callout, a +"these are your ships" callout over the side panel's ship rows, and a +"change ship counts" callout over the −/+/✕ cluster. + +- **The script is data.** `data/mastervega-tutorial.json` — an ordered `steps[]` + list, each with `kind` (`modal` | `callout`), `body`/`calloutText`, `voice` + (path under `assets/speech/`, no `.mp3`, or `null`), `highlights[]` / + `anchor` (string ids), `advanceOn` (`hotspot` = click the lit anchor; + `shipDetailClosed` = the player opened and closed a ship detail window), + and `buttons[]` (`{action, label}`, action ∈ next/back/skip/finish). A step + with `buttons: []` is legal only when `advanceOn` is set. `{token}` + placeholders are interpolated against a `vars` allow-list (only `species` + so far → `rules.species[emp.speciesId].plural`). Adding later steps = + editing this file; no code change unless a step needs a **new** highlight + target or advance mode. +- **Target ids** (`TUTORIAL_TARGET_IDS`): `homeStar` / `homeFleet` on the star + map (accent ring), `fleetShipProfiles` / `fleetCountControls` on the side + panel (yellow box). Panel regions resolve through a new + `VegaSidePanel.tutorialRegion(name)` — screen-space union of per-stack rects + recorded in `stackRow()` into `this._tutorRows` on every `rebuild()`, keyed + off `this.x0/this.y0` (the panel's resting position, so it is right even + while the panel is still sliding in). +- **Two modules, split like `VegaGnn` / `VegaGnnScreen`.** + `VegaTutorialData.js` is **Phaser-free** (schema validation, interpolation, + `TUTORIAL_TARGET_IDS`) so `tools/verifyMasterOfVega.js` imports it (section + 12). `VegaTutorial.js` is the Phaser half (overlay, callout, hotspot, + state machine). A highlightable thing needs an id in `TUTORIAL_TARGET_IDS` + **and** a `_resolveTarget` case in `VegaTutorial.js`. +- **Darken = four opaque strips framing one rectangular hole** (the union of + the step's resolved highlight rects, padded), NOT a mask cutout — every + target worth highlighting is rectangular. A pulsing accent ring is stroked + around each target on top. Empty `highlights` → one full-screen dim rect. +- **The map is frozen by `scene.modalOpen = true`** for the tutorial's whole + life. That one flag blocks star-map pan/zoom (`blockPointer`/`blockWheel`) + and every map/HUD handler — **but not the side panel's own controls** + (`detailHit`, `tinyButton`, `tinyCircleButton`, `openShipDetail` — none + check `modalOpen`), which is what lets the fleet-ship steps work: the hole + over the panel exposes real, clickable panel widgets (`input.topOnly` is on, + so the dark strips must genuinely not cover them — the four-strip hole does + exactly that). `centerOn(emp.homeStar)` on start. `finish()` restores + `modalOpen = false` + `refreshAll()`, mirroring `openModal`'s `done()`. +- **`D.tutorial = 75`** — deliberately just *below* `D.detail` (76). The + "these are your ships" step tells the player to click a ship profile, which + opens the real `openShipDetail` window; sitting below `D.detail` lets it + layer cleanly on top of the overlay. The step then advances when + `panel.detailOpen` goes true-then-false (polled in `VegaTutorial.update()`). +- The "click here to select them" hotspot is a transparent interactive rect + over the fleet marker; its handler does the real low-level selection + (`scene.selectedFleet = f; map.setSelectedFleet(f); panel.showFleet(f)`, + bypassing `onFleetClick`'s `modalOpen` guard) then `advance()`. +- The skip button opens a small confirm prompt drawn on the tutorial's own + container (not `openModal`), so it never touches `modalOpen`. Skip is a + per-step button in the JSON, present only on the intro step — once the + player clicks Next it is gone. A step with an empty `buttons[]` is legal + **only** when it has `advanceOn: "hotspot"` (the callout step advances by + clicking the fleet, nothing else); `validateTutorialData` enforces that. +- No `VegaLogic` change — runs every new game, so there is no "seen" flag to + serialize. A malformed JSON file is validated in `create()` and disables the + feature with a `console.warn` rather than crashing. + ## Files touched to register the game `src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js` diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index a67c5a2..6eb738b 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -148,6 +148,7 @@ export const MANIFEST = { // only the JSON does. mastervega: [ { type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' }, + { type: 'json', key: 'mastervega-tutorial', path: 'data/mastervega-tutorial.json' }, (scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']), (scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'), (scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'), diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 11e66f6..38bb15d 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -44,6 +44,8 @@ import { openCouncilSessionScreen } from './VegaCouncilSession.js'; import { openAudienceScreen } from './VegaAudience.js'; import { playIntroVideo } from './VegaIntroVideo.js'; import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js'; +import { VegaTutorial } from './VegaTutorial.js'; +import { validateTutorialData } from './VegaTutorialData.js'; const SAVE_KEY = 'mastervega-save'; // 10 manual slots, independent of the single SAVE_KEY auto-save above (which @@ -95,6 +97,17 @@ export default class MasterOfVegaGame extends Phaser.Scene { console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`); } + // Guided-tutorial script (data/mastervega-tutorial.json). A malformed file + // must never take the game down with it — log and disable the feature. + this.tutorialData = this.cache.json.get('mastervega-tutorial') ?? null; + if (this.tutorialData) { + const { ok, errors } = validateTutorialData(this.tutorialData); + if (!ok) { + console.warn('[MasterOfVega] tutorial disabled — invalid mastervega-tutorial.json:', errors); + this.tutorialData = null; + } + } + try { this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music')); } catch (err) { /* music is optional */ } @@ -129,6 +142,8 @@ export default class MasterOfVegaGame extends Phaser.Scene { } teardown() { + this.tutorial?.destroy(); + this.tutorial = null; resetSpeechQueue(); this.panel?.destroy(); this.map?.destroy(); @@ -663,6 +678,38 @@ export default class MasterOfVegaGame extends Phaser.Scene { this.buildHud(); this.refreshHud(); + + // A brand-new game (never a resumed/loaded one) opens with the guided + // tutorial. Runs every new game — there is no "seen" flag — and is also + // re-triggerable from the ☰ menu. + if (!savedState) this.startTutorial({ replay: false }); + } + + // ------------------------------------------------------------- tutorial + + startTutorial({ replay = false } = {}) { + if (this.tutorial || !this.tutorialData || !this.map || !this.panel) return; + if (replay && !this.canReplayTutorial()) return; + const emp = this.state.empires[this.state.humanIndex]; + const species = this.rules.species[emp?.speciesId]?.plural + ?? this.rules.species[emp?.speciesId]?.name ?? 'your people'; + this.tutorial = new VegaTutorial(this, { + data: this.tutorialData, + vars: { species }, + onFinish: () => { this.tutorial = null; }, + }); + this.tutorial.start(); + } + + /** The tutorial's fleet callout needs the untouched starting fleet in orbit + * at the homeworld — once it has moved or split there is nothing to point + * at, so the ☰ replay entry greys out. */ + canReplayTutorial() { + if (!this.tutorialData || !this.map || !this.panel || !this.state) return false; + const emp = this.state.empires[this.state.humanIndex]; + if (!emp) return false; + return this.state.fleets.some((f) => f.empireIdx === this.state.humanIndex + && f.starIdx === emp.homeStar && f.toStar < 0); } // ------------------------------------------------------------------ HUD @@ -768,6 +815,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { ['Return to Main Menu', () => this.returnToMainMenu()], ['Save', () => this.openSaveMenu()], ['Load', () => this.openLoadMenu(), !this.hasAnySaveSlot()], + ['Replay tutorial', () => this.startTutorial({ replay: true }), !this.canReplayTutorial()], ['Quit to Arcade', () => this.quitToArcade()], ]; @@ -1513,6 +1561,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { update(time, delta) { this.map?.update(time, delta); + this.tutorial?.update?.(time, delta); } // ------------------------------------------------------------ save/load diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index 18587b3..19fced6 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -570,6 +570,7 @@ export const speciesSpeechClip = (speciesId) => `vega/char-${speciesId}`; /** Non-species speech clips, addressed the same way. */ export const UI_SPEECH = { chooseSpecies: 'vega/ui-choose-start', + tutorialIntro: 'vega/tutorial-intro-01', }; export function hasSpeciesVideo(scene, speciesId) { diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 16c9124..9d778c5 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -30,9 +30,13 @@ export const FONT = '"Julius Sans One"'; // actually matters in practice; it sits next to gnn as the other full-screen // takeover. `intro` is the colony-founding vignette, which opens over the // system view it was triggered from and must cover everything except the -// end-of-game overlay. +// end-of-game overlay. `tutorial` is the guided-tutorial darken overlay — it +// sits above the HUD, side panel and modals, but DELIBERATELY just below +// `detail` so a ship-detail pop-over the tutorial itself invites the player +// to open layers cleanly on top of it; it only runs on a fresh turn-0 game, +// so nothing from `detail` up ever competes with it in practice. export const D = { - map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, detail: 76, intro: 78, toast: 80, + map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, tutorial: 75, detail: 76, intro: 78, toast: 80, }; /** diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index b68094d..07bc1c5 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -323,6 +323,9 @@ export default class VegaSidePanel { // The pool survives the wipe; claim what this pass actually uses and let // endFrame() hide and pause the rest. this.pool.beginFrame(); + // Per-stack local-coord rects the guided tutorial points at (ship + // profiles vs. the −/+/✕ cluster). Rebuilt every pass like the body. + this._tutorRows = []; this.y = 90; if (this.mode === 'star') this.buildStar(); else if (this.mode === 'fleet') this.buildFleet(); @@ -761,9 +764,55 @@ export default class VegaSidePanel { }).setOrigin(0.5); this.body.add(count); + // Tutorial anchors, in body-local coords (body sits at 0,0 in root). + const ctrlLeft = right - boxSize * 4.3 - boxSize / 2; + this._tutorRows.push({ + profiles: { + x: PAD - 4, + y: rowY - 4, + w: Math.min(name.x + name.width, ctrlLeft - 6) - (PAD - 4), + h: rowH + 8, + }, + counts: { + x: ctrlLeft - 4, + y: ctrlY - boxSize / 2 - 5, + w: (right + 4) - (ctrlLeft - 4), + h: boxSize + 10, + }, + }); + this.y = rowY + rowH + 10; } + /** + * Screen-space bounding box of a named region of the fleet view, for the + * guided tutorial's spotlight. `name` is 'shipProfiles' or 'countControls'; + * returns the union across every task-force stack, or null when the fleet + * view is not the thing on screen. Uses the panel's resting position + * (this.x0/this.y0), not this.root.x, so it is correct even while the panel + * is still sliding in. + */ + tutorialRegion(name) { + if (!this.root?.visible || this.mode !== 'fleet') return null; + const rows = this._tutorRows; + if (!rows || !rows.length) return null; + const key = name === 'shipProfiles' ? 'profiles' + : name === 'countControls' ? 'counts' : null; + if (!key) return null; + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + for (const r of rows) { + const b = r[key]; + x0 = Math.min(x0, b.x); + y0 = Math.min(y0, b.y); + x1 = Math.max(x1, b.x + b.w); + y1 = Math.max(y1, b.y + b.h); + } + return { x: this.x0 + x0, y: this.y0 + y0, w: x1 - x0, h: y1 - y0 }; + } + selectionSummary() { const { rules, state } = this; const ships = this.selectedShips(); diff --git a/src/games/mastervega/VegaTutorial.js b/src/games/mastervega/VegaTutorial.js new file mode 100644 index 0000000..0786875 --- /dev/null +++ b/src/games/mastervega/VegaTutorial.js @@ -0,0 +1,569 @@ +// Master of Vega — the in-game guided tutorial: overlay renderer + step driver. +// +// The tutorial SCRIPT is data (data/mastervega-tutorial.json, validated and +// interpolated by the headless VegaTutorialData.js). This file is the Phaser +// half: it darkens the screen except for a few highlighted things, shows a +// centred modal or an anchored callout window per step, plays the step's +// voice-over, and advances on a button or a click on the highlighted thing. +// +// While the tutorial is up it holds `scene.modalOpen = true` — the same flag +// the real modals use, which freezes star-map pan/zoom, every map click +// handler and every HUD button (all of them early-return on modalOpen). The +// side panel's OWN controls (ship rows, −/+/✕) do not check modalOpen, so a +// step whose hole sits over the panel still lets the player click those — that +// is deliberate (see the fleet-ship steps). +// +// The darken effect is four opaque strips framing one rectangular hole (the +// union of the step's highlight targets), NOT a mask cutout — trivial to lay +// out, and everything worth highlighting here is rectangular anyway. On top, +// each star-map target gets an accent ring and each side-panel region a +// yellow box. Targets that move (the panel rebuilds on every −/+ click) are +// tracked by a per-frame fingerprint in update() and re-laid-out on change. +// D.tutorial sits just below D.detail so a ship-detail pop-over the tutorial +// invites the player to open layers cleanly on top of the overlay. + +import * as Phaser from 'phaser'; +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { Button } from './VegaButton.js'; +import { FONT, D, uiClick } from './VegaScreens.js'; +import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js'; +import { resolveSteps } from './VegaTutorialData.js'; + +const ACCENT = 0x6fc4ff; +const HILITE_YELLOW = 0xffd400; +const PANEL = 0x0b1220; +const DIM_COLOR = 0x00060e; +const DIM_ALPHA = 0.72; +const HOLE_PAD = 18; + +// Keep windows clear of the docked right-hand command panel (400px wide, 16px +// gutter — see VegaSidePanel.js) even though it is hidden at tutorial time. +const SAFE_RIGHT = GAME_WIDTH - 440; + +function cornerTicks(scene, x, y, w, h, len = 22) { + const g = scene.add.graphics(); + g.lineStyle(2.5, ACCENT, 0.9); + for (const [cx, cy, dx, dy] of [ + [x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1], + ]) { + g.lineBetween(cx, cy, cx + dx * len, cy); + g.lineBetween(cx, cy, cx, cy + dy * len); + } + return g; +} + +export class VegaTutorial { + /** + * @param {Phaser.Scene} scene MasterOfVegaGame + * @param {object} opts + * @param {object} opts.data parsed + validated tutorial JSON + * @param {object} opts.vars { species: 'Humans', ... } for {token} substitution + * @param {Function} [opts.onFinish] + */ + constructor(scene, opts = {}) { + this.scene = scene; + this.opts = opts; + this.data = opts.data; + this.steps = []; + this.stepIndex = 0; + this.layer = null; + this._stepLayer = null; + this._skipLayer = null; + this._ringTween = null; + this._sawDetail = false; + this._voicedStep = -1; + this._dead = false; + this._started = false; + this._shutdown = () => this.destroy(); + } + + // --------------------------------------------------------------- lifecycle + + start() { + if (this._started || this._dead) return; + this._started = true; + + this.steps = resolveSteps(this.data, this.opts.vars || {}); + if (!this.steps.length) { this.finish(); return; } + + this.scene.modalOpen = true; + + const emp = this._humanEmpire(); + if (emp && this.scene.map?.centerOn) this.scene.map.centerOn(emp.homeStar); + + this.layer = this.scene.add.container(0, 0).setDepth(D.tutorial); + this.scene.events.once('shutdown', this._shutdown); + + this.stepIndex = 0; + this._renderStep(0); + } + + /** + * Per-frame housekeeping: advance a `shipDetailClosed` step once the player + * has opened and closed a ship detail window, and re-lay-out the current + * step if a highlighted target moved (the side panel rebuilds itself on + * every −/+ click; the map is frozen but this covers it too). + */ + update() { + if (this._dead || !this.layer || !this.steps.length || this._skipLayer) return; + const step = this.steps[this.stepIndex]; + if (!step) return; + + if (step.advanceOn === 'shipDetailClosed') { + const open = !!this.scene.panel?.detailOpen; + if (open) { + this._sawDetail = true; + } else if (this._sawDetail) { + this._sawDetail = false; + this.advance(); + return; + } + } + + const sig = this._targetsSig(step); + if (this._lastTargetsSig !== undefined && sig !== this._lastTargetsSig) { + this._lastTargetsSig = sig; + this._renderStep(this.stepIndex); + } + } + + /** A cheap fingerprint of where this step's targets currently sit, so a + * panel rebuild (or map move) triggers a re-layout. */ + _targetsSig(step) { + const ids = [...new Set([...(step.highlights || []), step.anchor].filter(Boolean))]; + return ids.map((id) => { + const t = this._resolveTarget(id); + return t + ? `${id}:${Math.round(t.cx)},${Math.round(t.cy)},${Math.round(t.rx)},${Math.round(t.ry)}` + : `${id}:none`; + }).join('|'); + } + + destroy() { + if (this._dead) return; + this._dead = true; + resetSpeechQueue(); + if (this._ringTween) { this._ringTween.stop(); this._ringTween = null; } + this.scene.events.off('shutdown', this._shutdown); + this.layer?.destroy(); + this.layer = null; + this._stepLayer = null; + this._skipLayer = null; + } + + // ------------------------------------------------------------ state machine + + advance() { + if (this.stepIndex + 1 >= this.steps.length) { this.finish(); return; } + this._sawDetail = false; + this.stepIndex += 1; + this._renderStep(this.stepIndex); + } + + back() { + if (this.stepIndex === 0) return; + this._sawDetail = false; + this.stepIndex -= 1; + this._renderStep(this.stepIndex); + } + + skip() { + if (this._skipLayer) return; + this._renderSkipConfirm(); + } + + finish() { + const onFinish = this.opts.onFinish; + this.destroy(); + this.scene.modalOpen = false; + this.scene.refreshAll?.(); + onFinish?.(); + } + + _onButton(action) { + switch (action) { + case 'next': this.advance(); break; + case 'back': this.back(); break; + case 'skip': this.skip(); break; + case 'finish': this.finish(); break; + default: break; + } + } + + // -------------------------------------------------------------- resolution + + _humanEmpire() { + const st = this.scene.state; + return st?.empires?.[st.humanIndex] ?? null; + } + + _homeFleet() { + const st = this.scene.state; + const emp = this._humanEmpire(); + if (!st || !emp) return null; + return st.fleets.find((f) => f.empireIdx === st.humanIndex + && f.starIdx === emp.homeStar && f.toStar < 0) ?? null; + } + + /** + * Target id -> { cx, cy, rx, ry, shape } in screen space, or null if it + * can't be found. `shape` is 'circle' (star-map things, drawn as an accent + * ring) or 'rect' (side-panel regions, drawn as a yellow box). + */ + _resolveTarget(id) { + const { map, state, panel } = this.scene; + const emp = this._humanEmpire(); + if (!state || !emp) return null; + + if (id === 'homeStar' || id === 'homeFleet') { + if (!map) return null; + const zoom = map.zoom ?? 1; + const toScreen = (wx, wy) => ({ x: map.root.x + wx * zoom, y: map.root.y + wy * zoom }); + + if (id === 'homeStar') { + const star = state.galaxy.stars[emp.homeStar]; + if (!star) return null; + const p = toScreen(star.x, star.y); + const clsR = map.starSprites?.[emp.homeStar]?.cls?.radius ?? 12; + const r = (clsR * 2.2 + 14) * zoom + 6; + return { cx: p.x, cy: p.y, rx: r, ry: r, shape: 'circle' }; + } + + const fleet = this._homeFleet(); + if (!fleet) return null; + const marker = map.fleetMarkers?.find((m) => m.fleet === fleet); + const star = state.galaxy.stars[emp.homeStar]; + let wx; + let wy; + if (marker?.container) { + wx = marker.container.x; + wy = marker.container.y; + } else if (star) { + wx = star.x + 26; + wy = star.y - 22; + } else { + return null; + } + const p = toScreen(wx, wy); + const r = 20 * zoom + 8; + return { cx: p.x, cy: p.y, rx: r, ry: r, shape: 'circle' }; + } + + if (id === 'fleetShipProfiles' || id === 'fleetCountControls') { + const region = id === 'fleetShipProfiles' ? 'shipProfiles' : 'countControls'; + const rect = panel?.tutorialRegion?.(region); + if (!rect) return null; + return { + cx: rect.x + rect.w / 2, + cy: rect.y + rect.h / 2, + rx: rect.w / 2, + ry: rect.h / 2, + shape: 'rect', + }; + } + + return null; + } + + /** A resolved target reduced to the point + radius the callout aims at. */ + static _anchorPoint(t) { + return { x: t.cx, y: t.cy, r: Math.max(t.rx, t.ry) }; + } + + // ----------------------------------------------------------------- render + + _clearStep() { + if (this._ringTween) { this._ringTween.stop(); this._ringTween = null; } + this._skipLayer?.destroy(); + this._skipLayer = null; + this._stepLayer?.destroy(); + this._stepLayer = null; + this._hotspot = null; + } + + _renderStep(i) { + if (this._dead || !this.layer) return; + // A pure re-layout (update() saw a target move) must not restart the + // voice — only a genuine step change does. + const freshStep = this._voicedStep !== i; + if (freshStep) resetSpeechQueue(); + this._clearStep(); + const step = this.steps[i]; + if (!step) { this.finish(); return; } + + const sl = this.scene.add.container(0, 0); + this._stepLayer = sl; + this.layer.add(sl); + + // --- darken, with a hole around the highlighted targets + const targets = (step.highlights || []) + .map((id) => this._resolveTarget(id)) + .filter(Boolean); + this._drawDim(sl, targets); + this._drawRings(sl, targets); + + // --- the step's window + if (step.kind === 'callout') this._renderCallout(sl, step); + else this._renderModal(sl, step); + + // --- voice (once per step, not on a re-layout) + if (freshStep) { + this._voicedStep = i; + if (step.voice) enqueueSpeech(step.voice, null, { force: true }); + } + + this._lastTargetsSig = this._targetsSig(step); + } + + _drawDim(sl, targets) { + const strip = (x, y, w, h) => { + if (w <= 0 || h <= 0) return; + sl.add(this.scene.add.rectangle(x, y, w, h, DIM_COLOR, DIM_ALPHA) + .setOrigin(0, 0).setInteractive()); + }; + + if (!targets.length) { + strip(0, 0, GAME_WIDTH, GAME_HEIGHT); + return; + } + + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + for (const t of targets) { + x0 = Math.min(x0, t.cx - t.rx); + y0 = Math.min(y0, t.cy - t.ry); + x1 = Math.max(x1, t.cx + t.rx); + y1 = Math.max(y1, t.cy + t.ry); + } + x0 = Math.max(0, x0 - HOLE_PAD); + y0 = Math.max(0, y0 - HOLE_PAD); + x1 = Math.min(GAME_WIDTH, x1 + HOLE_PAD); + y1 = Math.min(GAME_HEIGHT, y1 + HOLE_PAD); + + strip(0, 0, GAME_WIDTH, y0); // top + strip(0, y1, GAME_WIDTH, GAME_HEIGHT - y1); // bottom + strip(0, y0, x0, y1 - y0); // left + strip(x1, y0, GAME_WIDTH - x1, y1 - y0); // right + } + + _drawRings(sl, targets) { + if (!targets.length) return; + const g = this.scene.add.graphics(); + sl.add(g); + const paint = (alpha) => { + g.clear(); + for (const t of targets) { + if (t.shape === 'rect') { + g.lineStyle(3, HILITE_YELLOW, alpha); + g.strokeRect(t.cx - t.rx, t.cy - t.ry, t.rx * 2, t.ry * 2); + } else { + g.lineStyle(2.5, ACCENT, alpha); + g.strokeCircle(t.cx, t.cy, Math.max(t.rx, t.ry)); + } + } + }; + paint(0.9); + const pulse = { a: 0.9 }; + this._ringTween = this.scene.tweens.add({ + targets: pulse, + a: 0.35, + duration: 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + onUpdate: () => paint(pulse.a), + }); + } + + /** Shared window chrome: a panel container centred on (cx, cy). */ + _window(sl, w, h, cx, cy) { + const x = Math.round(cx - w / 2); + const y = Math.round(cy - h / 2); + const c = this.scene.add.container(0, 0); + sl.add(c); + c.add(this.scene.add.rectangle(x, y, w, h, PANEL, 0.97).setOrigin(0, 0) + .setStrokeStyle(1.5, ACCENT, 0.65).setInteractive()); + c.add(cornerTicks(this.scene, x, y, w, h, 22)); + return { c, x, y, w, h }; + } + + _addButtons(container, step, baseX, baseY) { + const gap = 22; + const bw = 220; + const bh = 54; + const total = step.buttons.length * bw + (step.buttons.length - 1) * gap; + let bx = baseX - total / 2 + bw / 2; + for (const b of step.buttons) { + const variant = b.action === 'skip' ? 'ghost' : 'solid'; + const btn = new Button(this.scene, bx, baseY, b.label, + uiClick(this.scene, () => this._onButton(b.action)), + { width: bw, height: bh, fontSize: 20, variant }); + container.add(btn); + bx += bw + gap; + } + } + + _renderModal(sl, step) { + const w = 980; + const bodyText = this.scene.add.text(0, 0, step.body ?? '', { + fontFamily: FONT, fontSize: '22px', color: '#e8f4ff', + lineSpacing: 6, align: 'left', wordWrap: { width: w - 96 }, + }); + const hasTitle = !!(step.title && step.title.trim()); + const titleH = hasTitle ? 46 : 0; + const h = Math.max(240, titleH + 64 + bodyText.height + 110); + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const win = this._window(sl, w, h, cx, cy); + + let ty = win.y + 34; + if (hasTitle) { + win.c.add(this.scene.add.text(win.x + 48, ty, step.title.toUpperCase(), { + fontFamily: FONT, fontSize: '26px', color: '#ffd88a', + })); + ty += titleH; + } + bodyText.setPosition(win.x + 48, ty); + win.c.add(bodyText); + + this._addButtons(win.c, step, cx, win.y + h - 44); + } + + _renderCallout(sl, step) { + const target = this._resolveTarget(step.anchor); + const anchor = target ? VegaTutorial._anchorPoint(target) : null; + const w = 460; + + const textObj = this.scene.add.text(0, 0, step.calloutText ?? '', { + fontFamily: FONT, fontSize: '19px', color: '#e8f4ff', + lineSpacing: 5, wordWrap: { width: w - 64 }, + }); + const btnRow = step.buttons.length ? 74 : 24; + const h = Math.max(120, 40 + textObj.height + btnRow); + + // Default below-right of the anchor; flip/clamp to stay on screen and + // clear of the right-hand command panel (SAFE_RIGHT), so a callout + // anchored to a panel region lands to its left. + let cx = (anchor?.x ?? GAME_WIDTH / 2) + 40 + w / 2; + let cy = (anchor?.y ?? GAME_HEIGHT / 2) + 60 + h / 2; + if (cx + w / 2 > SAFE_RIGHT) cx = (anchor?.x ?? GAME_WIDTH / 2) - 40 - w / 2; + cx = Phaser.Math.Clamp(cx, w / 2 + 20, SAFE_RIGHT - w / 2); + cy = Phaser.Math.Clamp(cy, h / 2 + 20, GAME_HEIGHT - h / 2 - 20); + + const win = this._window(sl, w, h, cx, cy); + + // connector line + arrowhead from the anchor to the nearest window edge + if (anchor) this._drawConnector(win.c, anchor, win); + + textObj.setPosition(win.x + 32, win.y + 26); + win.c.add(textObj); + + if (step.buttons.length) this._addButtons(win.c, step, cx, win.y + h - 40); + + // click-the-thing hotspot + if (step.advanceOn === 'hotspot' && anchor) { + const size = Math.max(44, anchor.r * 2); + const hs = this.scene.add.rectangle(anchor.x, anchor.y, size, size, 0xffffff, 0.001) + .setInteractive({ useHandCursor: true }); + hs.on('pointerup', () => this._onHotspot(step)); + sl.add(hs); + this._hotspot = hs; + } + } + + _drawConnector(container, anchor, win) { + // Aim at the window-edge point closest to the anchor. + const tx = Phaser.Math.Clamp(anchor.x, win.x, win.x + win.w); + const ty = Phaser.Math.Clamp(anchor.y, win.y, win.y + win.h); + const ax = anchor.x; + const ay = anchor.y; + const dx = tx - ax; + const dy = ty - ay; + const len = Math.hypot(dx, dy); + const g = this.scene.add.graphics(); + container.add(g); + if (len < 2) return; + const ux = dx / len; + const uy = dy / len; + + const dash = 18; + const gap = 12; + const period = dash + gap; + g.lineStyle(3, 0x9fd8ff, 0.9); + // start a little outside the anchor ring so the line doesn't cross it + for (let d = anchor.r + 4; d < len; d += period) { + const s = d; + const e = Math.min(d + dash, len); + if (e <= s) continue; + g.beginPath(); + g.moveTo(ax + ux * s, ay + uy * s); + g.lineTo(ax + ux * e, ay + uy * e); + g.strokePath(); + } + // arrowhead at the window edge, pointing at it + const ang = Math.atan2(dy, dx); + const ah = 12; + const tip = { x: tx, y: ty }; + g.fillStyle(0x9fd8ff, 0.95); + g.beginPath(); + g.moveTo(tip.x, tip.y); + g.lineTo(tip.x - Math.cos(ang - 2.6) * ah, tip.y - Math.sin(ang - 2.6) * ah); + g.lineTo(tip.x - Math.cos(ang + 2.6) * ah, tip.y - Math.sin(ang + 2.6) * ah); + g.closePath(); + g.fillPath(); + } + + _onHotspot(step) { + if (step.anchor === 'homeFleet') { + const fleet = this._homeFleet(); + const scene = this.scene; + if (fleet) { + scene.selectedFleet = fleet; + scene.map?.setSelectedFleet?.(fleet); + scene.panel?.showFleet?.(fleet); + } + // Let the panel finish sliding in before the next step lays its + // spotlight over it. + if (this._hotspot) { this._hotspot.disableInteractive(); this._hotspot = null; } + this.scene.time.delayedCall(240, () => { if (!this._dead) this.advance(); }); + return; + } + this.advance(); + } + + // ------------------------------------------------------------- skip prompt + + _renderSkipConfirm() { + const cfg = this.data.confirmSkip || {}; + const sk = this.scene.add.container(0, 0); + this._skipLayer = sk; + this.layer.add(sk); + + sk.add(this.scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, DIM_COLOR, 0.55) + .setOrigin(0, 0).setInteractive()); + + const w = 640; + const h = 260; + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const x = cx - w / 2; + const y = cy - h / 2; + sk.add(this.scene.add.rectangle(x, y, w, h, PANEL, 0.98).setOrigin(0, 0) + .setStrokeStyle(1.5, ACCENT, 0.65).setInteractive()); + sk.add(cornerTicks(this.scene, x, y, w, h, 22)); + + sk.add(this.scene.add.text(cx, y + 72, cfg.body ?? 'Skip the tutorial?', { + fontFamily: FONT, fontSize: '22px', color: '#e8f4ff', + align: 'center', wordWrap: { width: w - 80 }, + }).setOrigin(0.5)); + + sk.add(new Button(this.scene, cx - 130, y + h - 60, cfg.confirmLabel ?? 'Skip tutorial', + uiClick(this.scene, () => this.finish()), + { width: 220, height: 52, fontSize: 19, variant: 'ghost' })); + sk.add(new Button(this.scene, cx + 130, y + h - 60, cfg.cancelLabel ?? 'Keep learning', + uiClick(this.scene, () => { this._skipLayer?.destroy(); this._skipLayer = null; }), + { width: 220, height: 52, fontSize: 19 })); + } +} diff --git a/src/games/mastervega/VegaTutorialData.js b/src/games/mastervega/VegaTutorialData.js new file mode 100644 index 0000000..1d5d313 --- /dev/null +++ b/src/games/mastervega/VegaTutorialData.js @@ -0,0 +1,172 @@ +// Master of Vega — tutorial data: schema validation, text interpolation, and +// the highlight-target id registry. Headless, no Phaser imports, so it runs in +// Node (tools/verifyMasterOfVega.js) exactly like VegaTurnReport.js. +// +// The tutorial SCRIPT lives in data/mastervega-tutorial.json — an ordered list +// of steps, each with body/callout text, button labels, a voice clip, and a +// list of highlight-target ids. This file knows nothing about Phaser or the +// scene: it validates the JSON shape, substitutes {token} placeholders, and +// pins the set of target ids the renderer (VegaTutorial.js) knows how to +// resolve to on-screen positions. Anything the renderer can point at has to be +// listed in TUTORIAL_TARGET_IDS here AND have a resolver case in VegaTutorial. + +// Every id a step may name in `highlights` or `anchor`. VegaTutorial.js has a +// _resolveTarget() case for each. Adding a new highlightable thing later means +// adding its id here and a resolver there. `homeStar`/`homeFleet` are on the +// star map; `fleetShipProfiles`/`fleetCountControls` are regions of the side +// panel's fleet view, resolved through VegaSidePanel.tutorialRegion(). +export const TUTORIAL_TARGET_IDS = Object.freeze([ + 'homeStar', 'homeFleet', 'fleetShipProfiles', 'fleetCountControls', +]); + +const STEP_KINDS = Object.freeze(['modal', 'callout']); +const BUTTON_ACTIONS = Object.freeze(['next', 'back', 'skip', 'finish']); +// Ways a step can progress WITHOUT a button. `hotspot` = a click on the lit +// anchor; `shipDetailClosed` = the player opened and then closed a ship detail +// window. A step with no buttons must declare one of these or it is a dead end. +export const ADVANCE_MODES = Object.freeze(['hotspot', 'shipDetailClosed']); + +const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g; + +/** + * Replace every `{token}` in `str` with `vars[token]`. Tokens with no matching + * key are left untouched (the verifier separately flags any token not declared + * in the file's top-level `vars` allow-list, so an unresolved placeholder is a + * build error, not a silent runtime gap). + */ +export function interpolate(str, vars = {}) { + if (typeof str !== 'string') return str; + return str.replace(PLACEHOLDER_RE, (whole, token) => + (Object.prototype.hasOwnProperty.call(vars, token) ? String(vars[token]) : whole)); +} + +/** Every distinct `{token}` appearing anywhere in `str`. */ +export function placeholdersIn(str) { + if (typeof str !== 'string') return []; + const out = new Set(); + let m; + PLACEHOLDER_RE.lastIndex = 0; + // eslint-disable-next-line no-cond-assign + while ((m = PLACEHOLDER_RE.exec(str))) out.add(m[1]); + return [...out]; +} + +const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0; + +/** + * Validate a parsed tutorial JSON. Returns `{ ok, errors }` — `errors` is a + * list of human-readable strings, empty when `ok` is true. Deliberately + * permissive about unknown extra fields (forward-compatible) but strict about + * every field the renderer actually reads. + */ +export function validateTutorialData(json) { + const errors = []; + const fail = (msg) => errors.push(msg); + + if (!json || typeof json !== 'object') { + return { ok: false, errors: ['tutorial JSON is not an object'] }; + } + + if (json.version !== 1) fail(`version must be 1 (got ${JSON.stringify(json.version)})`); + + const vars = Array.isArray(json.vars) ? json.vars : []; + if (!Array.isArray(json.vars)) fail('`vars` must be an array of allowed placeholder names'); + + const cs = json.confirmSkip; + if (!cs || typeof cs !== 'object') { + fail('`confirmSkip` must be an object'); + } else { + if (!isNonEmptyString(cs.body)) fail('`confirmSkip.body` must be a non-empty string'); + if (!isNonEmptyString(cs.confirmLabel)) fail('`confirmSkip.confirmLabel` must be a non-empty string'); + if (!isNonEmptyString(cs.cancelLabel)) fail('`confirmSkip.cancelLabel` must be a non-empty string'); + } + + if (!Array.isArray(json.steps) || json.steps.length === 0) { + fail('`steps` must be a non-empty array'); + return { ok: errors.length === 0, errors }; + } + + const seenIds = new Set(); + json.steps.forEach((step, i) => { + const at = `steps[${i}]`; + if (!step || typeof step !== 'object') { fail(`${at} is not an object`); return; } + + if (!isNonEmptyString(step.id)) fail(`${at}.id must be a non-empty string`); + else if (seenIds.has(step.id)) fail(`${at}.id "${step.id}" is duplicated`); + else seenIds.add(step.id); + + if (!STEP_KINDS.includes(step.kind)) { + fail(`${at}.kind must be one of ${STEP_KINDS.join('/')} (got ${JSON.stringify(step.kind)})`); + } + + if (step.voice !== null && step.voice !== undefined && !isNonEmptyString(step.voice)) { + fail(`${at}.voice must be null or a non-empty string`); + } + + if (step.kind === 'modal' && !isNonEmptyString(step.body)) { + fail(`${at} (modal) must have a non-empty body`); + } + if (step.kind === 'callout') { + if (!isNonEmptyString(step.calloutText)) fail(`${at} (callout) must have a non-empty calloutText`); + if (!TUTORIAL_TARGET_IDS.includes(step.anchor)) { + fail(`${at} (callout) anchor must be one of ${TUTORIAL_TARGET_IDS.join('/')} (got ${JSON.stringify(step.anchor)})`); + } + } + + const highlights = step.highlights ?? []; + if (!Array.isArray(highlights)) { + fail(`${at}.highlights must be an array`); + } else { + highlights.forEach((h) => { + if (!TUTORIAL_TARGET_IDS.includes(h)) fail(`${at}.highlights has unknown target id ${JSON.stringify(h)}`); + }); + } + + if (step.advanceOn !== undefined && !ADVANCE_MODES.includes(step.advanceOn)) { + fail(`${at}.advanceOn, when set, must be one of ${ADVANCE_MODES.join('/')} (got ${JSON.stringify(step.advanceOn)})`); + } + if (step.advanceOn === 'hotspot' && !TUTORIAL_TARGET_IDS.includes(step.anchor)) { + fail(`${at}.advanceOn "hotspot" needs a valid anchor`); + } + + if (!Array.isArray(step.buttons)) { + fail(`${at}.buttons must be an array`); + } else if (step.buttons.length === 0 && !ADVANCE_MODES.includes(step.advanceOn)) { + // An empty button row is only legal when the step can be advanced some + // other way — otherwise the player is stuck with nowhere to click. + fail(`${at}.buttons is empty but the step has no advanceOn (${ADVANCE_MODES.join('/')}) to progress`); + } else { + step.buttons.forEach((b, bi) => { + if (!b || typeof b !== 'object') { fail(`${at}.buttons[${bi}] is not an object`); return; } + if (!BUTTON_ACTIONS.includes(b.action)) { + fail(`${at}.buttons[${bi}].action must be one of ${BUTTON_ACTIONS.join('/')} (got ${JSON.stringify(b.action)})`); + } + if (!isNonEmptyString(b.label)) fail(`${at}.buttons[${bi}].label must be a non-empty string`); + }); + } + + // Every {token} used in visible text must be declared in `vars`. + for (const field of ['title', 'body', 'calloutText']) { + for (const token of placeholdersIn(step[field])) { + if (!vars.includes(token)) fail(`${at}.${field} uses undeclared placeholder {${token}} (add it to top-level "vars")`); + } + } + }); + + return { ok: errors.length === 0, errors }; +} + +/** + * Produce the runtime step list: a shallow copy of `json.steps` with `title`, + * `body` and `calloutText` interpolated against `vars`, and `highlights` + * defaulted to `[]`. Assumes the data already passed validateTutorialData. + */ +export function resolveSteps(json, vars = {}) { + return (json.steps ?? []).map((step) => ({ + ...step, + title: interpolate(step.title, vars), + body: interpolate(step.body, vars), + calloutText: interpolate(step.calloutText, vars), + highlights: step.highlights ?? [], + })); +} diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 491a66b..189e09d 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -56,12 +56,19 @@ import * as Gnn from '../src/games/mastervega/VegaGnn.js'; import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js'; // Dependency-free, so what the game room eagerly pulls is checkable here. import { resolveGameAssets } from '../src/data/assetManifest.js'; +// Guided-tutorial data: schema/interpolation/target-id registry are Phaser-free +// (VegaTutorial.js is the Phaser half), so the script itself is checkable here. +import { + validateTutorialData, resolveSteps, interpolate, placeholdersIn, + TUTORIAL_TARGET_IDS, ADVANCE_MODES, +} from '../src/games/mastervega/VegaTutorialData.js'; const QUICK = process.argv.includes('--quick'); const gamesArg = process.argv.find((a) => a.startsWith('--games=')); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const rulesJson = JSON.parse(readFileSync(join(root, 'data/mastervega-rules.json'), 'utf8')); const artJson = JSON.parse(readFileSync(join(root, 'data/mastervega-artwork.json'), 'utf8')); +const tutorialJson = JSON.parse(readFileSync(join(root, 'data/mastervega-tutorial.json'), 'utf8')); let failures = 0; let passes = 0; @@ -5925,6 +5932,77 @@ section('11. Combat V2 (per-ship prototype)'); } } +// --------------------------------------------------------------------------- +section('12. Tutorial data'); +// --------------------------------------------------------------------------- +{ + const { ok, errors } = validateTutorialData(tutorialJson); + check('mastervega-tutorial.json passes schema validation', ok, errors.join('; ')); + check('tutorial version is 1', tutorialJson.version === 1, `${tutorialJson.version}`); + + const steps = Array.isArray(tutorialJson.steps) ? tutorialJson.steps : []; + check('tutorial has at least one step', steps.length > 0); + check('tutorial step ids are unique and non-empty', + new Set(steps.map((s) => s.id)).size === steps.length && steps.every((s) => typeof s.id === 'string' && s.id.trim())); + check('every tutorial step kind is modal or callout', + steps.every((s) => s.kind === 'modal' || s.kind === 'callout')); + check('every modal step has a body and at least one button', + steps.filter((s) => s.kind === 'modal').every((s) => typeof s.body === 'string' && s.body.trim() && Array.isArray(s.buttons) && s.buttons.length)); + check('every callout step has calloutText and a valid anchor', + steps.filter((s) => s.kind === 'callout').every((s) => + typeof s.calloutText === 'string' && s.calloutText.trim() && TUTORIAL_TARGET_IDS.includes(s.anchor))); + check('every highlight id is a known tutorial target', + steps.every((s) => (s.highlights ?? []).every((h) => TUTORIAL_TARGET_IDS.includes(h)))); + check('every button action is next/back/skip/finish', + steps.every((s) => (s.buttons ?? []).every((b) => ['next', 'back', 'skip', 'finish'].includes(b.action)))); + check('every advanceOn (when set) is a known mode', + steps.every((s) => s.advanceOn === undefined || ADVANCE_MODES.includes(s.advanceOn))); + check('every buttonless step can still be advanced (advanceOn set)', + steps.every((s) => (s.buttons ?? []).length > 0 || ADVANCE_MODES.includes(s.advanceOn)), + steps.filter((s) => !(s.buttons ?? []).length && !ADVANCE_MODES.includes(s.advanceOn)).map((s) => s.id).join(',')); + + // Every non-null voice clip resolves on disk (same streamed-from-assets/speech + // contract as the species clips checked in section 2). + for (const s of steps) { + if (!s.voice) continue; + check(`tutorial step "${s.id}" voice clip exists`, + existsSync(join(root, 'assets/speech', `${s.voice}.mp3`)), `${s.voice}.mp3`); + } + check('a tutorial step uses the shipped intro clip vega/tutorial-intro-01', + steps.some((s) => s.voice === 'vega/tutorial-intro-01')); + + // Every {token} in visible text is declared in the top-level vars allow-list. + const vars = tutorialJson.vars ?? []; + for (const s of steps) { + for (const field of ['title', 'body', 'calloutText']) { + for (const tok of placeholdersIn(s[field])) { + check(`tutorial step "${s.id}" ${field} placeholder {${tok}} is declared in vars`, vars.includes(tok)); + } + } + } + + // interpolate substitutes and leaves nothing behind. + const sample = interpolate('the {species} thrive', { species: 'Humans' }); + check('interpolate substitutes a declared token', sample === 'the Humans thrive', sample); + const resolved = resolveSteps(tutorialJson, { species: 'Humans' }); + check('resolveSteps leaves no {token} in any visible field', + resolved.every((s) => !['title', 'body', 'calloutText'] + .some((f) => typeof s[f] === 'string' && /\{[a-zA-Z0-9_]+\}/.test(s[f])))); + + // The lazy manifest exposes the file so scene.cache.json.get('mastervega-tutorial') works. + { + const stub = { + cache: { json: { get: (k) => (k === 'mastervega-tutorial' ? tutorialJson : null) } }, + textures: { exists: () => false }, + }; + const eager = resolveGameAssets(stub, 'mastervega'); + check('mastervega-tutorial.json is in the lazy asset manifest', + eager.some((d) => d.type === 'json' && d.key === 'mastervega-tutorial')); + } + + check('UI_SPEECH declares the tutorial intro clip', UI_SPEECH.tutorialIntro === 'vega/tutorial-intro-01'); +} + // --------------------------------------------------------------------------- console.log(`\n${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);