/**
* Central, typed, validated state for a role-play universe.
*
* One schema, one source of truth (Waidrin lesson): static types come from the
* same Zod schema that validates at runtime, so they can never drift. The state
* is persisted OUTSIDE the chat history (see the persistence caveat in
* docs/plugin-blueprint.md §6) — one JSON file per universe.
*
* This is the MVP slice: `world` + `director`. The `characters`, `memory` and
* full `lorebook` fields are added in later phases without breaking this shape.
*/
import { z } from "zod";
/** A game-master directive issued by the player via `/mj`. */
export const Directive = z.object({
/** Stable id (so a directive can be referenced or removed later). */
id: z.string(),
/** The instruction text, e.g. "steer toward a horror atmosphere". */
text: z.string(),
/**
* `persistent` directives are re-applied every turn until cleared;
* `once` directives are consumed after a single turn.
*/
scope: z.enum(["persistent", "once"]),
/** Turn index at which the directive was created (for ordering / cleanup). */
createdAtTurn: z.number().int().nonnegative(),
});
export type Directive = z.infer<typeof Directive>;
/** Narration steering — the player-facing "game master" knobs. */
export const DirectorState = z.object({
/**
* Ambient mood layered on top of the configured narration style, e.g.
* "horror", "romantic". Set/overwritten by `/mj` tone shifts.
*/
tone: z.string().default(""),
/** Active game-master directives, applied in order. */
directives: z.array(Directive).default([]),
});
export type DirectorState = z.infer<typeof DirectorState>;
/**
* A resolved numbered pick recorded after the fact (Phase D, Lot 2): which
* option the player chose on which turn, with the option's full text. Stored
* only for now — nothing consumes it yet (groundwork for Phase F/G recall).
*/
export const ChoiceRecord = z.object({
/** Turn index at which the option was picked. */
turn: z.number().int().nonnegative(),
/** The numbered option that was selected. */
index: z.number().int(),
/** The full text of that option (recovered from the previous reply). */
text: z.string(),
});
export type ChoiceRecord = z.infer<typeof ChoiceRecord>;
/** Minimal world description (expanded into lore/locations in later phases). */
export const WorldState = z.object({
name: z.string().default("Untitled"),
setting: z.string().default(""),
});
export type WorldState = z.infer<typeof WorldState>;
/**
* Long-term memory: a single rolling summary of the conversation so far
* (Phase C). It is the only persisted memory artifact and the only exception to
* the blueprint's "everything is ephemeral" rule — but it lives here in the
* per-universe state, NOT in the chat history, so the history stays clean and
* disabling the plugin leaves no residue. Updated incrementally at the end of a
* turn and reinjected as the `# Story so far` block the next turn.
*/
/**
* One archived conversation message kept verbatim in the vector-RAG store
* (Phase F). The rolling summary (above) keeps continuity but recompresses fine
* old details away; these chunks preserve the exact text so a specific detail
* from far back can be recalled when the recent context makes it relevant.
*/
export const MemoryChunk = z.object({
/** The verbatim message text (already role-tagged, e.g. "Player: …"). */
text: z.string(),
/** Turn at which the message was archived into the store (recency/debug). */
turn: z.number().int().nonnegative().default(0),
});
export type MemoryChunk = z.infer<typeof MemoryChunk>;
export const MemoryState = z.object({
/**
* The CURRENT chapter's rolling summary, recompressed every few messages while
* an act is in progress (`# Chapter so far`). Reset to "" when the conductor
* advances the act — its substance is folded into `storySummary` at that close.
* (Was the whole-story rolling summary before the two-tier split; an old save's
* value simply becomes the first chapter's summary and is integrated at the
* next chapter close — no migration.)
*/
summary: z.string().default(""),
/**
* The whole-story summary — a summary of chapter summaries (`# Story so far`).
* Updated ONLY at a chapter close, when the just-ended chapter's `summary` is
* integrated into it by a dedicated pass; otherwise stable across turns, so its
* size does not grow with the number of chapters. Additive default → no
* migration; "" until the first chapter closes (then the narrator sees only the
* chapter summary, which is the right behaviour for the opening act).
*/
storySummary: z.string().default(""),
/**
* How many leading messages have already been folded into a persisted summary
* (the chapter `summary` now, the story summary for older chapters). Still the
* safe pruning boundary: everything before it is captured in one tier or the
* other. Not advanced on a chapter reset, so the old chapter's un-summarized
* tail rolls into the next chapter's summary rather than being lost.
*/
summarizedMessageCount: z.number().int().nonnegative().default(0),
/**
* Vector-RAG store (Phase F): the verbatim text of messages that have left
* the protected recent window, for precise recall. Additive with a default,
* so saves written before this field existed reparse unchanged — no migration.
*/
store: z.array(MemoryChunk).default([]),
/** How many leading messages have already been archived into `store`. */
storedMessageCount: z.number().int().nonnegative().default(0),
});
export type MemoryState = z.infer<typeof MemoryState>;
/**
* Structured game mechanics (Phase G). A single resource change applied on one
* tier of a move's outcome.
*/
export const Delta = z.object({
/**
* Whose sheet the change hits: "player" or a combatant id. Validated against
* live combatants by the engine; an unknown target invalidates the move.
*/
target: z.string().default("player"),
/** Resource key, validated against the universe rules. */
resource: z.string(),
/** Dice/number expression, e.g. "-1d6", "+2", "+1d6". */
expr: z.string(),
});
export type Delta = z.infer<typeof Delta>;
/**
* The machine spec for a risky numbered choice carried to the next turn. Deltas
* are grouped by PbtA outcome tier; `resolveMove` rolls 2d6+stat at pick time
* and applies the matching tier.
*/
export const Move = z.object({
/** Stat keying the 2d6 roll; "" means no roll (a plain narrative choice). */
stat: z.string().default(""),
/**
* The narrator's risk judgment for a rolled option, one of
* `trivial | risky | dangerous | desperate` (or "" when not rolled). The
* engine maps it to a flat bonus on the 2d6 total (see `DIFFICULTY_MODIFIERS`
* in `rules/resolve.ts`). Additive with a default → no migration. Probability
* is never shown to the player (decision 5); only the tier outcome is narrated.
*/
difficulty: z.string().default(""),
/**
* Opposition (Phase G — opposition rolls). When `opposedBy` names a LIVE
* combatant and `opposingStat` is one of their stats, that stat is subtracted
* from the 2d6 total: a strong foe is genuinely harder to beat than a weak one,
* in place of (or alongside) the flat `difficulty` against the environment.
* Both default to "" → no opposition, so an unopposed roll behaves exactly as
* before (additive, no migration). The id is validated against live combatants
* by the engine, exactly like `Delta.target`: an unknown/dead id simply yields
* no opposition (the roll falls back to its flat difficulty).
*/
opposedBy: z.string().default(""),
opposingStat: z.string().default(""),
/** Resource changes on a miss (≤6). */
miss: z.array(Delta).default([]),
/** Resource changes on a partial success (7–9). */
partial: z.array(Delta).default([]),
/** Resource changes on a full hit (10+); rewards live here. */
hit: z.array(Delta).default([]),
});
export type Move = z.infer<typeof Move>;
/**
* A numbered choice carried from the previous turn together with its (optional)
* resolution move — the dice analogue of the rolling summary: produced at the
* end of one turn, consumed at the start of the next.
*/
export const PendingChoice = z.object({
index: z.number().int(),
/** null = a plain narrative option with no dice. */
move: Move.nullable().default(null),
});
export type PendingChoice = z.infer<typeof PendingChoice>;
/**
* A character sheet: the player's, or an (ephemeral) combatant's. Stats and
* resources are plain key→value maps validated against the universe rules at
* use; the schema stays permissive so any declared rule set fits.
*/
/**
* Relationship memory (the relationship pass). How two characters currently
* regard each other, evolving across the story — the missing piece that lets an
* NPC treat the player as a stranger on first meeting and warm (or sour) over
* time, instead of re-deriving the bond from scratch every turn.
*
* `familiarity` gates social knowledge (a stranger does not know the player's
* name); `disposition` is a signed standing that drifts; `summary` is a rolling,
* recursively-recompressed history of the two characters — the relationship
* analogue of `memory.summary`. The MVP fills only player↔NPC pairs; the record
* is keyed in `State.relationships` and the shape is ready for NPC↔NPC later.
*/
export const FAMILIARITY = ["stranger", "acquaintance", "known", "close"] as const;
export const Relationship = z.object({
/**
* How well the pair knows each other. Never decreases (a falling-out lowers
* `disposition`, not acquaintance). The engine bumps stranger→acquaintance in
* code on first co-presence; the relationship pass may raise it further.
*/
familiarity: z.enum(FAMILIARITY).default("stranger"),
/**
* One character's standing toward the other, drifting over the story. The
* engine clamps it to a symmetric bound (~[-100, 100]); ~0 is neutral, negative
* is wary/hostile, positive is warm. A single scalar is the MVP (richer axes —
* trust, respect — can come later).
*/
disposition: z.number().default(0),
/**
* The most negative `disposition` this pair has EVER reached — the lasting scar
* of a grave falling-out (a "low-water mark"). It only ever ratchets DOWN, never
* up, so the damage is remembered for good. The engine uses it to cap how far
* warmth can be rebuilt (`relationshipScarFactor`): the deeper the wound, the
* lower the ceiling on recovery — so a betrayal can be forgiven part-way but
* never erased. `0` (the default) means no scar; additive → no migration, and
* with the scar factor at 0 the field is inert (disposition recovers fully).
*/
lowWaterMark: z.number().default(0),
/**
* A dense, recursively-recompressed summary of the pair's shared history —
* folded in by the relationship pass and reinjected as part of the
* `# Relationships` block. The relationship analogue of `memory.summary`.
*/
summary: z.string().default(""),
/** Turn at which this record was last touched (recency / debug). */
updatedTurn: z.number().int().nonnegative().default(0),
});
export type Relationship = z.infer<typeof Relationship>;
/**
* Character psyche — the fluctuating STATE layer of an NPC (Phase J), distinct
* from the stable TRAIT layer (the card's Big Five) and from the RELATION layer
* (`Relationship`, how they regard the player). This is what the character feels
* and wants RIGHT NOW: it changes every turn, written by the social pass (the
* relationship pass extended in Phase J) from the scene just narrated, and read
* both by the social referee (to rule on a stance) and by the narrator (to voice
* the character true to their current state). Keyed in `State.psyche` by the
* lowercased character name, like `npcSheets`/`relationships`. Additive default →
* no migration; populated only when the feature is on.
*/
export const Psyche = z.object({
/** Current emotional state, a short phrase ("guarded, nursing a grudge"). */
mood: z.string().default(""),
/** Short-term intention toward the player / scene next ("wants them gone"). */
intent: z.string().default(""),
/** What this character is fixated on pursuing right now (their live focus). */
goalFocus: z.string().default(""),
/** Turn at which this record was last touched (recency / debug). */
updatedTurn: z.number().int().nonnegative().default(0),
});
export type Psyche = z.infer<typeof Psyche>;
/**
* Knowledge gating (the revelation system). What the PLAYER has actually learned
* over the story — the discrete facts a character has disclosed to them. This is
* the gate that keeps a guarded NPC from dumping a secret on first meeting: a
* character's secret (declared on their card) stays OUT of the narrator's prompt
* until its gate opens, and gates chain — fact B unlocks only once the player
* already knows fact A (`Secret.requires`). The set grows post-narration via the
* revelation pass (`src/knowledge/`), which reads the scene just shown and marks
* which eligible secret was genuinely disclosed. Additive default → no migration;
* populated only when an active card declares `secrets`.
*/
export const KnowledgeState = z.object({
/**
* Fact ids the player has learned, where a fact id is a `Secret.id`. Two cards
* may share an id (the same fact known from two sources); revealing it from
* either marks it known once. Used both to keep a revealed fact's content
* available to the narrator and as the prerequisite set other secrets gate on.
*/
playerKnownFacts: z.array(z.string()).default([]),
});
export type KnowledgeState = z.infer<typeof KnowledgeState>;
/**
* First-mention introduction (the disclosure system). Which lore elements and
* characters this playthrough's narration has already INTRODUCED — so the
* narrator grounds a place, faction or NPC the first time it brings it on, and
* stops once the player has met it. The complement to knowledge gating: that
* system withholds an NPC's *secrets* from the prompt; this one withholds
* nothing — it just asks for a short framing on first appearance and records,
* post-narration, what was actually named. The set grows by a pure keyword pass
* over the reply (no model call). Additive default → no migration; populated
* only when the feature is on. See `src/disclosure/`.
*/
export const DisclosureState = z.object({
/**
* Disclosure keys already introduced, namespaced by kind: `lore:<id|hash>` for
* a lore entry, `npc:<lowercased name>` for a character. A key present here is
* no longer flagged as a first appearance, so the intro note disappears once
* the element has been named in the prose. See `src/disclosure/`.
*/
revealed: z.array(z.string()).default([]),
});
export type DisclosureState = z.infer<typeof DisclosureState>;
/**
* Dramatic-arc pacing (Phase I): the conductor's working memory + where the
* story currently is in the universe's authored arc (`arc.json`, loaded as
* content like rules/lore — NOT persisted here). This slice is the *playthrough*
* progress through that score, written by the pacing orchestrator at the end of a
* turn and read the next turn to (a) gate which card secrets are eligible by act
* and (b) colour the narration with the act's mood. Top-level (not under
* `director`) so the `/mj` reducer never touches it. Additive default → no
* migration; populated only when the dramatic-arc feature is on.
*/
export const PacingState = z.object({
/**
* Id of the act the story is currently in (an `ArcAct.id`). "" before the
* first turn initializes it; the engine resolves "" / an unknown id to the
* arc's first act, so an old save with no pacing simply starts at act one.
*/
actId: z.string().default(""),
/** Turn at which the current act was entered, for the min/max-turn bounds. */
actStartedTurn: z.number().int().nonnegative().default(0),
/**
* The conductor's running read of dramatic tension, 0 (becalmed) → 1 (climax).
* Persisted so "speed up / slow down" decisions are coherent across beats.
*/
tension: z.number().min(0).max(1).default(0),
/** Turn the conductor last emitted an event beat (cadence: a beat, not a tick). */
lastBeatTurn: z.number().int().nonnegative().default(0),
/** Live unresolved threads the conductor is tracking (short labels). */
openThreads: z.array(z.string()).default([]),
});
export type PacingState = z.infer<typeof PacingState>;
/**
* World clock — temporality + weather (the chronos subsystem). Where this
* playthrough's clock currently sits: the time of day, how many in-fiction days
* have elapsed, the season, and the live weather. The authored *model* (the
* phases, seasons and weather palette of the setting) is NOT here — it is content
* loaded from `world.json` each turn (see `src/chronos/`), like the arc. Written
* at the end of each turn by `advanceClock` (a gentle code drive plus an optional
* diegetic signal read off the scene) and read the next turn into the
* `# Time & weather` block. Additive default → no migration; populated only when
* the feature is on. The phase/season/weather are stored as strings (resolved
* defensively, like `pacing.actId`) so a change to the authored model never
* corrupts an existing save.
*/
export const ChronosState = z.object({
/** Current time-of-day phase (a value from the model's `phases`); "" = the model's opening. */
phase: z.string().default(""),
/** In-fiction days elapsed since the story began (0-based; rendered "Day N+1"). */
dayCount: z.number().int().nonnegative().default(0),
/** Current season (a value from the model's `seasons`); "" = none / the first. */
season: z.string().default(""),
/** Current weather condition (a value from the model's palette); "" = weather off. */
weather: z.string().default(""),
/** Turns the current weather has held — drives the gentle code-side drift. */
weatherHeld: z.number().int().nonnegative().default(0),
});
export type ChronosState = z.infer<typeof ChronosState>;
/**
* Scene presence (the scene-presence subsystem). Where the scene currently is and
* who is physically on stage — the model's authoritative reading, written by the
* conductor's piggyback at each beat (it already reads the scene; +0 model calls)
* and freshened deterministically every turn by the planner (always-active NPCs,
* those the player names, and those bound to the current `location`). Read into
* the cast tiers so the narrator is handed the full card only for who is actually
* present, and a thin off-scene line for the rest. Additive default → no
* migration; populated only when the time-and-presence feature is on (off leaves
* it inert and the cast assembly byte-identical). Strings, resolved defensively
* (like `pacing.actId`), so a content change never corrupts an existing save.
*/
export const SceneState = z.object({
/**
* Where the scene is now — a free-text place name the conductor read off the
* narration (e.g. "the Drowned Lantern"). "" = unknown / not yet read. Matched
* against each NPC card's `location` to bind a place's regulars on stage.
*/
location: z.string().default(""),
/**
* Display names of the NPCs the conductor judged physically present at the end
* of the scene it last read. The authoritative roster for staging, carried
* forward between beats and augmented each turn by the planner. Empty = nobody
* read as present yet.
*/
present: z.array(z.string()).default([]),
/** Turn this reading was written (for staleness/debug; mirrors other slices). */
updatedTurn: z.number().int().nonnegative().default(0),
});
export type SceneState = z.infer<typeof SceneState>;
export const Sheet = z.object({
/** Whether starting values have been seeded from the rules (player sheet). */
initialized: z.boolean().default(false),
/** Display name for combatants; "" for the player. */
label: z.string().default(""),
stats: z.record(z.number()).default({}),
resources: z.record(z.number()).default({}),
});
export type Sheet = z.infer<typeof Sheet>;
/** The persisted state of a single universe. */
export const State = z.object({
/** Schema version, for forward migrations. */
version: z.literal(1).default(1),
/** Universe identifier (matches the state file name). */
universe: z.string(),
/** Monotonic turn counter (one increment per processed player message). */
turn: z.number().int().nonnegative().default(0),
/**
* Whether the first-turn onboarding/setup report has already been shown for
* this story. Additive with a default, so older `version: 1` files reparse as
* `false` and get the report once on their next turn — no migration.
*/
onboarded: z.boolean().default(false),
world: WorldState.default({}),
director: DirectorState.default({}),
/**
* Long-term memory (Phase C). Additive with a default, so `version: 1` files
* written before this field existed reparse unchanged — no migration needed.
*/
memory: MemoryState.default({}),
/**
* Structured game mechanics (Phase G). All additive with defaults, so saves
* written before these fields existed reparse unchanged — no migration, and
* a universe with no `rules.json` simply never populates them.
*/
sheet: Sheet.default({}),
/** Ephemeral combatant sheets (enemies/allies), keyed by id; cleared on defeat. */
combatants: z.record(Sheet).default({}),
/**
* Card-derived stat blocks for named characters, keyed by lowercased name
* (Phase G sheet generation). Generated once from a card's prose and cached
* so a named NPC entering combat fights with stats that match their
* description rather than a generic archetype. Additive default → no
* migration; never populated when a universe has no `rules.json`.
*/
npcSheets: z.record(Sheet).default({}),
/** Set by the engine when the player's `endWhenZero` resource hits its floor. */
gameOver: z.boolean().default(false),
/** Last turn's tagged choices, consumed to resolve this turn's selection. */
pendingChoices: z.array(PendingChoice).default([]),
/**
* Relationship memory (the relationship pass). Per-pair standing keyed
* `"<a>|<b>"` with lowercased character keys and `"player"` for the player
* (MVP: only `"player|<npc>"` pairs are filled). Additive default → no
* migration; populated only when relationship memory is on. See
* `src/relationships/`.
*/
relationships: z.record(Relationship).default({}),
/**
* How many leading conversation messages have already been folded into the
* relationship summaries — the relationship pass's own marker, the analogue of
* `memory.summarizedMessageCount`, so it digests only what is new since last
* time and is reset (like the summary) when history shrinks. Additive default
* → no migration.
*/
relationshipsDigestedCount: z.number().int().nonnegative().default(0),
/**
* Character psyche (Phase J): the fluctuating per-NPC state — mood / intent /
* goalFocus — keyed by lowercased character name, written by the social pass
* and read by the social referee and narrator. Additive default → no migration;
* populated only when the feature is on. See `src/psyche/`.
*/
psyche: z.record(Psyche).default({}),
/**
* Knowledge gating (the revelation system): what the player has learned so far,
* gating which of an NPC's card-declared secrets may enter the narrator's
* prompt. Additive default → no migration; populated only when an active card
* declares `secrets`. See `src/knowledge/`.
*/
knowledge: KnowledgeState.default({}),
/**
* First-mention introduction (the disclosure system): which lore elements and
* characters the narration has already introduced this playthrough, so a place
* or person is grounded on first appearance and not re-introduced after.
* Additive default → no migration; populated only when the feature is on. See
* `src/disclosure/`.
*/
disclosure: DisclosureState.default({}),
/**
* Dramatic-arc pacing (Phase I): where this playthrough is in the universe's
* authored arc, plus the conductor's running tension / open threads. Additive
* default → no migration; populated only when the dramatic-arc feature is on.
*/
pacing: PacingState.default({}),
/**
* World clock (the chronos subsystem): the live time of day, day count, season
* and weather. Additive default → no migration; populated only when the
* time-and-weather feature is on (off leaves it at the inert default and the
* `# Time & weather` block omitted, so the turn is byte-identical).
*/
chronos: ChronosState.default({}),
/**
* Scene presence (the scene-presence subsystem): where the scene is and who is
* physically on stage — the conductor's reading, carried between beats. Additive
* default → no migration; populated only when scene-presence is on (off leaves
* it inert and the cast assembly byte-identical).
*/
scene: SceneState.default({}),
/**
* History of resolved numbered picks (Phase D, Lot 2). Additive with a
* default, so saves written before this field existed reparse unchanged — no
* migration. Store-only for now; capped to the most recent `choiceHistoryMax`.
*/
choiceHistory: z.array(ChoiceRecord).default([]),
});
export type State = z.infer<typeof State>;
/** Build a fresh, valid state for a brand-new universe. */
export function createState(universe: string): State {
return State.parse({ universe });
}