/**
* First-turn onboarding โ the pure report builder (no LM Studio dependency).
*
* LM Studio auto-generates the settings UI, but it knows nothing about what
* makes *this* plugin actually work: whether the active universe has a player
* persona, NPCs, lore, a world, and whether an embedding model is loaded for
* semantic matching. A new user can therefore start a bland, empty session
* without understanding why โ and without knowing how to fix it or how to play.
*
* So the report has four parts, shown once on the first turn of a story:
* 1. **Readiness** โ one โ
/โ ๏ธ/โน๏ธ line per check.
* 2. **How to fix** โ concrete, step-by-step instructions, but ONLY for the
* items that are actually wrong (no noise when everything is fine).
* 3. **How to play** โ the controls (free actions, numbered choices, `/mj`).
* 4. **Tips for a great session** โ how to get fun, coherent role-play.
*
* Pure: the handler gathers the facts (I/O โ file loaders, the embedding model
* list, the token-source kind) and passes them in here, mirroring how the rest
* of the codebase splits pure logic from side effects. So this stays unit-tested
* and decoupled from `world/`, `characters/` and `memory/`.
*/
const OK = "โ
";
const WARN = "โ ๏ธ";
const INFO = "โน๏ธ";
export interface SetupFacts {
/** Active universe identifier (used in the advice paths). */
universe: string;
/** Save slot / story name for this playthrough (empty = the default story). */
storyName: string;
/** A player persona (`player.json`) was loaded. */
hasPlayer: boolean;
/** The player persona's name, when present (for a friendlier line). */
playerName: string;
/** Number of NPC cards loaded for this universe. */
npcCount: number;
/** Number of lore entries available (file + cards' `character_book`). */
loreCount: number;
/** The resolved world name (may be empty or the "Untitled" default). */
worldName: string;
/** Whether a real world name (non-default) or a setting description is set. */
worldSet: boolean;
/** Whether semantic matching is switched on in config. */
semanticRequested: boolean;
/** Whether an embedding model is actually available for semantic matching. */
embeddingAvailable: boolean;
/** Whether the token source is a real model (so the sampling settings apply). */
realModelTokenSource: boolean;
/** Structured game mechanics are active (a `rules.json` is loaded + enabled). */
mechanicsActive: boolean;
}
export interface SetupReport {
/** The full block text, ready to emit (Markdown). */
text: string;
/** True if at least one check produced a warning (something worth fixing). */
hasWarnings: boolean;
}
/**
* Build the first-turn readiness report. Pure and deterministic.
*
* Each check yields one checklist line โ `โ
` good, `โ ๏ธ` likely to degrade play,
* `โน๏ธ` informational โ and warnings additionally contribute a step-by-step fix
* paragraph under "How to fix". The how-to-play and tips sections always follow.
*/
export function buildSetupReport(f: SetupFacts): SetupReport {
const checks: string[] = [];
const fixes: string[] = [];
let warnings = 0;
const pkg = `universes/${f.universe}/`;
const dir = `${pkg}characters/`;
const worldFile = `${pkg}world.json`;
// An entirely empty universe is the most common (and most confusing) mistake
// โ usually the wrong "Active universe" is selected. Call it out first.
const empty = !f.hasPlayer && f.npcCount === 0 && f.loreCount === 0 && !f.worldSet;
if (empty) {
checks.push(
`${WARN} The "${f.universe}" universe looks empty โ no player, NPCs, lore or world.`,
);
fixes.push(
`**The universe is empty.** Check the **Active universe** setting points ` +
`to the world you prepared. If you're starting from scratch, build the ` +
`package \`${pkg}\`: \`world.json\` (name + setting), a \`characters/\` ` +
`folder with cards, and optionally \`lore.json\` (world info) โ then send ` +
`your message again.`,
);
warnings++;
}
if (f.hasPlayer) {
checks.push(`${OK} Player character: ${f.playerName || "(unnamed)"}.`);
} else {
checks.push(`${WARN} No player character.`);
fixes.push(
`**Add a player character.** Create \`player.json\` in \`${dir}\` โ a JSON ` +
`card with at least a name and description, e.g. ` +
`\`{ "name": "Mara", "description": "A wandering cartographer with a debt ` +
`to the sea." }\`. SillyTavern V2/V3 cards work too. This is who you play.`,
);
warnings++;
}
if (f.npcCount > 0) {
checks.push(`${OK} NPCs available: ${f.npcCount}.`);
} else {
checks.push(`${WARN} No NPC cards โ scenes will feel empty.`);
fixes.push(
`**Add some NPCs.** Drop one or more \`*.json\` character cards (any name ` +
`except \`player.json\`) in \`${dir}\`. They step on stage when the scene ` +
`mentions them; pin a key one so it's always present with ` +
`\`"always_active": true\` on its card.`,
);
warnings++;
}
if (f.worldSet) {
checks.push(`${OK} World: ${f.worldName.trim() || "(set)"}.`);
} else {
checks.push(`${WARN} World name/setting empty.`);
fixes.push(
`**Describe the world.** Create \`${worldFile}\` with a \`name\` and a ` +
`\`setting\` (a sentence or two of premise and atmosphere), e.g. ` +
`\`{ "name": "Saltmere", "setting": "A fog-bound smuggler's port where ` +
`the law is for sale." }\`. It anchors every scene; you can also set a ` +
`\`narration\` style there. Otherwise the world defaults to "Untitled".`,
);
warnings++;
}
if (f.loreCount > 0) {
checks.push(`${OK} World lore entries: ${f.loreCount}.`);
} else {
checks.push(`${INFO} No world lore (optional).`);
}
if (f.embeddingAvailable) {
checks.push(
`${OK} Embedding model loaded โ lore and characters trigger by meaning ` +
`(even across languages).`,
);
} else if (f.semanticRequested) {
checks.push(
`${WARN} No embedding model loaded โ lore and characters trigger on exact ` +
`keywords only.`,
);
fixes.push(
`**Turn on smart memory (embeddings).** Right now lore and characters only ` +
`fire on exact keywords. In LM Studio, open the **Search / Download** tab ` +
`(the magnifier), search for an **embedding model** โ a multilingual one ` +
`is best, e.g. \`bge-m3\` or \`nomic-embed-text\` โ then download and load ` +
`it. Semantic matching switches on automatically: a line in French can ` +
`surface English lore, and topics pull in the right characters. ` +
`Optionally set its exact name in this plugin's **global** settings โ ` +
`**Embedding model**.`,
);
warnings++;
} else {
checks.push(
`${INFO} Semantic matching is off (keyword-only). Turn it on in settings ` +
`and load an embedding model for cross-language triggering.`,
);
}
if (!f.realModelTokenSource) {
checks.push(
`${INFO} Generation is driven by another generator/plugin โ this plugin's ` +
`temperature and length settings won't apply.`,
);
}
if (f.mechanicsActive) {
checks.push(
`${OK} Game mechanics on โ character sheet, dice rolls and HP are active ` +
`(\`${pkg}rules.json\` found).`,
);
} else {
checks.push(
`${INFO} Game mechanics off โ pure narrative. Add \`${pkg}rules.json\` ` +
`(stats, resources, archetypes) and keep "Game mechanics" on to enable ` +
`the character sheet, dice rolls and HP.`,
);
}
const story = f.storyName.trim() ? `story "${f.storyName.trim()}"` : "default story";
const howToPlay = [
"## How to play",
"- **Write what your character does**, in plain language โ the game master " +
"narrates the outcome and never acts for you.",
"- Each reply ends with **numbered options**: type a number, or just describe " +
"your own action.",
"- **Steer with `/mj`:** `/mj <instruction>` (ongoing), `/mj! <instruction>` " +
"(once), `/mj tone <mood>`, `/mj clear` (wipe steering), `/mj restart` " +
"(erase the current save and start this playthrough over).",
].join("\n");
const tips = [
"## Tips for a great session",
"- **Be specific, and name people and places** โ the narrator mirrors your " +
"detail, and characters and lore activate when mentioned (or by meaning, " +
"with embeddings on).",
"- **Set genre and pacing early** with `/mj` (horror, romance, slow-burnโฆ); " +
"change your mind any time. A rolling summary keeps long sessions coherent " +
"past the context window.",
"- **Another adventure in the same world?** Settings โ **Story** โ " +
"`๏ผ New story`, name it โ separate memory and progress, same universe.",
].join("\n");
const sections: string[] = [
`# Setup check โ universe "${f.universe}", ${story}`,
"A quick readiness check before we begin. Fix anything marked " +
`${WARN} for the best experience; ${INFO} items are optional.`,
checks.join("\n"),
];
if (fixes.length > 0) {
sections.push(["## How to fix", ...fixes.map((x) => `- ${x}`)].join("\n\n"));
}
sections.push(howToPlay, tips);
return { text: sections.join("\n\n"), hasWarnings: warnings > 0 };
}
/** Facts needed to render the opening title card. */
export interface TitleCardFacts {
/** The resolved world name (may be empty or the "Untitled" default). */
worldName: string;
/** The world's setting/premise (a sentence or two); used for the subtitle. */
setting: string;
/** Active universe identifier โ humanized as a fallback title. */
universe: string;
}
/**
* Build the opening title card shown once, right after the first-turn setup
* report โ the curtain rising on a new story, so the narration doesn't begin
* cold against the onboarding text. A framed world title with a short premise as
* an epigraph subtitle. Pure and deterministic; emitted in its own
* `includeInContext: false` block so it never re-enters the model's history.
*
* Returns "" when there is nothing worth titling (no world name and no
* universe), so the caller can simply skip the block.
*/
export function buildTitleCard(f: TitleCardFacts): string {
const title = resolveTitle(f.worldName, f.universe);
if (!title) return "";
const subtitle = firstSentence(f.setting, 200);
const lines = [TITLE_DIVIDER, "", `# ${title}`];
if (subtitle) lines.push("", `*${subtitle}*`);
lines.push("", TITLE_DIVIDER);
return lines.join("\n");
}
/**
* An elegant typographic separator that frames the opening title โ a centered
* flourish rather than a plain `---` rule. Uses widely-supported glyphs so it
* renders consistently in the LM Studio chat.
*/
const TITLE_DIVIDER = "โฆ ยท โฆ ยท โฆ";
/** Prefer a real world name; fall back to a humanized universe id. */
function resolveTitle(worldName: string, universe: string): string {
const name = worldName.trim();
if (name && name !== "Untitled") return name;
return universe
.trim()
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* The first sentence of `text` for use as an epigraph subtitle: trimmed to the
* first sentence terminator, then capped to `max` characters on a word boundary
* (with an ellipsis) if still too long. Returns "" for empty input.
*/
function firstSentence(text: string, max: number): string {
const t = text.replace(/\s+/g, " ").trim();
if (!t) return "";
const end = t.search(/[.!?](?:\s|$)/);
let s = end >= 0 ? t.slice(0, end + 1) : t;
if (s.length > max) {
const cut = s.slice(0, max);
const lastSpace = cut.lastIndexOf(" ");
s = (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[.,;:]+$/, "") + "โฆ";
}
return s;
}