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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user