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