/**
* Game-master directive parsing — the `/mj ...` convention.
*
* The player drives the narration out-of-character by starting a line with
* `/mj`. These lines are stripped from the in-fiction text (they are never
* spoken by the player character) and turned into structured commands that
* mutate `DirectorState`.
*
* Grammar (one per line, anywhere in the message):
* /mj <text> persistent directive (e.g. "steer toward horror")
* /mj! <text> one-shot directive (applies to the next turn only)
* /mj tone <value> set the ambient tone ("/mj tone romantic")
* /mj tone clear the ambient tone
* /mj clear clear all directives + tone (reset steering)
* /mj restart wipe THIS playthrough (memory, sheets, arc, turn) and start over
* /mj reset alias of /mj restart
*/
import { Directive, DirectorState } from "../state/schema.js";
export type DirectiveCommand =
| { kind: "add"; scope: "persistent" | "once"; text: string }
| { kind: "set-tone"; tone: string }
| { kind: "clear" };
export interface ParsedDirectives {
/** Commands to apply to the director state, in order. */
commands: DirectiveCommand[];
/**
* The player asked to wipe THIS playthrough (`/mj restart` / `/mj reset`).
* A control command, not a director-state mutation: it ends the turn and
* resets the save, so it lives outside `commands` (the reducer ignores it).
*/
restart: boolean;
/** The message with all `/mj` lines removed (the in-fiction action). */
cleanedText: string;
}
// `/mj` or `/mj!`, then a space (or end of line), then the directive body.
// The lookahead keeps `/mjabc` from being treated as a command.
const MJ_LINE = /^\s*\/mj(!)?(?=\s|$)[ \t]*(.*)$/i;
/** Extract `/mj` commands from a raw player message. */
export function parseDirectives(text: string): ParsedDirectives {
const commands: DirectiveCommand[] = [];
const kept: string[] = [];
let restart = false;
for (const line of text.split(/\r?\n/)) {
const m = line.match(MJ_LINE);
if (!m) {
kept.push(line);
continue;
}
const bang = m[1] === "!";
const body = m[2].trim();
// The bare word only — `/mj restart the engine` stays an ordinary steer.
if (!bang && /^(restart|reset)$/i.test(body)) {
restart = true;
continue;
}
if (!bang && /^clear$/i.test(body)) {
commands.push({ kind: "clear" });
continue;
}
const tone = body.match(/^tone\b[ \t:]*(.*)$/i);
if (!bang && tone) {
commands.push({ kind: "set-tone", tone: tone[1].trim() });
continue;
}
if (body.length > 0) {
commands.push({ kind: "add", scope: bang ? "once" : "persistent", text: body });
}
}
return { commands, restart, cleanedText: kept.join("\n").trim() };
}
let counter = 0;
/** Local, collision-resistant id (state is single-process per universe). */
function nextId(turn: number): string {
counter = (counter + 1) % 1_000_000;
return `d${turn}-${counter}`;
}
/**
* Pure reducer: apply parsed commands to a director state, returning a new one.
* `turn` stamps newly created directives.
*/
export function applyDirectives(
director: DirectorState,
commands: DirectiveCommand[],
turn: number,
): DirectorState {
let tone = director.tone;
let directives: Directive[] = [...director.directives];
for (const cmd of commands) {
switch (cmd.kind) {
case "clear":
tone = "";
directives = [];
break;
case "set-tone":
tone = cmd.tone;
break;
case "add":
directives.push({
id: nextId(turn),
text: cmd.text,
scope: cmd.scope,
createdAtTurn: turn,
});
break;
}
}
return { tone, directives };
}
/**
* Consume one-shot directives after a turn has been assembled: `once`
* directives are dropped so they only affect a single generation.
*/
export function consumeOnce(director: DirectorState): DirectorState {
return {
tone: director.tone,
directives: director.directives.filter((d) => d.scope !== "once"),
};
}