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
This commit is contained in:
2026-05-09 10:49:59 -04:00
parent de3f55b1c4
commit 26eb77a216
12 changed files with 828 additions and 8 deletions
+80 -8
View File
@@ -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<string, number>;
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<Story> {
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?`,
+141
View File
@@ -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> = {}): 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);
});
});
+90
View File
@@ -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 };
}
+18
View File
@@ -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 };
}
+1
View File
@@ -14,3 +14,4 @@ export {
tileGrowthStage,
} from './commands';
export type { SimContext } from './commands';
export { autoHarvestReadyPlants } from './auto-harvest';
+1
View File
@@ -13,4 +13,5 @@ export * from './scheduler';
export * from './garden';
export * from './memory';
export * from './narrative';
export * from './offline';
export type { SimState } from './state';
+168
View File
@@ -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();
});
});
+68
View File
@@ -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<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,
};
}
+13
View File
@@ -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';
+139
View File
@@ -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);
});
});
+62
View File
@@ -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,
};
}