From 364fa6e09bcfbd48cac2121fa07247b71291be77 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 27 Jul 2026 11:23:20 +0800 Subject: [PATCH 1/5] feat(island): elevated placement pool for sprout seeding Add Island.elevatedPlaceableCells() (tier >= 2, falls back to the full placeable pool if a custom spec has no elevated land) and point Sprouts.seededPlacement() at it, so sprout/bloom growth seeds on the raised grass tiers instead of the tier-1 beach. --- src/engine/student-space/Game/State/Island.d.ts | 1 + src/engine/student-space/Game/State/Island.js | 14 ++++++++++++++ src/engine/student-space/Game/View/Sprouts.js | 10 ++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) 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..031380b1 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,8 +89,9 @@ 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. const CAM_ZOOM_IN_MS = 500 @@ -107,7 +109,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 } From d91e80ed1f94d773c1532ea8f7c650c7057d8c3c Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 27 Jul 2026 11:26:01 +0800 Subject: [PATCH 2/5] fix(sprouts): defer capture camera flow until overlays close, add narrator confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue the per-capture camera flow (_pendingCamFlow) instead of starting it immediately when the capture sheet/chooser overlay is open or another consumer holds the camera (Camera._zoom / _saveStack). Draining happens once the overlay closes and the camera settles (or after a 4s timeout), so the cinematic no longer plays behind the closing sheet and no longer corrupts the 'capture' owner's save-stack anchor. Also open the bottom Kira narrator panel during the camera hold to confirm the capture landed and the island is growing (GROW_NARRATION / BLOOM_NARRATION), extending the hold to NARRATOR_HOLD_MS while the panel is up, with early-dismiss and reduced-motion (words-only, no camera) paths. Note: this commit intentionally combines the deferral (Step 3) and narrator (Step 4) changes from plan 075 — the narrator wiring lives inside the same _tickCameraFlow/update() edits as the deferral gate, so splitting them into separate commits would require re-deriving interleaved hunks by hand. --- src/engine/student-space/Game/View/Sprouts.js | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/src/engine/student-space/Game/View/Sprouts.js b/src/engine/student-space/Game/View/Sprouts.js index 031380b1..99b3773c 100644 --- a/src/engine/student-space/Game/View/Sprouts.js +++ b/src/engine/student-space/Game/View/Sprouts.js @@ -100,6 +100,15 @@ 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 NARRATOR_HOLD_MS = 3600 // replaces CAM_HOLD_MS while the panel is up +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) { @@ -234,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._rmNarratorCloseAtMs = 0 // Pick-and-plant edit mode. Flipped via the 'ss:edit-mode' // CustomEvent dispatched by the React overlay's Arrange button. @@ -1316,6 +1327,45 @@ 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 + this._startCameraFlow(pending.sproutId, { autoBloom: pending.autoBloom }) + } + /** * Start the per-capture camera flow: glide camera to the sprout, * hold, then either restore or trigger auto-bloom. If a flow is @@ -1333,6 +1383,16 @@ export default class Sprouts const node = this.nodes.get(sproutId) if(!node) return + if(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 @@ -1351,6 +1411,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._rmNarratorCloseAtMs = performance.now() + NARRATOR_HOLD_MS return } @@ -1404,13 +1467,23 @@ 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 + const holdMs = flow.autoBloom + ? CAM_HOLD_BLOOM_MS + : (flow.narratorOpened ? NARRATOR_HOLD_MS : CAM_HOLD_MS) + // Student dismissed the panel early — return right away. + if(flow.narratorOpened && !this.view.kiraNarrator?.isActive) + { + flow.narratorOpened = false + this._returnCamera(flow) + return + } if(elapsed >= holdMs) { if(flow.autoBloom) @@ -1442,6 +1515,7 @@ export default class Sprouts { if(elapsed >= CAM_ZOOM_OUT_MS) { + this._closeFlowNarrator(flow) this._camFlow = null } } @@ -1455,6 +1529,31 @@ 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 }) + flow.narratorOpened = true + } + catch(_) {} + } + + _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 @@ -1537,7 +1636,13 @@ export default class Sprouts const camera = this.view.camera?.instance // Drive the per-capture camera-flow state machine. + this._drainPendingCamFlow(now) this._tickCameraFlow(now) + if(this._rmNarratorCloseAtMs && now >= this._rmNarratorCloseAtMs) + { + this._rmNarratorCloseAtMs = 0 + this._closeFlowNarrator({ narratorOpened: true }) + } // Late-install decor hit targets once Tree.js finishes loading // its async templates. No-op once installed. @@ -1709,6 +1814,9 @@ export default class Sprouts if(controls) controls.enabled = true } catch(_) {} + this._pendingCamFlow = null + this._closeFlowNarrator(this._camFlow) + this._rmNarratorCloseAtMs = 0 for(const id of Array.from(this.nodes.keys())) { this._disposeNode(id) From f6184f98fa7a5fef931db7e3c12114eb7e2f3170 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 27 Jul 2026 11:34:54 +0800 Subject: [PATCH 3/5] fix(sprouts): let the pending-flow drain bypass its own defer check on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _drainPendingCamFlow's PENDING_FLOW_TIMEOUT_MS escape hatch called _startCameraFlow unconditionally, but _startCameraFlow re-runs _shouldDeferCameraFlow() with no timeout awareness — so a camera that's still busy past the timeout just got re-queued instead of flying, silently defeating the safety valve. Add a `force` option so the drain path's authoritative decision (overlay-closed, idle-or-timed-out) isn't second-guessed. Also adds test/engine/SproutsView.captureFlow.test.ts (plan 075 step 5): elevated seeding against the real committed spec + fallback pool, capture-sheet/chooser/busy-camera deferral with timeout and autoBloom merge, and the narrator confirmation beat (open on hold, early-dismiss, full-hold-then-close, already-active no-op). --- src/engine/student-space/Game/View/Sprouts.js | 11 +- test/engine/SproutsView.captureFlow.test.ts | 248 ++++++++++++++++++ 2 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 test/engine/SproutsView.captureFlow.test.ts diff --git a/src/engine/student-space/Game/View/Sprouts.js b/src/engine/student-space/Game/View/Sprouts.js index 99b3773c..38f98901 100644 --- a/src/engine/student-space/Game/View/Sprouts.js +++ b/src/engine/student-space/Game/View/Sprouts.js @@ -1363,7 +1363,12 @@ export default class Sprouts const busy = camera && (camera._zoom || (camera._saveStack && camera._saveStack.size > 0)) if(busy && now - pending.queuedAtMs < PENDING_FLOW_TIMEOUT_MS) return this._pendingCamFlow = null - this._startCameraFlow(pending.sproutId, { autoBloom: pending.autoBloom }) + // 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 }) } /** @@ -1378,12 +1383,12 @@ 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(this._shouldDeferCameraFlow()) + if(!force && this._shouldDeferCameraFlow()) { this._pendingCamFlow = { sproutId, diff --git a/test/engine/SproutsView.captureFlow.test.ts b/test/engine/SproutsView.captureFlow.test.ts new file mode 100644 index 00000000..5aa19dd0 --- /dev/null +++ b/test/engine/SproutsView.captureFlow.test.ts @@ -0,0 +1,248 @@ +/** + * 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, + * extends the hold while the panel is up, and closes when the flow + * returns (or immediately if the student dismisses the panel early) + * + * 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 }) { + 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, + } + 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((self._camFlow as { narratorOpened: boolean }).narratorOpened).toBe(true) + }) + + it('returns the camera right away if the student dismisses the panel early', () => { + const { self, kiraNarrator, 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') + + kiraNarrator.isActive = false // student tapped the close X + self._tickCameraFlow.call(self, t0 + 600) + + expect(restoreZoom).toHaveBeenCalledTimes(1) + expect((self._camFlow as { phase: string }).phase).toBe('returning') + }) + + it('holds through NARRATOR_HOLD_MS then closes the panel when the flow completes', () => { + const { self, close } = 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 + const holdStart = (self._camFlow as { startMs: number }).startMs + + // Keep the panel active through the full hold. + self._tickCameraFlow.call(self, holdStart + 3610) // → returning + expect((self._camFlow as { phase: string }).phase).toBe('returning') + expect(close).not.toHaveBeenCalled() + + const returnStart = (self._camFlow as { startMs: number }).startMs + self._tickCameraFlow.call(self, returnStart + 510) // zoom-out completes + + expect(close).toHaveBeenCalledTimes(1) + expect(self._camFlow).toBeNull() + }) + + 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') + }) +}) From e328341a1afaf72b6bcdb5eddb882213cdad4587 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 27 Jul 2026 13:11:38 +0800 Subject: [PATCH 4/5] fix(sprouts): wait for capture confirmation before restoring camera --- .../student-space/world/WorldInteractions.tsx | 47 +++++++--- src/engine/student-space/Game/View/Sprouts.js | 86 ++++++++++++++----- test/engine/SproutsView.captureFlow.test.ts | 59 +++++++++---- 3 files changed, 142 insertions(+), 50 deletions(-) diff --git a/src/components/student-space/world/WorldInteractions.tsx b/src/components/student-space/world/WorldInteractions.tsx index 527799a4..7f64d840 100644 --- a/src/components/student-space/world/WorldInteractions.tsx +++ b/src/components/student-space/world/WorldInteractions.tsx @@ -139,6 +139,7 @@ type NarratorState = { name: string text: string cta: string + dismissible: boolean } type HoverCtaState = { @@ -170,7 +171,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 +531,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 +558,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 +622,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 +653,7 @@ class KiraNarratorController { cta, text: '', name: name ?? this.state?.profile?.displayCompanionName?.() ?? 'Kira', + dismissible, })) this._scheduleType(text, 180) @@ -696,6 +716,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) { @@ -1814,14 +1835,16 @@ function NarratorPanel({
{state.name}
- + {state.dismissible ? ( + + ) : null}

{state.text}

{state.cta ? (
diff --git a/src/engine/student-space/Game/View/Sprouts.js b/src/engine/student-space/Game/View/Sprouts.js index 38f98901..6091fe30 100644 --- a/src/engine/student-space/Game/View/Sprouts.js +++ b/src/engine/student-space/Game/View/Sprouts.js @@ -93,7 +93,8 @@ const DISSOLVE_MS = 700 // bloomed sprout dissolve duration // 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 @@ -105,7 +106,6 @@ const PENDING_FLOW_TIMEOUT_MS = 4000 // max wait on a busy camera before flying // 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 NARRATOR_HOLD_MS = 3600 // replaces CAM_HOLD_MS while the panel is up 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!' @@ -244,7 +244,7 @@ export default class Sprouts // moment — the existing flow finishes first. this._camFlow = null // null | { sproutId, phase, startMs, autoBloom } this._pendingCamFlow = null // { sproutId, autoBloom, queuedAtMs } - this._rmNarratorCloseAtMs = 0 + this._rmNarratorFlow = null // Pick-and-plant edit mode. Flipped via the 'ss:edit-mode' // CustomEvent dispatched by the React overlay's Arrange button. @@ -1418,7 +1418,7 @@ export default class Sprouts if(autoBloom) this._triggerBloom(sproutId) const rmFlow = { autoBloom, narratorOpened: false } this._openFlowNarrator(rmFlow) - if(rmFlow.narratorOpened) this._rmNarratorCloseAtMs = performance.now() + NARRATOR_HOLD_MS + if(rmFlow.narratorOpened) this._rmNarratorFlow = rmFlow return } @@ -1479,17 +1479,24 @@ export default class Sprouts if(flow.phase === 'holding') { - const holdMs = flow.autoBloom - ? CAM_HOLD_BLOOM_MS - : (flow.narratorOpened ? NARRATOR_HOLD_MS : CAM_HOLD_MS) - // Student dismissed the panel early — return right away. - if(flow.narratorOpened && !this.view.kiraNarrator?.isActive) + if(flow.narratorOpened) { - flow.narratorOpened = false - this._returnCamera(flow) + // 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 >= holdMs) + + if(elapsed >= (flow.autoBloom ? CAM_HOLD_BLOOM_MS : CAM_HOLD_MS)) { if(flow.autoBloom) { @@ -1511,11 +1518,18 @@ 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) @@ -1542,12 +1556,48 @@ export default class Sprouts if(!narrator || typeof narrator.speak !== 'function' || narrator.isActive) return try { - narrator.speak({ text: flow.autoBloom ? BLOOM_NARRATION : GROW_NARRATION }) + 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 @@ -1643,11 +1693,6 @@ export default class Sprouts // Drive the per-capture camera-flow state machine. this._drainPendingCamFlow(now) this._tickCameraFlow(now) - if(this._rmNarratorCloseAtMs && now >= this._rmNarratorCloseAtMs) - { - this._rmNarratorCloseAtMs = 0 - this._closeFlowNarrator({ narratorOpened: true }) - } // Late-install decor hit targets once Tree.js finishes loading // its async templates. No-op once installed. @@ -1821,7 +1866,8 @@ export default class Sprouts catch(_) {} this._pendingCamFlow = null this._closeFlowNarrator(this._camFlow) - this._rmNarratorCloseAtMs = 0 + 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 index 5aa19dd0..8a52f4ac 100644 --- a/test/engine/SproutsView.captureFlow.test.ts +++ b/test/engine/SproutsView.captureFlow.test.ts @@ -8,9 +8,8 @@ * 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, - * extends the hold while the panel is up, and closes when the flow - * returns (or immediately if the student dismisses the panel early) + * - 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 @@ -155,7 +154,15 @@ describe('SproutsView — capture camera flow deferral', () => { 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 }) { + 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 }) { @@ -179,6 +186,7 @@ describe('SproutsView — narrator confirmation beat', () => { _tickCameraFlow: SproutsView.prototype._tickCameraFlow, _openFlowNarrator: SproutsView.prototype._openFlowNarrator, _closeFlowNarrator: SproutsView.prototype._closeFlowNarrator, + _confirmFlowNarrator: SproutsView.prototype._confirmFlowNarrator, } return { self, kiraNarrator, speak, close, restoreZoom } } @@ -193,40 +201,55 @@ describe('SproutsView — narrator confirmation beat', () => { 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('returns the camera right away if the student dismisses the panel early', () => { - const { self, kiraNarrator, restoreZoom } = makeNarratorStub() + 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 - kiraNarrator.isActive = false // student tapped the close X - self._tickCameraFlow.call(self, t0 + 600) + // 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('holds through NARRATOR_HOLD_MS then closes the panel when the flow completes', () => { - const { self, close } = makeNarratorStub() + 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: false } + 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 - // Keep the panel active through the full hold. - self._tickCameraFlow.call(self, holdStart + 3610) // → returning - expect((self._camFlow as { phase: string }).phase).toBe('returning') - expect(close).not.toHaveBeenCalled() + 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) - const returnStart = (self._camFlow as { startMs: number }).startMs - self._tickCameraFlow.call(self, returnStart + 510) // zoom-out completes + 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(self._camFlow).toBeNull() + 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', () => { From 95c0db05e3a2354ac6eee623f570b1068940b403 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 27 Jul 2026 14:47:12 +0800 Subject: [PATCH 5/5] fix(kira): ask natural school-day reflection prompts --- .../student-space/world/WorldInteractions.tsx | 78 ++++++++++--------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/src/components/student-space/world/WorldInteractions.tsx b/src/components/student-space/world/WorldInteractions.tsx index 7f64d840..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 = { @@ -1731,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', )}