diff --git a/src/components/student-space/world/WorldInteractions.tsx b/src/components/student-space/world/WorldInteractions.tsx index 527799a4..a591e370 100644 --- a/src/components/student-space/world/WorldInteractions.tsx +++ b/src/components/student-space/world/WorldInteractions.tsx @@ -15,48 +15,54 @@ import { getPreset } from '~/lib/student-space/camera-tuner' import { cn } from '~/lib/utils' const GREETINGS = { - morning: ["You're back. The island's been quiet.", "Morning. The wind's from the east today."], + morning: [ + 'What are you hoping goes well at school today?', + "Is there anything at school you're thinking about this morning?", + ], afternoon: [ - "Good — I was hoping you'd come by.", - 'The fruit on the southwest tree is heavier than yesterday.', + "What happened at school today that you're still thinking about?", + 'Did anyone make your day a little better?', + ], + evening: [ + 'What part of school is still on your mind?', + "Was there something you wanted to say today but didn't?", ], - evening: ["Late one. I'll keep my voice down.", "Hey. The light's getting soft."], - any: ['Settling in?', "Take your time. I'll be on the branch."], + any: ['What made you laugh today?', "What's something you did well today?"], } const FIRST_ARRIVAL_GREETINGS = [ - "There you are. I've been on this branch a while.", - "Welcome. The island's quiet — but it's listening.", - "The sky was waiting for someone. That's you.", - "Take a look around. Nothing's growing yet, and that's okay.", + 'How did school go today?', + 'What was the best part of your day?', + 'Did anything at school surprise you today?', + "What's one thing you want to remember from today?", ] const INVITES = [ - 'Anything pull at you today?', - 'What was the loudest part of today?', - 'If you had to describe today as a kind of weather — what would it be?', - 'Something stuck with you. I can usually tell. What was it?', - "Small thing. Big thing. Either's fine.", - 'Did anything surprise you today?', - "Anything you'd want me to remember?", - 'What did you do that felt like *you*?', - 'If today had a color, what would it be?', - 'Heavy day or light one?', + "What happened at school today that you're still thinking about?", + 'What made you laugh today?', + 'Did anyone make your day a little better?', + 'Was there a class that felt harder than usual?', + 'Did anything happen that you wish had gone differently?', + "What's something you did well today?", + "Was there something you wanted to say but didn't?", + 'What part of today felt the longest?', + 'How did things go with your friends today?', + "Is there anything about tomorrow you're already thinking about?", ] const SOFT_INVITES = [ - "Quiet day. That's okay.", - "I'm not asking anything today.", - 'Just here if you want company.', - "The wind's gentle today.", - "Take your time. I'll be on the branch.", - 'We can both just sit for a bit.', - "I noticed. That's all.", + 'Was today harder than usual?', + 'Is there one part of today you want to talk about?', + 'Did anything at school leave you feeling tired?', + 'Was there a moment today you wish had gone differently?', + 'Did anyone help make today a little easier?', + 'Is there something you wish someone understood?', + 'What would make tomorrow feel a little easier?', ] const KIRA_NARRATION = { - text: 'It’s me. If anything is on your mind, I’m here. Choose whatever feels easiest — words, voice, a feeling, or a picture.', - cta: 'Talk to me', + text: 'How did school go today? You can answer with words, your voice, a feeling, or a picture.', + cta: 'Tell me', } const SPECIES_LINE: Record = { @@ -139,6 +145,7 @@ type NarratorState = { name: string text: string cta: string + dismissible: boolean } type HoverCtaState = { @@ -170,7 +177,13 @@ type ObjectPickupState = { } const INITIAL_BUBBLE: KiraBubbleState = { visible: false, text: '', x: 0, y: 0, hidden: false } -const INITIAL_NARRATOR: NarratorState = { open: false, name: 'Kira', text: '', cta: 'Open' } +const INITIAL_NARRATOR: NarratorState = { + open: false, + name: 'Kira', + text: '', + cta: 'Open', + dismissible: true, +} const INITIAL_HOVER_CTA: HoverCtaState = { open: false, eyebrow: '', @@ -524,8 +537,9 @@ class KiraNarratorController { _kiraTurn: KiraTurnState | null = null _kiraRestYaw: number | null = null _speakConfirm: (() => void) | null = null + _speakDismissible = true _onKeyDown = (event: KeyboardEvent) => { - if (this.isActive && event.key === 'Escape') this.close() + if (this.isActive && this._speakDismissible && event.key === 'Escape') this.close() } constructor( @@ -550,8 +564,14 @@ class KiraNarratorController { narrate(target: Target) { if (!target) return this.target = target + this._speakDismissible = true const narration = narrationFor(target, this.state) - this.setNarrator((prev) => ({ ...prev, cta: narration.cta, text: '' })) + this.setNarrator((prev) => ({ + ...prev, + cta: narration.cta, + text: '', + dismissible: true, + })) this._scheduleType(narration.text, 260) this.view.kiraDialogue?.hide?.() @@ -608,23 +628,28 @@ class KiraNarratorController { * * `cta` is optional: omit it to render the panel as read-only text, with * only the close X for dismissal. `onConfirm` fires when the CTA is tapped. + * Set `dismissible` false for a required acknowledgement: the close X and + * Escape dismissal are suppressed, while programmatic close still works. */ speak({ text, cta = '', name, onConfirm, + dismissible = true, }: { text: string cta?: string name?: string onConfirm?: () => void + dismissible?: boolean }) { this.target = null this._speakConfirm = onConfirm ?? null + this._speakDismissible = dismissible if (this.isActive) { - this.setNarrator((prev) => ({ ...prev, cta, text: '' })) + this.setNarrator((prev) => ({ ...prev, cta, text: '', dismissible })) this._scheduleType(text, 0) return } @@ -634,6 +659,7 @@ class KiraNarratorController { cta, text: '', name: name ?? this.state?.profile?.displayCompanionName?.() ?? 'Kira', + dismissible, })) this._scheduleType(text, 180) @@ -696,6 +722,7 @@ class KiraNarratorController { this.isActive = false this.typerId += 1 this._speakConfirm = null + this._speakDismissible = true this._clearTimers() this.setNarrator((prev) => ({ ...prev, open: false })) if (keepFraming) { @@ -1710,22 +1737,20 @@ function KiraBubble({ state, onDismiss }: { state: KiraBubbleState; onDismiss: ( transform: `translate(calc(${Math.round(state.x)}px - 50%), calc(${Math.round(state.y)}px - 100% - 8px))`, }} className={cn( - 'pointer-events-auto fixed left-0 top-0 z-[54] max-w-[240px] rounded-2xl bg-white px-3.5 py-2.5 text-left font-sans text-[13.5px] leading-[1.45] font-medium text-[#2b2620]', - 'shadow-[0_2px_4px_rgba(40,30,20,0.06),0_12px_30px_rgba(40,30,20,0.14)] ring-1 ring-black/5', - 'transition-[opacity,transform] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none', + 'pointer-events-auto fixed left-0 top-0 z-[54] max-w-[210px] rounded-[14px] bg-[#fffaf0]/96 px-3 py-2 text-left font-sans text-[13px] leading-[1.4] font-normal text-[#352f28]', + 'shadow-[0_4px_14px_rgba(40,30,20,0.12)] ring-1 ring-black/8', + 'transition-[opacity,transform,background-color] duration-(--duration-base) ease-(--ease-out) hover:bg-[#fffdf7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#7d654d]/55 focus-visible:ring-offset-2 motion-reduce:transition-none', // Hide the bubble when a capture sheet or chooser covers the // world — those surfaces sit above z-[54] but the bubble still // bleeds through their backdrop. Onboarding intentionally reuses // this bubble for the bird's dialogue, so don't hide on // `is-onboarding`. '[body.has-capture-sheet_&]:hidden [body.has-chooser_&]:hidden', - // Caret as a downward-pointing triangle. clip-path is more reliable - // than border-triangles in Tailwind (no border-style needed). - // ::before paints the outline (matched to ring-black/5), ::after - // sits 1px higher so the white fill covers all but a 1px rim. - 'before:absolute before:left-1/2 before:top-full before:h-[7px] before:w-[18px] before:-translate-x-1/2 before:-translate-y-px before:bg-black/5', + // Caret as a downward-pointing triangle. ::before paints the + // subtle outline; ::after sits 1px higher to reveal a narrow rim. + 'before:absolute before:left-1/2 before:top-full before:h-[6px] before:w-[14px] before:-translate-x-1/2 before:-translate-y-px before:bg-black/8', 'before:[clip-path:polygon(0_0,100%_0,50%_100%)]', - 'after:absolute after:left-1/2 after:top-full after:h-[7px] after:w-[18px] after:-translate-x-1/2 after:-translate-y-[2px] after:bg-white', + 'after:absolute after:left-1/2 after:top-full after:h-[6px] after:w-[14px] after:-translate-x-1/2 after:-translate-y-[2px] after:bg-[#fffaf0]', 'after:[clip-path:polygon(0_0,100%_0,50%_100%)]', state.visible ? 'scale-100 opacity-100' : 'pointer-events-none scale-95 opacity-0', )} @@ -1814,14 +1839,16 @@ function NarratorPanel({
{state.name}
- + {state.dismissible ? ( + + ) : null}

{state.text}

{state.cta ? (
diff --git a/src/engine/student-space/Game/State/Island.d.ts b/src/engine/student-space/Game/State/Island.d.ts index a7d71817..44ad364a 100644 --- a/src/engine/student-space/Game/State/Island.d.ts +++ b/src/engine/student-space/Game/State/Island.d.ts @@ -28,5 +28,6 @@ export default class Island { isPlaceable(x: number, z: number, inset?: number): boolean landCells(): LandCell[] placeableCells(): LandCell[] + elevatedPlaceableCells(): LandCell[] } diff --git a/src/engine/student-space/Game/State/Island.js b/src/engine/student-space/Game/State/Island.js index 78c48ace..1bd06984 100644 --- a/src/engine/student-space/Game/State/Island.js +++ b/src/engine/student-space/Game/State/Island.js @@ -169,4 +169,18 @@ export default class Island this._placeableCells = this.landCells().filter((cell) => this.isPlaceable(cell.x, cell.z)) return this._placeableCells } + + /** + * Placeable cells on the raised tiers (grid tier >= 2) — strictly above + * the tier-1 beach. Sprout/bloom seeding uses this pool so growth reads + * as "the island levels up", not sand clutter. Falls back to the full + * placeable pool if a custom spec has no elevated land. Cached. + */ + elevatedPlaceableCells() + { + if(this._elevatedPlaceableCells) return this._elevatedPlaceableCells + const elevated = this.placeableCells().filter((cell) => cell.tier >= 2) + this._elevatedPlaceableCells = elevated.length > 0 ? elevated : this.placeableCells() + return this._elevatedPlaceableCells + } } diff --git a/src/engine/student-space/Game/View/Sprouts.js b/src/engine/student-space/Game/View/Sprouts.js index 9d50f0c9..6091fe30 100644 --- a/src/engine/student-space/Game/View/Sprouts.js +++ b/src/engine/student-space/Game/View/Sprouts.js @@ -30,7 +30,8 @@ import { snapPositionToLand } from '../State/islandSpecCore/snapToLand.ts' * glow, dissolve → 200ms cross-fade. * * Placement: seeds → world coords via a multiplicative hash mapped - * into the central plateau (radius ~3 from origin), then state.island. + * into a random elevated (tier >= 2) placeable cell of the spec + * terrain — strictly above the tier-1 beach — then state.island. * heightAt(x, z) for terrain-snapped y. */ @@ -88,16 +89,26 @@ const BOB_PERIOD_S = 2.5 // seconds per bob cycle const PULSE_PERIOD_S = 2.5 // seconds per pulse cycle const DISSOLVE_MS = 700 // bloomed sprout dissolve duration -// Seeded placements land on random PLACEABLE land cells of the spec terrain -// (world-port U10) — the old polar plateau formula is gone. +// Seeded placements land on random ELEVATED (tier >= 2) placeable land +// cells of the spec terrain (world-port U10) — the old polar plateau +// formula is gone, and growth is kept off the tier-1 beach. -// Camera flow timings — total ≈1.5s for a normal grow, ≈2.7s for a bloom. +// Camera flow timings. The confirmation hold is student-controlled: once +// Kira opens, the close-up remains parked until the student presses OK. const CAM_ZOOM_IN_MS = 500 const CAM_HOLD_MS = 500 // non-bloom hold before returning const CAM_HOLD_BLOOM_MS = 350 // shorter; bloom animation provides the dwell const CAM_ZOOM_OUT_MS = 500 const BLOOM_GROW_MS = 1000 // bloomed-object grow-in duration (was 1200) +const PENDING_FLOW_TIMEOUT_MS = 4000 // max wait on a busy camera before flying anyway + +// Narrator beat — the bottom Kira panel confirms the capture while the +// camera holds on the sprout. Copy is deliberately plain; species-aware +// variants are a product decision deferred to a future plan. +const GROW_NARRATION = 'Your capture has been recorded — your island is growing.' +const BLOOM_NARRATION = 'Your capture has been recorded — something new is blooming on your island!' + /** Stable PRNG from a seed integer. Deterministic + fast. */ export function seededPlacement(seed, island) { @@ -107,7 +118,7 @@ export function seededPlacement(seed, island) const b = Math.sin(seed * 78.233) * 12345.6789 const theta = (a - Math.floor(a)) * Math.PI * 2 const u = b - Math.floor(b) - const cells = island.placeableCells() + const cells = island.elevatedPlaceableCells() if(cells.length === 0) return { theta, x: 0, z: 0 } const cell = cells[Math.min(cells.length - 1, Math.floor(u * cells.length))] return { theta, x: cell.x, z: cell.z } @@ -232,6 +243,8 @@ export default class Sprouts // (badge, sprout scale tick) but don't enqueue another camera // moment — the existing flow finishes first. this._camFlow = null // null | { sproutId, phase, startMs, autoBloom } + this._pendingCamFlow = null // { sproutId, autoBloom, queuedAtMs } + this._rmNarratorFlow = null // Pick-and-plant edit mode. Flipped via the 'ss:edit-mode' // CustomEvent dispatched by the React overlay's Arrange button. @@ -1314,6 +1327,50 @@ export default class Sprouts node.parts.berryHint = berry } + /** + * True while the capture overlays are up or another consumer holds the + * camera. Starting the sprout zoom now would (a) play behind the sheet + * and (b) snapshot the capture dolly's close-up as the 'sprouts' restore + * anchor — the sheet's later out-of-order restoreZoom('capture') then + * drops the true pre-capture pose (Camera.js save-stack semantics), and + * the camera ends the cinematic stuck at the capture framing. + */ + _shouldDeferCameraFlow() + { + if(typeof document !== 'undefined') + { + const cls = document.body.classList + if(cls.contains('has-capture-sheet') || cls.contains('has-chooser')) return true + } + const camera = this.view.camera + if(camera && (camera._zoom || (camera._saveStack && camera._saveStack.size > 0))) return true + return false + } + + /** Start a queued flow once the overlay is gone and the camera settles. */ + _drainPendingCamFlow(now) + { + const pending = this._pendingCamFlow + if(!pending || this._camFlow) return + if(typeof document !== 'undefined') + { + const cls = document.body.classList + // An open overlay always blocks — no timeout while the student + // is mid-capture. + if(cls.contains('has-capture-sheet') || cls.contains('has-chooser')) return + } + const camera = this.view.camera + const busy = camera && (camera._zoom || (camera._saveStack && camera._saveStack.size > 0)) + if(busy && now - pending.queuedAtMs < PENDING_FLOW_TIMEOUT_MS) return + this._pendingCamFlow = null + // force: true — this method already re-derived the authoritative + // decision to go (overlay-closed, and either idle or past the busy + // timeout). Without it, _startCameraFlow's own _shouldDeferCameraFlow() + // check would see the same still-busy camera and just re-queue, + // silently defeating the timeout safety valve. + this._startCameraFlow(pending.sproutId, { autoBloom: pending.autoBloom, force: true }) + } + /** * Start the per-capture camera flow: glide camera to the sprout, * hold, then either restore or trigger auto-bloom. If a flow is @@ -1326,11 +1383,21 @@ export default class Sprouts * auto-bloom still fires but with the existing reduced-motion path * (200ms cross-fade) inside the dissolve/grow code. */ - _startCameraFlow(sproutId, { autoBloom }) + _startCameraFlow(sproutId, { autoBloom, force = false }) { const node = this.nodes.get(sproutId) if(!node) return + if(!force && this._shouldDeferCameraFlow()) + { + this._pendingCamFlow = { + sproutId, + autoBloom: autoBloom || !!(this._pendingCamFlow && this._pendingCamFlow.autoBloom), + queuedAtMs: performance.now(), + } + return + } + // While the student is arranging the island, suppress the // auto-fly cinematic — flying the camera around would yank them // out of edit mode. State updates (badge, scale, glow) still @@ -1349,6 +1416,9 @@ export default class Sprouts { node.tapAckUntilMs = performance.now() + 240 if(autoBloom) this._triggerBloom(sproutId) + const rmFlow = { autoBloom, narratorOpened: false } + this._openFlowNarrator(rmFlow) + if(rmFlow.narratorOpened) this._rmNarratorFlow = rmFlow return } @@ -1402,14 +1472,31 @@ export default class Sprouts { flow.phase = 'holding' flow.startMs = now + this._openFlowNarrator(flow) } return } if(flow.phase === 'holding') { - const holdMs = flow.autoBloom ? CAM_HOLD_BLOOM_MS : CAM_HOLD_MS - if(elapsed >= holdMs) + if(flow.narratorOpened) + { + // Normal growth stays parked until the required OK action. + // Auto-bloom may animate while the confirmation remains up, + // but the camera still waits for that acknowledgement. + if(flow.autoBloom && elapsed >= CAM_HOLD_BLOOM_MS) + { + const ok = this._triggerBloom(flow.sproutId) + if(ok) + { + flow.phase = 'blooming' + flow.startMs = now + } + } + return + } + + if(elapsed >= (flow.autoBloom ? CAM_HOLD_BLOOM_MS : CAM_HOLD_MS)) { if(flow.autoBloom) { @@ -1431,15 +1518,23 @@ export default class Sprouts { if(elapsed >= BLOOM_GROW_MS) { - this._returnCamera(flow) + if(flow.confirmed) this._returnCamera(flow) + else + { + flow.phase = 'awaiting-confirmation' + flow.startMs = now + } } return } + if(flow.phase === 'awaiting-confirmation') return + if(flow.phase === 'returning') { if(elapsed >= CAM_ZOOM_OUT_MS) { + this._closeFlowNarrator(flow) this._camFlow = null } } @@ -1453,6 +1548,67 @@ export default class Sprouts flow.startMs = performance.now() } + /** Open the bottom Kira panel for this flow. No-ops if the narrator is + * unavailable (world host unmounted) or already mid-conversation. */ + _openFlowNarrator(flow) + { + const narrator = this.view.kiraNarrator + if(!narrator || typeof narrator.speak !== 'function' || narrator.isActive) return + try + { + narrator.speak({ + text: flow.autoBloom ? BLOOM_NARRATION : GROW_NARRATION, + cta: 'OK', + dismissible: false, + onConfirm: () => this._confirmFlowNarrator(flow), + }) + flow.narratorOpened = true + } + catch(_) {} + } + + _confirmFlowNarrator(flow) + { + if(!flow || flow.confirmed) return + flow.confirmed = true + this._closeFlowNarrator(flow) + + if(this._rmNarratorFlow === flow) + { + this._rmNarratorFlow = null + return + } + if(this._camFlow !== flow) return + + if(flow.phase === 'holding') + { + if(flow.autoBloom) + { + const ok = this._triggerBloom(flow.sproutId) + if(ok) + { + flow.phase = 'blooming' + flow.startMs = performance.now() + return + } + } + this._returnCamera(flow) + return + } + if(flow.phase === 'awaiting-confirmation') this._returnCamera(flow) + } + + _closeFlowNarrator(flow) + { + if(!flow || !flow.narratorOpened) return + flow.narratorOpened = false + const narrator = this.view.kiraNarrator + if(narrator && narrator.isActive) + { + try { narrator.close() } catch(_) {} + } + } + /** * Dispatch a bloom on the slice. Returns true if the slice * accepted the bloom; false if it refused (e.g., the sprout is @@ -1535,6 +1691,7 @@ export default class Sprouts const camera = this.view.camera?.instance // Drive the per-capture camera-flow state machine. + this._drainPendingCamFlow(now) this._tickCameraFlow(now) // Late-install decor hit targets once Tree.js finishes loading @@ -1707,6 +1864,10 @@ export default class Sprouts if(controls) controls.enabled = true } catch(_) {} + this._pendingCamFlow = null + this._closeFlowNarrator(this._camFlow) + this._closeFlowNarrator(this._rmNarratorFlow) + this._rmNarratorFlow = null for(const id of Array.from(this.nodes.keys())) { this._disposeNode(id) diff --git a/test/engine/SproutsView.captureFlow.test.ts b/test/engine/SproutsView.captureFlow.test.ts new file mode 100644 index 00000000..8a52f4ac --- /dev/null +++ b/test/engine/SproutsView.captureFlow.test.ts @@ -0,0 +1,271 @@ +/** + * Unit coverage for plan 075 — capture cinematic elevated growth: + * + * - seededPlacement() lands only on elevated (grid tier >= 2) cells + * against the real committed island spec, with the fallback pool + * owned by Island.elevatedPlaceableCells() itself + * - the per-capture camera flow (_startCameraFlow / _tickCameraFlow) + * defers instead of firing while the capture overlay is open or the + * camera is otherwise busy (Camera._zoom / _saveStack), draining once + * the overlay closes and the camera settles (or after a timeout) + * - the Kira narrator confirmation beat opens during the camera hold + * and keeps the close-up parked until the student presses OK + * + * Tests the diff logic directly against the prototype methods, with a + * lightweight stub `this` shape — modeled on + * test/engine/SproutsView.timelapse.test.ts. We avoid constructing a real + * SproutsView because that requires a full engine boot (scene, camera, + * island heightfield, slice singletons, GLSL-using View modules). + */ + +import * as THREE from 'three' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Island from '~/engine/student-space/Game/State/Island.js' +import { + cellIndex, + worldToCell, +} from '~/engine/student-space/Game/State/islandSpecCore/terrainGrid.ts' +// @ts-expect-error — Sprouts.js is JS without a companion .d.ts (View modules +// are intentionally untyped per the engine-substrate doctrine). +import SproutsView, { seededPlacement } from '~/engine/student-space/Game/View/Sprouts.js' + +afterEach(() => { + document.body.className = '' +}) + +describe('seededPlacement — elevated pool', () => { + it('every seed lands on a grid cell with tier >= 2 against the committed spec', () => { + const island = new Island() + const { grid } = island.spec + + // Committed island should never be beach-only — an empty elevated pool + // would be a spec/tier-semantics regression, not something to paper over. + expect(island.elevatedPlaceableCells().length).toBeGreaterThan(0) + + for (let i = 0; i < 120; i++) { + const seed = (i * 2654435761) >>> 0 + const { x, z } = seededPlacement(seed, island) + const { c, r } = worldToCell(island.worldSize, grid, x, z) + const tier = grid.tiers[cellIndex(grid, c, r)] + expect(tier).toBeGreaterThanOrEqual(2) + } + }) + + it('falls back to whatever elevatedPlaceableCells() returns — the method owns the fallback, not the view', () => { + const fakeIsland = { + elevatedPlaceableCells: () => [{ x: 3, z: 4, tier: 1 }], + } + const { x, z } = seededPlacement(42, fakeIsland as unknown as Island) + expect(x).toBe(3) + expect(z).toBe(4) + }) +}) + +function makeCameraFlowStub(id = 'sprout-1') { + const zoomTo = vi.fn() + const restoreZoom = vi.fn() + const self = { + nodes: new Map([[id, { group: { position: new THREE.Vector3(0, 1, 0) } }]]), + _editMode: false, + _camFlow: null as unknown, + _pendingCamFlow: null as unknown, + _tmpVec: new THREE.Vector3(), + view: { + camera: { + instance: { position: new THREE.Vector3(5, 5, 5) }, + zoomTo, + restoreZoom, + }, + kiraNarrator: undefined as unknown, + }, + _shouldDeferCameraFlow: SproutsView.prototype._shouldDeferCameraFlow, + _drainPendingCamFlow: SproutsView.prototype._drainPendingCamFlow, + _startCameraFlow: SproutsView.prototype._startCameraFlow, + _tickCameraFlow: SproutsView.prototype._tickCameraFlow, + _returnCamera: SproutsView.prototype._returnCamera, + _openFlowNarrator: SproutsView.prototype._openFlowNarrator, + _closeFlowNarrator: SproutsView.prototype._closeFlowNarrator, + _triggerBloom: vi.fn(() => true), + } + return { self, zoomTo, restoreZoom } +} + +describe('SproutsView — capture camera flow deferral', () => { + it('queues behind the capture sheet instead of starting, then drains once the overlay closes', () => { + const { self, zoomTo } = makeCameraFlowStub() + document.body.classList.add('has-capture-sheet') + + self._startCameraFlow.call(self, 'sprout-1', { autoBloom: false }) + + expect(zoomTo).not.toHaveBeenCalled() + expect(self._pendingCamFlow).toMatchObject({ sproutId: 'sprout-1', autoBloom: false }) + expect(self._camFlow).toBeNull() + + document.body.classList.remove('has-capture-sheet') + self._drainPendingCamFlow.call(self, performance.now()) + + expect(zoomTo).toHaveBeenCalledTimes(1) + expect(self._pendingCamFlow).toBeNull() + expect((self._camFlow as { phase: string }).phase).toBe('flying') + }) + + it('queues behind the has-chooser overlay class too', () => { + const { self, zoomTo } = makeCameraFlowStub() + document.body.classList.add('has-chooser') + + self._startCameraFlow.call(self, 'sprout-1', { autoBloom: false }) + + expect(zoomTo).not.toHaveBeenCalled() + expect(self._pendingCamFlow).not.toBeNull() + }) + + it('waits for a busy camera (save-stack held) and flies only after the timeout elapses', () => { + const { self, zoomTo } = makeCameraFlowStub() + ;(self.view.camera as unknown as { _saveStack: Map })._saveStack = new Map([ + ['capture', {}], + ]) + + self._startCameraFlow.call(self, 'sprout-1', { autoBloom: false }) + expect(zoomTo).not.toHaveBeenCalled() + const queuedAtMs = (self._pendingCamFlow as { queuedAtMs: number }).queuedAtMs + + // Still busy, short elapsed — stays pending. + self._drainPendingCamFlow.call(self, queuedAtMs + 100) + expect(zoomTo).not.toHaveBeenCalled() + expect(self._pendingCamFlow).not.toBeNull() + + // Past the timeout — flies anyway even though the camera is still busy. + self._drainPendingCamFlow.call(self, queuedAtMs + 4001) + expect(zoomTo).toHaveBeenCalledTimes(1) + expect(self._pendingCamFlow).toBeNull() + }) + + it('merges autoBloom true-wins when a rapid grow-then-ready pair queues behind one sheet', () => { + const { self } = makeCameraFlowStub() + document.body.classList.add('has-capture-sheet') + + self._startCameraFlow.call(self, 'sprout-1', { autoBloom: true }) + self._startCameraFlow.call(self, 'sprout-1', { autoBloom: false }) + + expect((self._pendingCamFlow as { autoBloom: boolean }).autoBloom).toBe(true) + }) +}) + +describe('SproutsView — narrator confirmation beat', () => { + function makeNarratorStub(id = 'sprout-1') { + const restoreZoom = vi.fn() + const speak = vi.fn(function ( + this: { isActive: boolean }, + _opts: { + text: string + cta?: string + dismissible?: boolean + onConfirm?: () => void + }, + ) { + this.isActive = true + }) + const close = vi.fn(function (this: { isActive: boolean }) { + this.isActive = false + }) + const kiraNarrator = { isActive: false, speak, close } + const self = { + nodes: new Map([[id, { group: { position: new THREE.Vector3(0, 1, 0) } }]]), + _camFlow: null as unknown, + view: { + camera: { + instance: { position: new THREE.Vector3(5, 5, 5) }, + zoomTo: vi.fn(), + restoreZoom, + }, + kiraNarrator, + }, + _tmpVec: new THREE.Vector3(), + _triggerBloom: vi.fn(() => true), + _returnCamera: SproutsView.prototype._returnCamera, + _tickCameraFlow: SproutsView.prototype._tickCameraFlow, + _openFlowNarrator: SproutsView.prototype._openFlowNarrator, + _closeFlowNarrator: SproutsView.prototype._closeFlowNarrator, + _confirmFlowNarrator: SproutsView.prototype._confirmFlowNarrator, + } + return { self, kiraNarrator, speak, close, restoreZoom } + } + + it('opens the narrator with the grow copy when the flow reaches the holding phase', () => { + const { self, speak } = makeNarratorStub() + const t0 = performance.now() + self._camFlow = { sproutId: 'sprout-1', phase: 'flying', startMs: t0, autoBloom: false } + + self._tickCameraFlow.call(self, t0 + 510) + + expect((self._camFlow as { phase: string }).phase).toBe('holding') + expect(speak).toHaveBeenCalledTimes(1) + expect(speak.mock.calls[0]?.[0]?.text).toMatch(/growing/) + expect(speak.mock.calls[0]?.[0]).toMatchObject({ + cta: 'OK', + dismissible: false, + onConfirm: expect.any(Function), + }) + expect((self._camFlow as { narratorOpened: boolean }).narratorOpened).toBe(true) + }) + + it('keeps the dialog and close-up parked until the student presses OK', () => { + const { self, speak, close, restoreZoom } = makeNarratorStub() + const t0 = performance.now() + self._camFlow = { sproutId: 'sprout-1', phase: 'flying', startMs: t0, autoBloom: false } + self._tickCameraFlow.call(self, t0 + 510) // → holding, narrator opens + expect((self._camFlow as { phase: string }).phase).toBe('holding') + const holdStart = (self._camFlow as { startMs: number }).startMs + + // Elapsed time alone must never dismiss the confirmation or restore + // the camera — the student controls when this beat finishes. + self._tickCameraFlow.call(self, holdStart + 60_000) + expect((self._camFlow as { phase: string }).phase).toBe('holding') + expect(close).not.toHaveBeenCalled() + expect(restoreZoom).not.toHaveBeenCalled() + + speak.mock.calls[0]?.[0]?.onConfirm?.() + expect(close).toHaveBeenCalledTimes(1) + expect(restoreZoom).toHaveBeenCalledTimes(1) + expect((self._camFlow as { phase: string }).phase).toBe('returning') + }) + + it('lets auto-bloom finish while parked and returns only after OK', () => { + const { self, speak, close, restoreZoom } = makeNarratorStub() + const t0 = performance.now() + self._camFlow = { sproutId: 'sprout-1', phase: 'flying', startMs: t0, autoBloom: true } + self._tickCameraFlow.call(self, t0 + 510) // → holding, narrator opens + const holdStart = (self._camFlow as { startMs: number }).startMs + + self._tickCameraFlow.call(self, holdStart + 360) // → blooming + expect((self._camFlow as { phase: string }).phase).toBe('blooming') + const bloomStart = (self._camFlow as { startMs: number }).startMs + self._tickCameraFlow.call(self, bloomStart + 60_000) + + expect((self._camFlow as { phase: string }).phase).toBe('awaiting-confirmation') + expect(close).not.toHaveBeenCalled() + expect(restoreZoom).not.toHaveBeenCalled() + + speak.mock.calls[0]?.[0]?.onConfirm?.() + expect(close).toHaveBeenCalledTimes(1) + expect(restoreZoom).toHaveBeenCalledTimes(1) + expect((self._camFlow as { phase: string }).phase).toBe('returning') + }) + + it('does not reopen an already-active narrator, and falls back to the short hold', () => { + const { self, kiraNarrator, speak } = makeNarratorStub() + kiraNarrator.isActive = true // some other conversation is already open + const t0 = performance.now() + self._camFlow = { sproutId: 'sprout-1', phase: 'flying', startMs: t0, autoBloom: false } + + self._tickCameraFlow.call(self, t0 + 510) // → holding + + expect(speak).not.toHaveBeenCalled() + expect((self._camFlow as { narratorOpened: boolean }).narratorOpened).toBeFalsy() + + // Falls back to the plain 500ms hold (CAM_HOLD_MS) instead of NARRATOR_HOLD_MS. + const holdStart = (self._camFlow as { startMs: number }).startMs + self._tickCameraFlow.call(self, holdStart + 510) + expect((self._camFlow as { phase: string }).phase).toBe('returning') + }) +})