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, }; }