Files
TheLastGarden/src/sim/offline/events.ts
T
josh 26eb77a216 feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer
- src/sim/offline/: OfflineEventBlockSchema (Zod) + EMPTY_OFFLINE_EVENTS
  + aggregateOfflineEvent pure aggregator (D-19); 14 tests green
- src/sim/garden/auto-harvest.ts: autoHarvestReadyPlants silent-mode
  branch (D-10); reuses harvest() pipeline so selector + Pitfall 10
  unlocks + STRY-10 Lura gate all run identically; BLOCKER 3 invariant
  preserved (no lastTickAt writes); 7 tests green
- simulateOneTick: ctx.silent triggers auto-harvest sweep before tick
  increment; active-play path unchanged (silent defaults false)
- content/dialogue/season1/letter-from-the-garden.ink: authored skeleton
  with VAR plants_bloomed / fragment_titles / lura_was_here per D-17/D-18;
  bible voice, anti-FOMO compliant, 24h cap silent in voice (D-11)
- ink-loader: loadInkStory union extended with letter-from-the-garden;
  separate letterStoryGlob for lazy code-split chunk; INK_VARIABLE_MAP
  gains plants_bloomed / fragment_titles / lura_was_here slots reading
  from session.pendingLetterEventBlock
- src/ui/letter/letter-renderer.ts: pure buildLetterSlots helper —
  prefers fragment first-sentence body for tonal weight, slugified-id
  fallback; 10 tests green
- npm run compile:ink emits 5 .ink.json files (was 4); Vite emits the
  letter as a separate lazy chunk (letter-from-the-garden.ink-*.js)
- 295/295 tests green (was 264; +31 new); npm run ci exits 0
2026-05-09 10:49:59 -04:00

69 lines
2.7 KiB
TypeScript

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<typeof OfflineEventBlockSchema>;
/**
* 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<string, number>,
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,
};
}