/**
* World clock — temporality + weather (the "chronos" subsystem).
*
* Like the dramatic arc, this splits into AUTHORED CONTENT (the `ChronosModel` —
* how a day is divided, the seasons, the weather palette of this setting) and
* PLAYTHROUGH STATE (the `ChronosState` in `state/schema.ts` — where the clock
* currently is). The model is loaded fresh each turn from `world.json` (see
* `load.ts`), never persisted; the state is persisted per save and advanced at
* the end of each turn.
*
* Advancement is DIEGETIC-FIRST: the narration is the source of truth. A code
* drive nudges the clock gently each turn so the world feels alive even when the
* player loiters (time creeps phase by phase; weather drifts along its palette),
* and an optional per-turn SIGNAL — read off the scene by a pass that is already
* running (the conductor, +0 model calls) — overrides that drive when the story
* itself moves time ("they travel three days") or turns the weather ("a storm
* breaks"). When no such pass runs (arc off), only the code drive applies; that
* is the accepted trade-off of the zero-extra-cost design.
*
* Pure + unit-testable: this file is the model, the defaults, the tolerant
* normalization, the pure `advanceClock`, and the prompt-block renderer. No I/O,
* no `@lmstudio/sdk`. Loading from disk lives in `load.ts`.
*/
import type { ChronosState } from "../state/schema.js";
/** A setting's authored clock + climate. All fields have sensible defaults. */
export interface ChronosModel {
/**
* The ordered phases of a day, walked front to back; crossing the LAST back to
* the first is a new day (increments `dayCount`). Always non-empty (defaults
* apply when a universe authors none).
*/
phases: string[];
/** The phase a fresh playthrough opens on (must be one of `phases`). */
start: string;
/** Ordered seasons; empty = the setting has no seasons (the season line is omitted). */
seasons: string[];
/**
* The weather conditions possible in this setting, ordered roughly calm →
* severe (the code drift walks neighbours, so ordering shapes how weather
* moves). EMPTY = weather is disabled for this universe (only the time line is
* shown) — the safe default, since a generic palette would clash with a
* setting it does not fit (fog in a desert). Author one to turn weather on.
*/
weather: string[];
/** The weather a fresh playthrough opens on (one of `weather`; "" = palette[0]). */
weatherStart: string;
/**
* A free-text climate note for THIS setting (e.g. "cold, wet, fog-bound;
* storms off the grey sea"), rendered into the block as steering context so
* the narrator paints weather in the setting's register. Optional.
*/
bias: string;
}
/**
* The built-in clock used when a universe authors no `time` block: a plain
* six-phase day opening at morning, four seasons. Weather is intentionally OFF
* by default (empty palette) — it is setting-specific and a wrong palette reads
* worse than none, so a universe opts in by authoring one.
*/
export const DEFAULT_CHRONOS_MODEL: ChronosModel = {
phases: ["dawn", "morning", "midday", "afternoon", "dusk", "night"],
start: "morning",
seasons: ["spring", "summer", "autumn", "winter"],
weather: [],
weatherStart: "",
bias: "",
};
/** Coerce an unknown value into a trimmed, non-empty string list (dedup, ordered). */
function toStringList(raw: unknown): string[] {
const arr = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
const out: string[] = [];
const seen = new Set<string>();
for (const v of arr) {
const s = String(v).trim();
if (!s || seen.has(s.toLowerCase())) continue;
seen.add(s.toLowerCase());
out.push(s);
}
return out;
}
/**
* Normalize a parsed `world.json` object into a {@link ChronosModel}, reading its
* optional `time` / `weather` blocks and falling back to the defaults field by
* field. Tolerant of junk and of field aliases (`phases` | `dayParts`,
* `palette` | `conditions`); anything unusable yields the default, never throws.
*/
export function normalizeChronosModel(raw: unknown): ChronosModel {
const o = raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const time = o.time && typeof o.time === "object" ? (o.time as Record<string, unknown>) : {};
const weather =
o.weather && typeof o.weather === "object" ? (o.weather as Record<string, unknown>) : {};
const phases = toStringList(time.phases ?? (time as Record<string, unknown>).dayParts);
const seasons = toStringList(time.seasons);
const palette = toStringList(weather.palette ?? weather.conditions);
const model: ChronosModel = {
phases: phases.length > 0 ? phases : DEFAULT_CHRONOS_MODEL.phases,
start: "",
seasons: seasons.length > 0 ? seasons : DEFAULT_CHRONOS_MODEL.seasons,
weather: palette, // empty unless authored — weather opt-in
weatherStart: "",
bias: typeof weather.bias === "string" ? weather.bias.trim() : "",
};
// Resolve the opening phase: authored `start` if it names a real phase, else
// the model's natural opening (DEFAULT start when phases are the defaults,
// else the first phase).
const startRaw = typeof time.start === "string" ? time.start.trim() : "";
model.start = model.phases.includes(startRaw)
? startRaw
: model.phases.includes(DEFAULT_CHRONOS_MODEL.start)
? DEFAULT_CHRONOS_MODEL.start
: model.phases[0];
const wStartRaw = typeof weather.start === "string" ? weather.start.trim() : "";
model.weatherStart = model.weather.includes(wStartRaw) ? wStartRaw : (model.weather[0] ?? "");
return model;
}
/** The current phase, resolved defensively: the state's phase if valid, else the model's start. */
export function resolvePhase(model: ChronosModel, state: ChronosState): string {
return model.phases.includes(state.phase) ? state.phase : model.start;
}
/** The current season, or "" when the setting has none. */
export function resolveSeason(model: ChronosModel, state: ChronosState): string {
if (model.seasons.length === 0) return "";
return model.seasons.includes(state.season) ? state.season : model.seasons[0];
}
/** The current weather, or "" when the setting has no palette. */
export function resolveWeather(model: ChronosModel, state: ChronosState): string {
if (model.weather.length === 0) return "";
return model.weather.includes(state.weather) ? state.weather : model.weatherStart;
}
/**
* Diegetic elapsed-time buckets a narration-reading pass (the conductor) may
* report. Mapped to a number of day-phase steps by {@link elapsedToSteps}.
*/
export const ELAPSED_BUCKETS = [
"none",
"moments",
"hours",
"halfday",
"nextday",
"days",
"weeks",
] as const;
export type ElapsedBucket = (typeof ELAPSED_BUCKETS)[number];
/** How many phase-steps a reported elapsed bucket advances the clock. */
export function elapsedToSteps(model: ChronosModel, bucket: ElapsedBucket): number {
const day = Math.max(1, model.phases.length);
switch (bucket) {
case "hours":
return 1;
case "halfday":
return Math.max(1, Math.floor(day / 2));
case "nextday":
return day;
case "days":
return day * 2;
case "weeks":
return day * 7;
default: // none / moments — within the current phase
return 0;
}
}
/** One optional diegetic signal read off the scene, overriding the code drive. */
export interface ChronosSignal {
/** How much in-fiction time the scene just passed. */
elapsed?: ElapsedBucket;
/** The weather the scene established now (must be in the palette to apply). */
weather?: string;
}
export interface AdvanceOptions {
/** The turn being played (drives the gentle code creep of time). */
turn: number;
/** Auto-advance one phase every Nth turn (0 = never; only the signal moves time). */
turnsPerPhase: number;
/** Drift weather to a neighbour after it has held this many turns (0 = never). */
weatherHold: number;
/** Injected randomness (the impure boundary supplies `() => Math.random()`). */
rng: () => number;
/** A diegetic override read off the narration, when a pass produced one. */
signal?: ChronosSignal | null;
}
/** Pick the next weather: a neighbour in the palette, so weather drifts gradually. */
function driftWeather(palette: string[], current: string, rng: () => number): string {
if (palette.length <= 1) return palette[0] ?? current;
const i = palette.indexOf(current);
if (i < 0) return palette[0];
const neighbours: string[] = [];
if (i > 0) neighbours.push(palette[i - 1]);
if (i < palette.length - 1) neighbours.push(palette[i + 1]);
if (neighbours.length === 0) return current;
return neighbours[Math.floor(rng() * neighbours.length) % neighbours.length];
}
/**
* Advance the world clock for one turn (pure). Time advances by the diegetic
* signal when present, else creeps one phase every `turnsPerPhase` turns; each
* wrap past the last phase increments `dayCount`. Weather is set outright by the
* signal, else drifts to a neighbouring condition once it has held long enough.
* Defensive: an invalid stored phase/weather resolves to the model's opening.
*/
export function advanceClock(
model: ChronosModel,
state: ChronosState,
opts: AdvanceOptions,
): ChronosState {
const phases = model.phases;
let idx = Math.max(0, phases.indexOf(resolvePhase(model, state)));
let dayCount = state.dayCount;
// --- Time ---
let steps = 0;
if (opts.signal?.elapsed) {
steps = elapsedToSteps(model, opts.signal.elapsed);
} else if (opts.turnsPerPhase > 0 && opts.turn % opts.turnsPerPhase === 0) {
steps = 1;
}
for (let k = 0; k < steps; k++) {
idx += 1;
if (idx >= phases.length) {
idx = 0;
dayCount += 1;
}
}
const phase = phases[idx] ?? state.phase;
// --- Weather ---
let weather = resolveWeather(model, state);
let weatherHeld = state.weatherHeld;
if (model.weather.length > 0) {
const signalled = opts.signal?.weather?.trim();
if (signalled && model.weather.includes(signalled)) {
weather = signalled;
weatherHeld = 0;
} else {
weatherHeld += 1;
if (opts.weatherHold > 0 && weatherHeld >= opts.weatherHold) {
weather = driftWeather(model.weather, weather, opts.rng);
weatherHeld = 0;
}
}
} else {
weather = "";
weatherHeld = 0;
}
return {
phase,
dayCount,
season: resolveSeason(model, state),
weather,
weatherHeld,
};
}
/**
* The `# Time & weather` block: the current hour, day and weather as plain
* context, plus a PRINCIPLE (not an example — a worked sentence would bias the
* prose) asking the narrator to let them suffuse the scene's senses and never
* contradict them. Placed right after `# World`. Returns the block string; the
* caller only renders it when the feature is on, so off stays byte-identical.
*/
export function chronosBlock(model: ChronosModel, state: ChronosState): string {
const phase = resolvePhase(model, state);
const season = resolveSeason(model, state);
const weather = resolveWeather(model, state);
const facts: string[] = [`It is ${phase}`];
if (season) facts.push(`in ${season}`);
let line = facts.join(" ") + `. Day ${state.dayCount + 1} of the story.`;
if (weather) line += ` The weather: ${weather}.`;
const lines = ["# Time & weather", line];
if (model.bias.trim()) {
lines.push(`Climate of this place: ${model.bias.trim()}.`);
}
lines.push(
"Let the time of day" +
(weather ? " and the weather" : "") +
" suffuse the scene's sensory texture — the light, the temperature, the " +
"sound and the mood they cast — without ever contradicting them or " +
"stating them as a bare label. They are the world's to change over time, " +
"not the player's to declare.",
);
return lines.join("\n");
}