diff --git a/content/dialogue/season1/letter-from-the-garden.ink b/content/dialogue/season1/letter-from-the-garden.ink new file mode 100644 index 0000000..4977ee4 --- /dev/null +++ b/content/dialogue/season1/letter-from-the-garden.ink @@ -0,0 +1,47 @@ +// Letter from the garden — UX-02 + CONTEXT D-17 + D-18 + D-20. +// +// Composed from authored skeleton + templated insertions per CONTEXT D-17. +// Slots populated at runtime from sim/offline/events.ts via the variable +// map in src/content/ink-loader.ts. +// +// Per Pitfall 4: Ink VAR names are snake_case AND case-sensitive. +// Per CONTEXT D-11: 24h offline cap is silent in voice — no numeric +// "28h" copy in any branch. +// Per CLAUDE.md Tone — the gardener-keeper voice. Warm. Specific. +// Intermittent. Sometimes funny, sometimes devastating. Never a stat +// dump (UX-02 explicitly forbids that). The skeleton holds the voice; +// the slots fill in the specifics. +// Per anti-fomo-doctrine.md: this letter is NOT a "you missed X — come +// back tomorrow!" nag. It is a contemplative summary of what stayed. + +VAR plants_bloomed = 0 +VAR fragment_titles = "" +VAR lura_was_here = false +VAR fragment_count = 0 +VAR last_plant_type = "" + +== letter == + +The garden held its breath while you were gone. + +{ plants_bloomed > 1: + {plants_bloomed} blooms came and went, each leaving the soil a little quieter than they found it. +- else: + { plants_bloomed == 1: + One bloom came and went. The space it left feels generous, somehow. + - else: + Nothing bloomed. The wind carried something else, and the garden held that, too. + } +} + +{ fragment_titles != "": + Among what stayed: {fragment_titles}. +} + +{ lura_was_here: + Lura came by once. She did not knock. She left a folded leaf on the gate post — you'll find it when you next walk past. +} + +The light is the same as when you left. The garden is older. + +-> END diff --git a/src/content/ink-loader.ts b/src/content/ink-loader.ts index b2d41a1..2fc4a08 100644 --- a/src/content/ink-loader.ts +++ b/src/content/ink-loader.ts @@ -31,11 +31,19 @@ const compostStoryGlob = import.meta.glob( { query: '?raw', import: 'default' }, ); +// Plan 02-05 — letter Ink (UX-02). Lazy-loaded by the Letter overlay +// when the boot path determines absence ≥5min and opens the overlay. +const letterStoryGlob = import.meta.glob( + '/src/content/compiled-ink/season1/letter-from-the-garden.ink.json', + { query: '?raw', import: 'default' }, +); + export type InkBeatName = | 'lura-arrival' | 'lura-mid' | 'lura-farewell' - | 'compost-acknowledgements'; + | 'compost-acknowledgements' + | 'letter-from-the-garden'; /** * INK_VARIABLE_MAP — the centralized snake_case mapping (Pitfall 4). @@ -45,10 +53,30 @@ export type InkBeatName = * camelCase typo fails CI rather than silently leaving the variable * unbound. * - * Phase 2 ships these three slots — `last_fragment_title` is reserved - * for Plan 02-05's letter prose authoring (W4) but is exposed now so - * the Ink files can read it without a follow-up patch. + * Phase 2 ships: + * - fragment_count / last_plant_type / last_fragment_title (Plan 02-04) + * — used by Lura's Ink files. + * - plants_bloomed / fragment_titles / lura_was_here (Plan 02-05) + * — used by letter-from-the-garden.ink. These read from the + * SessionSlice's pendingLetterEventBlock (set by the boot path + * when a returning player has been away ≥5min, per CONTEXT D-20). */ + +/** Shape of pendingLetterEventBlock when the boot path populates it. */ +type PendingLetterEvents = { + plantsBloomedCount?: Record; + harvestedFragmentIds?: string[]; + luraBeatPending?: string | null; +}; + +function readPendingLetterEvents( + s: AppStoreShape, +): PendingLetterEvents | null { + const block = s.pendingLetterEventBlock; + if (!block || typeof block !== 'object') return null; + return block as PendingLetterEvents; +} + export const INK_VARIABLE_MAP = { fragment_count: (s: AppStoreShape) => s.harvestedFragmentIds.length, last_plant_type: (s: AppStoreShape): string => { @@ -75,6 +103,40 @@ export const INK_VARIABLE_MAP = { if (!frag) return ''; return frag.body.split(/[.!?]/)[0]?.trim() ?? ''; }, + // Plan 02-05 — letter slots. Read from session.pendingLetterEventBlock + // populated by the boot path's offline catchup loop. + plants_bloomed: (s: AppStoreShape): number => { + const events = readPendingLetterEvents(s); + if (!events?.plantsBloomedCount) return 0; + return Object.values(events.plantsBloomedCount).reduce( + (a, b) => a + b, + 0, + ); + }, + fragment_titles: (s: AppStoreShape): string => { + const events = readPendingLetterEvents(s); + const ids = events?.harvestedFragmentIds ?? []; + if (ids.length === 0) return ''; + // Convert ids to a comma-joined human-readable list. Prefer the + // fragment's first-sentence body (tonal weight); fall back to a + // slugified id if the fragment is missing. + return ids + .map((id) => { + const frag = allFragments.find((f) => f.id === id); + if (frag) { + const firstLine = frag.body.split(/[.!?]/)[0]?.trim() ?? ''; + if (firstLine.length > 0 && firstLine.length <= 60) { + return firstLine.toLowerCase(); + } + } + return id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' '); + }) + .join('; '); + }, + lura_was_here: (s: AppStoreShape): boolean => { + const events = readPendingLetterEvents(s); + return Boolean(events?.luraBeatPending); + }, } as const; function compiledInkPath(name: InkBeatName): string { @@ -96,13 +158,23 @@ function stripBom(s: string): string { * choosing the entry knot/path. Throws if the compiled artefact is * missing — runs the diagnostic message past the cause: * "Did `npm run compile:ink` succeed?" + * + * Plan 02-05: dispatch extended to support the letter-from-the-garden + * Ink (UX-02). The three globs — luraStoryGlob, compostStoryGlob, + * letterStoryGlob — give Vite three independent code-split chunks so + * the letter doesn't enter the entry bundle until a returning player + * triggers it. */ export async function loadInkStory(name: InkBeatName): Promise { const path = compiledInkPath(name); - const loader = - name === 'compost-acknowledgements' - ? compostStoryGlob[path] - : luraStoryGlob[path]; + let loader; + if (name === 'compost-acknowledgements') { + loader = compostStoryGlob[path]; + } else if (name === 'letter-from-the-garden') { + loader = letterStoryGlob[path]; + } else { + loader = luraStoryGlob[path]; + } if (!loader) { throw new Error( `[ink-loader] No compiled story at ${path}. Did 'npm run compile:ink' succeed?`, diff --git a/src/sim/garden/auto-harvest.test.ts b/src/sim/garden/auto-harvest.test.ts new file mode 100644 index 0000000..b2a4775 --- /dev/null +++ b/src/sim/garden/auto-harvest.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import type { SimState } from '../state'; +import type { Fragment } from '../../content'; +import { autoHarvestReadyPlants } from './auto-harvest'; +import { type SimContext } from './commands'; +import { emptyTiles, type Tile } from './types'; +import { PLANT_TYPES } from './plants'; +import type { OfflineEventBlock } from '../offline/events'; + +// Deeper warm-tag pool so multi-rosemary tests don't exhaust before the +// auto-harvest sweep finishes. The selector is no-dup, so we need at +// least one warm fragment per ready tile we expect to harvest. +const fixtureFragments: Fragment[] = [ + { id: 'season1.soil.f-warm-1', season: 1, body: 'warm-1', tags: ['warm'] }, + { id: 'season1.soil.f-warm-2', season: 1, body: 'warm-2', tags: ['warm'] }, + { id: 'season1.soil.f-warm-3', season: 1, body: 'warm-3', tags: ['warm'] }, + { id: 'season1.soil.f-warm-4', season: 1, body: 'warm-4', tags: ['warm'] }, + { id: 'season1.soil._exhaustion', season: 1, body: 'sentinel', tags: ['_meta'] }, +]; +const silentCtx: SimContext = { + fragments: fixtureFragments, + currentSeason: 1, + silent: true, +}; + +function freshSimState(overrides: Partial = {}): SimState { + return { + garden: { tiles: emptyTiles() }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: 0, + tickCount: 0, + unlockedPlantTypes: ['rosemary'], + luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, + offlineEvents: null, + settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: false }, + ...overrides, + }; +} + +function withReadyRosemaryAt(...indices: number[]): SimState { + return freshSimState({ + garden: { + tiles: emptyTiles().map((t, i) => + indices.includes(i) + ? { idx: i, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } + : t, + ), + }, + }); +} + +describe('autoHarvestReadyPlants (D-10 silent-mode harvest)', () => { + it('harvests a single ready rosemary and records offlineEvents', () => { + const state = withReadyRosemaryAt(0); + const next = autoHarvestReadyPlants( + state, + PLANT_TYPES.rosemary.durationTicks, + silentCtx, + ); + expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + expect(next.harvestedFragmentIds.length).toBe(1); + expect(next.offlineEvents).not.toBeNull(); + const events = next.offlineEvents as OfflineEventBlock; + expect(events.plantsBloomedCount.rosemary).toBe(1); + expect(events.harvestedFragmentIds.length).toBe(1); + }); + + it('harvests two ready rosemaries and accumulates plantsBloomedCount.rosemary=2', () => { + const state = withReadyRosemaryAt(0, 5); + const next = autoHarvestReadyPlants( + state, + PLANT_TYPES.rosemary.durationTicks, + silentCtx, + ); + expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + expect((next.garden.tiles as Tile[])[5]?.plant).toBeNull(); + expect(next.harvestedFragmentIds.length).toBe(2); + const events = next.offlineEvents as OfflineEventBlock; + expect(events.plantsBloomedCount.rosemary).toBe(2); + expect(events.harvestedFragmentIds.length).toBe(2); + }); + + it('does NOT harvest immature plants (sprout / mature stage)', () => { + const state = withReadyRosemaryAt(0); + // Tick 100 — sprout still (durationTicks = 600, mature at 33% = 198) + const next = autoHarvestReadyPlants(state, 100, silentCtx); + expect((next.garden.tiles as Tile[])[0]?.plant).not.toBeNull(); + expect(next.harvestedFragmentIds.length).toBe(0); + }); + + it('returns the SAME state reference when there are no ready plants (empty grid)', () => { + const state = freshSimState(); + const next = autoHarvestReadyPlants(state, 1000, silentCtx); + expect(next).toBe(state); + }); + + it('after the 1st auto-harvest crosses the threshold, offlineEvents.luraBeatPending === "arrival"', () => { + const state = withReadyRosemaryAt(0); + const next = autoHarvestReadyPlants( + state, + PLANT_TYPES.rosemary.durationTicks, + silentCtx, + ); + expect(next.luraBeatProgress.pending).toBe('arrival'); + const events = next.offlineEvents as OfflineEventBlock; + expect(events.luraBeatPending).toBe('arrival'); + }); + + it('does NOT modify lastTickAt (BLOCKER 3 — saveSync owns that field)', () => { + const state = freshSimState({ + lastTickAt: 99999, + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 + ? { idx: i, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } + : t, + ), + }, + }); + const next = autoHarvestReadyPlants( + state, + PLANT_TYPES.rosemary.durationTicks, + silentCtx, + ); + expect(next.lastTickAt).toBe(99999); + }); + + it('preserves prior offlineEvents when a non-ready tile sweep yields no new harvest', () => { + const priorEvents: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 3 }, + harvestedFragmentIds: ['season1.soil.f-warm-1', 'season1.soil.f-warm-2', 'season1.soil.f-warm-3'], + luraBeatPending: 'arrival', + }; + const state = freshSimState({ offlineEvents: priorEvents }); + const next = autoHarvestReadyPlants(state, 500, silentCtx); + // Empty grid → no new harvest → state ref preserved. + expect(next).toBe(state); + expect(next.offlineEvents).toBe(priorEvents); + }); +}); diff --git a/src/sim/garden/auto-harvest.ts b/src/sim/garden/auto-harvest.ts new file mode 100644 index 0000000..f51993b --- /dev/null +++ b/src/sim/garden/auto-harvest.ts @@ -0,0 +1,90 @@ +import type { SimState } from '../state'; +import type { Tile } from './types'; +import { PLANT_TYPES } from './plants'; +import { advanceGrowth } from './growth'; +import { harvest, type SimContext } from './commands'; +import { + EMPTY_OFFLINE_EVENTS, + aggregateOfflineEvent, + type OfflineEventBlock, +} from '../offline/events'; + +/** + * autoHarvestReadyPlants — silent-mode harvest branch (CONTEXT D-10). + * + * Pure. Called from simulateOneTick when ctx.silent === true (set by the + * boot path's offline catchup loop in src/PhaserGame.tsx — Plan 02-05). + * Walks every tile, identifies plants that have reached the 'ready' + * stage at currentTick, and harvests them via the standard harvest() + * pipeline. Each successful harvest is also recorded into a fresh + * offlineEvents block on the returned state so the letter Ink template + * (UX-02) can narrate what bloomed while the player was away. + * + * BLOCKER 3 invariant preserved — this function NEVER writes lastTickAt + * (the wall-clock ms field is owned by saveSync; sim modules only write + * tickCount). The harvest() pipeline already obeys this invariant; we + * simply thread its return value forward. + * + * Per CLAUDE.md sim-purity rule: no Date.now, no setInterval, no DOM. + * The auto-harvest event log is a pure derivation of (tiles, currentTick, + * ctx.fragments) at call time. + * + * Note on cycle: this module imports `harvest` from './commands' AND + * `commands.ts` imports `autoHarvestReadyPlants` from this file. The + * cycle is benign in ESM because neither function references the other + * at module-init time — both bindings are resolved lazily at call time. + */ +export function autoHarvestReadyPlants( + state: SimState, + currentTick: number, + ctx: SimContext, +): SimState { + let next = state; + const tiles = state.garden.tiles as Tile[]; + // Seed the offline-events accumulator from whatever was already on the + // state (the boot path may chain multiple catchup ticks; the previous + // tick's accumulated events flow through here). + let events: OfflineEventBlock = + (next.offlineEvents as OfflineEventBlock | null) ?? EMPTY_OFFLINE_EVENTS; + + for (let i = 0; i < tiles.length; i++) { + const tile = (next.garden.tiles as Tile[])[i]; + if (!tile?.plant) continue; + const type = PLANT_TYPES[tile.plant.plantTypeId]; + if (!type) continue; + const stage = advanceGrowth(tile.plant, type, currentTick); + if (stage !== 'ready') continue; + + const harvestedBefore = next.harvestedFragmentIds.length; + const plantTypeId = tile.plant.plantTypeId; + + // Reuse the standard harvest pipeline so the fragment selector, + // plant-type unlock thresholds (Pitfall 10), and Lura beat gate + // (STRY-10) all run identically to active-play harvests. + next = harvest(next, i, currentTick, ctx); + + // If a fragment was actually selected (i.e. the harvest committed), + // record the event. selectFragment() can return null in degenerate + // ctx-empty fixtures; in that case harvest() returns the original + // state and harvestedFragmentIds.length is unchanged. + if (next.harvestedFragmentIds.length > harvestedBefore) { + const newId = + next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]; + if (newId) { + events = aggregateOfflineEvent( + events, + plantTypeId, + newId, + next.luraBeatProgress.pending, + ); + } + } + } + + // Only allocate a new state object if events actually changed — keeps + // the no-op path === to the input for downstream identity checks. + if (events === ((next.offlineEvents as OfflineEventBlock | null) ?? EMPTY_OFFLINE_EVENTS)) { + return next; + } + return { ...next, offlineEvents: events }; +} diff --git a/src/sim/garden/commands.ts b/src/sim/garden/commands.ts index 709fc21..6cc064f 100644 --- a/src/sim/garden/commands.ts +++ b/src/sim/garden/commands.ts @@ -7,6 +7,7 @@ import { GRID_SIZE } from './types'; import { advanceGrowth } from './growth'; import { selectFragment } from '../memory/selector'; import { advanceLuraBeatProgress } from '../narrative/lura-gate'; +import { autoHarvestReadyPlants } from './auto-harvest'; /** * Pure command applications. Each returns a NEW SimState — no mutation. @@ -56,10 +57,18 @@ function computePlantUnlocks(harvestCount: number): string[] { * Season. The Garden scene reads `fragments` (eager export from * src/content) at create() time and passes the snapshot through every * simulateOneTick call. Sim modules NEVER import import.meta.glob. + * + * Plan 02-05 extension: `silent` flips on during the boot path's offline + * catchup loop (D-10). When silent, simulateOneTick auto-harvests every + * ready-stage tile via autoHarvestReadyPlants — the player is away, so + * the sim drives harvests instead of waiting for player commands. The + * resulting offlineEvents block feeds the letter Ink template (UX-02). */ export interface SimContext { fragments: readonly Fragment[]; currentSeason: number; + /** Plan 02-05 — silent mode for offline catchup (D-10). */ + silent?: boolean; } export function plantSeed( @@ -222,6 +231,15 @@ export function simulateOneTick( next = compost(next, cmd.tileIdx, currentTick); } } + // Plan 02-05 — silent-mode auto-harvest (D-10). When the player is away, + // the boot path runs the silent catch-up loop with ctx.silent === true, + // so any tile that ripened during absence is harvested by the sim and + // recorded into next.offlineEvents (which feeds the letter UX-02). + // The active-play path leaves ctx.silent false/undefined so the player + // chooses when to harvest ready plants. + if (ctx.silent) { + next = autoHarvestReadyPlants(next, currentTick, ctx); + } return { ...next, tickCount: next.tickCount + 1 }; } diff --git a/src/sim/garden/index.ts b/src/sim/garden/index.ts index e895265..a8d97e9 100644 --- a/src/sim/garden/index.ts +++ b/src/sim/garden/index.ts @@ -14,3 +14,4 @@ export { tileGrowthStage, } from './commands'; export type { SimContext } from './commands'; +export { autoHarvestReadyPlants } from './auto-harvest'; diff --git a/src/sim/index.ts b/src/sim/index.ts index 2590229..7d6cffe 100644 --- a/src/sim/index.ts +++ b/src/sim/index.ts @@ -13,4 +13,5 @@ export * from './scheduler'; export * from './garden'; export * from './memory'; export * from './narrative'; +export * from './offline'; export type { SimState } from './state'; diff --git a/src/sim/offline/events.test.ts b/src/sim/offline/events.test.ts new file mode 100644 index 0000000..8c578d4 --- /dev/null +++ b/src/sim/offline/events.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { + OfflineEventBlockSchema, + EMPTY_OFFLINE_EVENTS, + aggregateOfflineEvent, + type OfflineEventBlock, +} from './events'; + +describe('OfflineEventBlockSchema (D-19 runtime validation)', () => { + it('accepts EMPTY_OFFLINE_EVENTS', () => { + expect(() => OfflineEventBlockSchema.parse(EMPTY_OFFLINE_EVENTS)).not.toThrow(); + }); + + it('accepts a populated block', () => { + const block: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 3, yarrow: 1 }, + harvestedFragmentIds: ['season1.soil.first-bloom', 'season1.soil.the-cat'], + luraBeatPending: 'arrival', + }; + expect(() => OfflineEventBlockSchema.parse(block)).not.toThrow(); + }); + + it('rejects a missing plantsBloomedCount field', () => { + const bad = { + harvestedFragmentIds: [], + luraBeatPending: null, + }; + expect(OfflineEventBlockSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects a wrong-type plantsBloomedCount field', () => { + const bad = { + plantsBloomedCount: { rosemary: 'three' }, + harvestedFragmentIds: [], + luraBeatPending: null, + }; + expect(OfflineEventBlockSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects a fragment id with bad regex', () => { + const bad = { + plantsBloomedCount: {}, + harvestedFragmentIds: ['not-a-valid-id'], + luraBeatPending: null, + }; + expect(OfflineEventBlockSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects a luraBeatPending value outside the enum', () => { + const bad = { + plantsBloomedCount: {}, + harvestedFragmentIds: [], + luraBeatPending: 'goodbye', + }; + expect(OfflineEventBlockSchema.safeParse(bad).success).toBe(false); + }); + + it('accepts luraBeatPending: null', () => { + const ok = { + plantsBloomedCount: {}, + harvestedFragmentIds: [], + luraBeatPending: null, + }; + expect(OfflineEventBlockSchema.safeParse(ok).success).toBe(true); + }); + + it('rejects negative bloom counts', () => { + const bad = { + plantsBloomedCount: { rosemary: -1 }, + harvestedFragmentIds: [], + luraBeatPending: null, + }; + expect(OfflineEventBlockSchema.safeParse(bad).success).toBe(false); + }); +}); + +describe('aggregateOfflineEvent (pure aggregator)', () => { + it('appends a fragment id and increments the plant count', () => { + const next = aggregateOfflineEvent( + EMPTY_OFFLINE_EVENTS, + 'rosemary', + 'season1.soil.first-bloom', + null, + ); + expect(next.plantsBloomedCount).toEqual({ rosemary: 1 }); + expect(next.harvestedFragmentIds).toEqual(['season1.soil.first-bloom']); + expect(next.luraBeatPending).toBeNull(); + }); + + it('two consecutive aggregates increment counts correctly', () => { + const a = aggregateOfflineEvent( + EMPTY_OFFLINE_EVENTS, + 'rosemary', + 'season1.soil.first-bloom', + null, + ); + const b = aggregateOfflineEvent(a, 'rosemary', 'season1.soil.the-cat', null); + expect(b.plantsBloomedCount).toEqual({ rosemary: 2 }); + expect(b.harvestedFragmentIds).toEqual([ + 'season1.soil.first-bloom', + 'season1.soil.the-cat', + ]); + }); + + it('counts different plant types separately', () => { + const a = aggregateOfflineEvent( + EMPTY_OFFLINE_EVENTS, + 'rosemary', + 'season1.soil.first-bloom', + null, + ); + const b = aggregateOfflineEvent( + a, + 'yarrow', + 'season1.soil.what-the-wind-was-for', + null, + ); + expect(b.plantsBloomedCount).toEqual({ rosemary: 1, yarrow: 1 }); + }); + + it('luraBeatPending overwrites only when newer is non-null', () => { + const a = aggregateOfflineEvent( + EMPTY_OFFLINE_EVENTS, + 'rosemary', + 'season1.soil.first-bloom', + 'arrival', + ); + expect(a.luraBeatPending).toBe('arrival'); + // Subsequent harvest with null beat preserves the prior pending value. + const b = aggregateOfflineEvent(a, 'rosemary', 'season1.soil.the-cat', null); + expect(b.luraBeatPending).toBe('arrival'); + // A newer non-null pending overwrites. + const c = aggregateOfflineEvent( + b, + 'rosemary', + 'season1.soil.kettle-on-the-hob', + 'mid', + ); + expect(c.luraBeatPending).toBe('mid'); + }); + + it('does NOT mutate the prev block (immutability)', () => { + const prev: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 2 }, + harvestedFragmentIds: ['season1.soil.first-bloom'], + luraBeatPending: null, + }; + const next = aggregateOfflineEvent( + prev, + 'rosemary', + 'season1.soil.the-cat', + null, + ); + expect(prev.plantsBloomedCount).toEqual({ rosemary: 2 }); + expect(prev.harvestedFragmentIds).toEqual(['season1.soil.first-bloom']); + expect(next).not.toBe(prev); + }); + + it('output round-trips through OfflineEventBlockSchema', () => { + const next = aggregateOfflineEvent( + EMPTY_OFFLINE_EVENTS, + 'rosemary', + 'season1.soil.first-bloom', + 'arrival', + ); + expect(() => OfflineEventBlockSchema.parse(next)).not.toThrow(); + }); +}); diff --git a/src/sim/offline/events.ts b/src/sim/offline/events.ts new file mode 100644 index 0000000..7ea23da --- /dev/null +++ b/src/sim/offline/events.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +/** + * sim/offline/events — OfflineEventBlock Zod runtime validator + pure + * aggregator. Per CONTEXT D-19, D-10, D-11. + * + * Phase 2 ships the minimum slot vocabulary that the letter Ink template + * (UX-02) needs: per-plant counts of plants bloomed, the list of + * auto-harvested fragment ids, and a flag for any newly-unlocked Lura + * beat queued for first-visit. Phase 4+ may add more if playtest + * demands. + * + * Structurally compatible with the OfflineEventBlock interface declared + * in src/save/migrations.ts (Plan 02-01) — that one is the type the + * save layer carries; this file is the runtime validator + aggregator. + * + * Pure. Imports only zod. CORE-10 firewall + Phase-2 sim-purity rule + * still apply: no Date.now, no setInterval, no DOM, no fetch. + */ +export const OfflineEventBlockSchema = z.object({ + plantsBloomedCount: z.record(z.string(), z.number().int().nonnegative()), + harvestedFragmentIds: z.array(z.string().regex(/^season\d+\.[a-z0-9._-]+$/)), + luraBeatPending: z.enum(['arrival', 'mid', 'farewell']).nullable(), +}); + +export type OfflineEventBlock = z.infer; + +/** + * Frozen empty block. The boot path uses this as the seed for the silent + * catch-up loop's offlineEvents accumulator. Object.freeze prevents + * accidental mutation across catchup boundaries. + */ +export const EMPTY_OFFLINE_EVENTS: OfflineEventBlock = Object.freeze({ + plantsBloomedCount: Object.freeze({}) as Record, + harvestedFragmentIds: Object.freeze([]) as unknown as string[], + luraBeatPending: null, +}); + +/** + * aggregateOfflineEvent — pure combiner for a single auto-harvest event + * during the silent-mode catchup loop. + * + * Returns a NEW OfflineEventBlock with: + * - plantsBloomedCount[plantTypeId] incremented by 1 + * - fragmentId appended to harvestedFragmentIds + * - luraBeatPending: prev's value preserved unless the incoming + * `luraBeatPending` is non-null (in which case the most recent + * pending beat wins — Phase 2 has at most one pending beat at a + * time per advanceLuraBeatProgress's invariant in + * src/sim/narrative/lura-gate.ts). + * + * Per CONTEXT D-17/D-19 — this is the slot vocabulary the letter Ink + * template renders. + */ +export function aggregateOfflineEvent( + prev: OfflineEventBlock, + plantTypeId: string, + fragmentId: string, + luraBeatPending: OfflineEventBlock['luraBeatPending'], +): OfflineEventBlock { + const counts = { ...prev.plantsBloomedCount }; + counts[plantTypeId] = (counts[plantTypeId] ?? 0) + 1; + return { + plantsBloomedCount: counts, + harvestedFragmentIds: [...prev.harvestedFragmentIds, fragmentId], + luraBeatPending: luraBeatPending ?? prev.luraBeatPending, + }; +} diff --git a/src/sim/offline/index.ts b/src/sim/offline/index.ts new file mode 100644 index 0000000..925d1c4 --- /dev/null +++ b/src/sim/offline/index.ts @@ -0,0 +1,13 @@ +/** + * Public barrel for src/sim/offline/. + * + * Phase 2 Plan 02-05 — silent-mode offline catchup feeds the OfflineEventBlock + * the letter Ink template (UX-02) renders. Per CORE-10 + Phase-2 sim-purity: + * pure module, no Date.now / setInterval / DOM / fetch. + */ +export { + OfflineEventBlockSchema, + EMPTY_OFFLINE_EVENTS, + aggregateOfflineEvent, +} from './events'; +export type { OfflineEventBlock } from './events'; diff --git a/src/ui/letter/letter-renderer.test.ts b/src/ui/letter/letter-renderer.test.ts new file mode 100644 index 0000000..3d2c783 --- /dev/null +++ b/src/ui/letter/letter-renderer.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { buildLetterSlots } from './letter-renderer'; +import type { Fragment } from '../../content'; +import type { OfflineEventBlock } from '../../sim/offline'; + +const fragments: Fragment[] = [ + { + id: 'season1.soil.first-bloom', + season: 1, + tags: ['warm'], + body: 'The first thing that grew was rosemary.', + }, + { + id: 'season1.soil.the-cat', + season: 1, + tags: ['warm'], + body: 'The cat is missing now too.', + }, + // A fragment whose first sentence is longer than 60 chars — the + // helper should fall back to the slugified id. + { + id: 'season1.soil.the-very-long-one', + season: 1, + tags: ['contemplative'], + body: + 'There is a kind of evening light that lasts longer than the day requires of it, and the garden seems to know.', + }, +]; + +describe('buildLetterSlots (UX-02 + D-17 — pure slot builder)', () => { + it('returns all-empty slots when events is null', () => { + const slots = buildLetterSlots(null, fragments); + expect(slots).toEqual({ + plants_bloomed: 0, + fragment_titles: '', + lura_was_here: false, + }); + }); + + it('counts a single rosemary auto-harvest as plants_bloomed=1', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 1 }, + harvestedFragmentIds: ['season1.soil.first-bloom'], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.plants_bloomed).toBe(1); + // First-sentence slug, lowercased. + expect(slots.fragment_titles).toBe('the first thing that grew was rosemary'); + expect(slots.lura_was_here).toBe(false); + }); + + it('joins multiple fragment titles with semicolon-space', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 2 }, + harvestedFragmentIds: ['season1.soil.first-bloom', 'season1.soil.the-cat'], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.fragment_titles).toBe( + 'the first thing that grew was rosemary; the cat is missing now too', + ); + }); + + it('falls back to slugified id when first sentence is too long', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { yarrow: 1 }, + harvestedFragmentIds: ['season1.soil.the-very-long-one'], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + // Slug is the id with `season1.` stripped and dots/underscores → spaces. + expect(slots.fragment_titles).toBe('soil the very long one'); + }); + + it('falls back to slugified id when fragment is missing from corpus', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 1 }, + harvestedFragmentIds: ['season1.soil.unknown-fragment'], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.fragment_titles).toBe('soil unknown fragment'); + }); + + it('lura_was_here flips true when luraBeatPending is non-null', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: {}, + harvestedFragmentIds: [], + luraBeatPending: 'arrival', + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.lura_was_here).toBe(true); + }); + + it('lura_was_here is false when luraBeatPending is null', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 1 }, + harvestedFragmentIds: ['season1.soil.first-bloom'], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.lura_was_here).toBe(false); + }); + + it('counts multiple plant types together (D-17 plants_bloomed is total)', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 3, yarrow: 2 }, + harvestedFragmentIds: [], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.plants_bloomed).toBe(5); + }); + + it('handles a 24h-cap edge case — 50 plants bloomed, no truncation (D-11 silent cap)', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: { rosemary: 50 }, + harvestedFragmentIds: [], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + // The Ink template handles "many" copy ('plants_bloomed > 1' branch). + // The helper passes the raw count through; no numeric "28h" copy + // appears anywhere here either. + expect(slots.plants_bloomed).toBe(50); + }); + + it('returns empty fragment_titles when no harvested ids (zero-bloom path)', () => { + const events: OfflineEventBlock = { + plantsBloomedCount: {}, + harvestedFragmentIds: [], + luraBeatPending: null, + }; + const slots = buildLetterSlots(events, fragments); + expect(slots.fragment_titles).toBe(''); + expect(slots.plants_bloomed).toBe(0); + }); +}); diff --git a/src/ui/letter/letter-renderer.ts b/src/ui/letter/letter-renderer.ts new file mode 100644 index 0000000..66f579b --- /dev/null +++ b/src/ui/letter/letter-renderer.ts @@ -0,0 +1,62 @@ +import type { OfflineEventBlock } from '../../sim/offline'; +import type { Fragment } from '../../content'; + +/** + * letter-renderer — pure helper that converts an OfflineEventBlock + + * the fragment corpus into the slot values for letter-from-the-garden.ink. + * + * Separated from the Letter.tsx React component so the slot-building + * logic is testable without spinning up happy-dom + the Ink runtime. + * + * Per CONTEXT D-17 / D-18 / UX-02 — the slots are the templated + * insertions; the Ink skeleton holds the voice. The fragment-titles + * slot prefers the first-sentence body of each fragment (tonal weight) + * with a slugified-id fallback for anything that fails to resolve. + * + * Per CONTEXT D-11: this helper does not surface a numeric "X hours" + * value — the Ink template handles "many" copy on its own. The + * letter-renderer never touches wall-clock time. + * + * Pure. No DOM, no Date.now, no fetch. + */ +export interface LetterSlots { + plants_bloomed: number; + fragment_titles: string; + lura_was_here: boolean; +} + +const EMPTY_SLOTS: LetterSlots = Object.freeze({ + plants_bloomed: 0, + fragment_titles: '', + lura_was_here: false, +}); + +export function buildLetterSlots( + events: OfflineEventBlock | null, + allFragments: readonly Fragment[], +): LetterSlots { + if (!events) return EMPTY_SLOTS; + const total = Object.values(events.plantsBloomedCount).reduce( + (a, b) => a + b, + 0, + ); + const titles = events.harvestedFragmentIds + .map((id) => { + const frag = allFragments.find((f) => f.id === id); + // Prefer the fragment's first sentence (≤60 chars) for tonal + // weight; fall back to slugified id for missing fragments. + if (frag) { + const firstLine = frag.body.split(/[.!?]/)[0]?.trim() ?? ''; + if (firstLine.length > 0 && firstLine.length <= 60) { + return firstLine.toLowerCase(); + } + } + return id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' '); + }) + .filter((t) => t.length > 0); + return { + plants_bloomed: total, + fragment_titles: titles.join('; '), + lura_was_here: events.luraBeatPending !== null, + }; +}