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,47 @@
|
||||
// Letter from the garden — UX-02 + CONTEXT D-17 + D-18 + D-20.
|
||||
//
|
||||
// Composed from authored skeleton + templated insertions per CONTEXT D-17.
|
||||
// Slots populated at runtime from sim/offline/events.ts via the variable
|
||||
// map in src/content/ink-loader.ts.
|
||||
//
|
||||
// Per Pitfall 4: Ink VAR names are snake_case AND case-sensitive.
|
||||
// Per CONTEXT D-11: 24h offline cap is silent in voice — no numeric
|
||||
// "28h" copy in any branch.
|
||||
// Per CLAUDE.md Tone — the gardener-keeper voice. Warm. Specific.
|
||||
// Intermittent. Sometimes funny, sometimes devastating. Never a stat
|
||||
// dump (UX-02 explicitly forbids that). The skeleton holds the voice;
|
||||
// the slots fill in the specifics.
|
||||
// Per anti-fomo-doctrine.md: this letter is NOT a "you missed X — come
|
||||
// back tomorrow!" nag. It is a contemplative summary of what stayed.
|
||||
|
||||
VAR plants_bloomed = 0
|
||||
VAR fragment_titles = ""
|
||||
VAR lura_was_here = false
|
||||
VAR fragment_count = 0
|
||||
VAR last_plant_type = ""
|
||||
|
||||
== letter ==
|
||||
|
||||
The garden held its breath while you were gone.
|
||||
|
||||
{ plants_bloomed > 1:
|
||||
{plants_bloomed} blooms came and went, each leaving the soil a little quieter than they found it.
|
||||
- else:
|
||||
{ plants_bloomed == 1:
|
||||
One bloom came and went. The space it left feels generous, somehow.
|
||||
- else:
|
||||
Nothing bloomed. The wind carried something else, and the garden held that, too.
|
||||
}
|
||||
}
|
||||
|
||||
{ fragment_titles != "":
|
||||
Among what stayed: {fragment_titles}.
|
||||
}
|
||||
|
||||
{ lura_was_here:
|
||||
Lura came by once. She did not knock. She left a folded leaf on the gate post — you'll find it when you next walk past.
|
||||
}
|
||||
|
||||
The light is the same as when you left. The garden is older.
|
||||
|
||||
-> END
|
||||
@@ -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?`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export {
|
||||
tileGrowthStage,
|
||||
} from './commands';
|
||||
export type { SimContext } from './commands';
|
||||
export { autoHarvestReadyPlants } from './auto-harvest';
|
||||
|
||||
@@ -13,4 +13,5 @@ export * from './scheduler';
|
||||
export * from './garden';
|
||||
export * from './memory';
|
||||
export * from './narrative';
|
||||
export * from './offline';
|
||||
export type { SimState } from './state';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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