diff --git a/backend/tools/branch_fixture.py b/backend/tools/branch_fixture.py index d53b65e..679da92 100644 --- a/backend/tools/branch_fixture.py +++ b/backend/tools/branch_fixture.py @@ -67,6 +67,9 @@ modifier(text); class ScriptedProvider: replies: list = [] + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None calls = 0 def __init__(self, *a, **k): diff --git a/backend/tools/shots_fixture.py b/backend/tools/shots_fixture.py index 88279f6..2dc0de1 100644 --- a/backend/tools/shots_fixture.py +++ b/backend/tools/shots_fixture.py @@ -151,6 +151,9 @@ class ScriptedProvider: That difference is what the screenshots show. """ + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None next_reply = ("", {}) diff --git a/backend/tools/stress_session.py b/backend/tools/stress_session.py index 35ab40a..515b9ed 100644 --- a/backend/tools/stress_session.py +++ b/backend/tools/stress_session.py @@ -196,6 +196,10 @@ class FakeProvider: time. """ + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None + def __init__(self, *a, **k): pass diff --git a/backend/tools/tree_fixture.py b/backend/tools/tree_fixture.py index fb8aa71..184a81d 100644 --- a/backend/tools/tree_fixture.py +++ b/backend/tools/tree_fixture.py @@ -57,6 +57,9 @@ PROSE = [ class ScriptedProvider: calls = 0 + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None def __init__(self, *a, **k): pass diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index bb6f621..1a4311f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -6,7 +6,7 @@ import Home from './pages/Home.jsx' import Adventures from './pages/Adventures.jsx' import Scenarios from './pages/Scenarios.jsx' import ScenarioEditor from './pages/ScenarioEditor.jsx' -import Play from './pages/Play.jsx' +import Play from './pages/Play' import Scripts from './pages/Scripts.jsx' import ScriptEditor from './pages/ScriptEditor.jsx' import Settings from './pages/Settings.jsx' diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx deleted file mode 100644 index 1854b40..0000000 --- a/frontend/src/pages/Play.jsx +++ /dev/null @@ -1,2280 +0,0 @@ -import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { useNavigate, useParams } from 'react-router-dom' -import { api } from '../api' -import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components' -import { BranchMap } from '../BranchMap' -import { branchLabel, headLineage, orderBranches } from '../branches' - -const MODES = ['do', 'say', 'story'] -const PLAYER_TYPES = ['do', 'say', 'story'] - -// Models often emit light markdown emphasis; render **bold** / *italic* -// instead of showing raw asterisks. Everything else stays plain text. -function renderEmphasis(text) { - const re = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g - const parts = [] - let last = 0 - let match - while ((match = re.exec(text)) !== null) { - if (match.index > last) parts.push(text.slice(last, match.index)) - parts.push(match[1] !== undefined - ? {match[1]} - : {match[2]}) - last = match.index + match[0].length - } - if (parts.length === 0) return text - if (last < text.length) parts.push(text.slice(last)) - return parts -} - -function ReasoningBlock({ text, streaming }) { - if (!text) return null - return ( -
- 💭 Reasoning{streaming ? '…' : ''} -
{text}
-
- ) -} - -const SECTION_LABELS = { - narrator: 'Narrator prompt', - script_context: 'Script context', - ai_instructions: 'AI Instructions', - plot_essentials: 'Plot Essentials', - story_summary: 'Story Summary', - used_memories: 'Used Memories (memory bank)', - world_state_guide: 'World State (stat guide)', - world_state: 'World State (RPG)', - world_state_rule: 'World State (reporting rule)', - world_lore: 'World Lore (story cards)', - history: 'Story history', - authors_note: "Author's Note", - recent_history: 'Recent history', - front_memory: 'Front memory', - length_hint: 'Length guidance', - world_state_reminder: 'World State (emit reminder)', -} - -// One colour per context section, and the single source of truth for it: the -// token bar, the legend and each section's own header all read from here, so a -// slice of the bar and the text it stands for always carry the same colour. -// Related sections share a hue family but never an exact shade — in a stacked -// bar two identical colours read as one section. -const SECTION_COLORS = { - narrator: '#7d8fc9', - ai_instructions: '#9c8fd6', - plot_essentials: '#c97dc0', - script_context: '#d99ad0', - story_summary: '#7dc9a2', - used_memories: '#5fb8c9', - world_lore: '#c9b47d', - world_state: '#d79a63', - world_state_guide: '#b8834a', - world_state_rule: '#9d7a52', - world_state_reminder: '#8a6f52', - history: '#74748c', - recent_history: '#9d9db4', - authors_note: '#c97d7d', - front_memory: '#d99a9a', - length_hint: '#98a06b', -} -const SECTION_FALLBACK = '#6a6a78' -const sectionColor = (label) => SECTION_COLORS[label] || SECTION_FALLBACK - -// Share of the prompt, rounded for glanceability. Sections too small to round -// to a whole percent still say so rather than showing a misleading 0%. -const pctLabel = (pct) => (pct > 0 && pct < 1 ? '<1%' : `${Math.round(pct)}%`) - -const FIELD_LABELS = { - memory: 'Plot Essentials (Memory)', - authors_note: "Author's Note", - ai_instructions: 'AI Instructions', -} - -function clip(text, n = 90) { - const one = (text || '').replace(/\s+/g, ' ').trim() - return one.length > n ? `${one.slice(0, n)}…` : (one || '(empty)') -} - -// Confirms "Update from scenario" by showing exactly what it would change, and -// collects any ${Placeholder} answers the adventure has no stored value for -// (adventures started before those were saved, or a placeholder the author -// added since). Destructive by design — it overwrites plot text and -// scenario-derived cards — so nothing happens until Update is pressed. -function RefreshModal({ plan, onConfirm, onCancel }) { - const [values, setValues] = useState( - Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])), - ) - const [busy, setBusy] = useState(false) - const { added = [], updated = [], removed = [] } = plan.cards || {} - const world = plan.world_state || {} - const fields = Object.entries(plan.fields || {}) - - const submit = (e) => { - e.preventDefault() - setBusy(true) - onConfirm(values).finally(() => setBusy(false)) - } - - // Portalled to : this modal is opened from inside .side-panel, whose - // panel-in animation (fill mode `both`) makes it the containing block for - // position:fixed children — an overlay rendered in place would be trapped in - // the 420px panel and clipped by its overflow. Same trap for any future modal - // opened from a drawer or panel. - return createPortal( -
-
e.stopPropagation()} onSubmit={submit}> -

Update from scenario

- {!plan.has_changes ? ( -

- This adventure already matches “{plan.scenario_title}”. Nothing to update. -

- ) : ( - <> -

- Pull the current content of “{plan.scenario_title}” down over this adventure. - Your story, its title, summary and your own story cards are untouched — but - the plot text below is overwritten, including any edits you made here. -

-
- {fields.map(([field, diff]) => ( -
- {FIELD_LABELS[field] || field} -
− {clip(diff.old)}
-
+ {clip(diff.new)}
-
- ))} - {added.length > 0 && ( -
Story cards added: {added.join(', ')}
- )} - {updated.length > 0 && ( -
Story cards overwritten: {updated.join(', ')}
- )} - {removed.length > 0 && ( -
Story cards removed: {removed.join(', ')}
- )} - {world.added?.length > 0 && ( -
- New stats (at their starting value): {world.added.join(', ')} -
- )} - {world.removed?.length > 0 && ( -
Stats removed: {world.removed.join(', ')}
- )} -
- {(plan.placeholders_needed || []).length > 0 && ( - <> -

- This scenario asks a few questions, and this adventure has no saved - answers for them. They'll be remembered from now on. -

- {plan.placeholders_needed.map((name) => ( - - ))} - - )} - - )} -
- - {plan.has_changes && ( - - )} -
-
-
, - document.body, - ) -} - -function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) { - const toast = useToast() - const [plan, setPlan] = useState(null) // non-null while the modal is open - const [planning, setPlanning] = useState(false) - // One timer per field/card: a single shared timer would cancel the pending - // save of whatever was edited previously within the debounce window. - const saveTimers = useRef(new Map()) - const debounceSave = (key, fn) => { - clearTimeout(saveTimers.current.get(key)) - saveTimers.current.set(key, setTimeout(fn, 600)) - } - - const setField = (field, value) => { - setAdventure({ ...adventure, [field]: value }) - debounceSave(field, () => api.updateAdventure(adventure.id, { [field]: value })) - } - - const addCard = async () => { - const card = await api.createStoryCard({ adventure_id: adventure.id }) - setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] }) - } - - const updateCard = (card) => { - setAdventure({ - ...adventure, - story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)), - }) - debounceSave(`card-${card.id}`, () => { - api.updateStoryCard(card.id, { - name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes, - }) - }) - } - - const deleteCard = async (cardId) => { - await api.deleteStoryCard(cardId) - setAdventure({ - ...adventure, - story_cards: adventure.story_cards.filter((c) => c.id !== cardId), - }) - } - - const exportCards = async () => { - const cards = await api.exportStoryCards({ adventure_id: adventure.id }) - downloadJSON(cards, `${(adventure.title || 'adventure').replace(/\W+/g, '-')}-cards.json`) - } - - const importCards = async () => { - try { - const parsed = await pickJSONFile() - const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) - if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error') - const created = await api.importStoryCards({ adventure_id: adventure.id, cards }) - setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] }) - } catch (err) { - toast(err.message, 'error') - } - } - - // Ask the server what a refresh would do, then let the player confirm it. - const openRefresh = async () => { - setPlanning(true) - try { - setPlan(await api.previewRefresh(adventure.id)) - } catch (err) { - // 404 = the scenario was deleted or unshared; there's nothing to sync to. - toast(err.message, 'error') - } finally { - setPlanning(false) - } - } - - const applyRefresh = async (placeholders) => { - try { - const updated = await api.refreshFromScenario(adventure.id, placeholders) - setAdventure(updated) - setPlan(null) - onWorldStateChanged?.() - toast('Updated from scenario.') - } catch (err) { - toast(err.message, 'error') - } - } - - return ( -
- {adventure.scenario_id != null && ( -
- - Copied from its scenario when the adventure began; later scenario edits don't - reach it on their own. - - -
- )} - {plan && ( - setPlan(null)} /> - )} - - setField('memory', v)} textarea - placeholder="Key facts the AI should always remember." /> - setField('authors_note', v)} textarea rows={2} - placeholder="Style/theme guidance, injected near the end of context." /> - setField('ai_instructions', v)} textarea rows={2} - placeholder="Behavioral guidance for the model." /> - setField('story_summary', v)} textarea - placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." /> - -
-

Story Cards

-
- - - -
-
- {adventure.story_cards.length === 0 && ( -
No story cards yet.
- )} - {adventure.story_cards.map((card) => ( - deleteCard(card.id)} /> - ))} -
- ) -} - -function MemoryRow({ memory, onChange, onDelete }) { - const [editText, setEditText] = useState(null) - - const save = () => { - const text = editText.trim() - setEditText(null) - if (text && text !== memory.text) onChange({ text }) - } - - return ( -
- {editText !== null ? ( -
- setEditText(e.target.value)} /> -
- - -
-
- ) : ( - <> -
{memory.text}
-
- {memory.pinned && 📌 pinned} - {memory.forgotten && forgotten} - {!memory.embedded && !memory.forgotten && ( - not embedded yet - )} - {memory.source_start != null && ( - actions {memory.source_start}–{memory.source_end} - )} - used {memory.use_count}× - - - {memory.forgotten && ( - - )} - - - -
- - )} -
- ) -} - -function MemoryPanel({ adventure, setAdventure, refreshKey }) { - const [memories, setMemories] = useState(null) - const [newText, setNewText] = useState('') - - const load = useCallback(() => { - api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([])) - }, [adventure.id]) - - // Summarization runs in the background after a turn, so also refresh shortly after. - useEffect(() => { - load() - const timer = setTimeout(load, 4000) - return () => clearTimeout(timer) - }, [load, refreshKey]) - - const setFlag = async (field, value) => { - setAdventure({ ...adventure, [field]: value }) - await api.updateAdventure(adventure.id, { [field]: value }) - } - - const change = async (memory, data) => { - const updated = await api.updateMemory(adventure.id, memory.id, data) - setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m))) - } - - const remove = async (memory) => { - await api.deleteMemory(adventure.id, memory.id) - setMemories((prev) => prev.filter((m) => m.id !== memory.id)) - } - - const add = async () => { - const text = newText.trim() - if (!text) return - setNewText('') - const memory = await api.createMemory(adventure.id, text) - setMemories((prev) => [...prev, memory]) - } - - const active = memories?.filter((m) => !m.forgotten) ?? [] - const forgotten = memories?.filter((m) => m.forgotten) ?? [] - - return ( -
- - - -
-

Memories {memories && `(${active.length})`}

- -
- {!memories &&
Loading…
} - {memories && active.length === 0 && ( -
- No memories yet. They are generated automatically as you play (from action 12 - onward), or add one below. -
- )} - {active.map((m) => ( - change(m, data)} onDelete={() => remove(m)} /> - ))} - -
- setNewText(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') add() }} /> - -
- - {forgotten.length > 0 && ( - <> -
-

Forgotten ({forgotten.length})

-
- {forgotten.map((m) => ( - change(m, data)} onDelete={() => remove(m)} /> - ))} - - )} -
- ) -} - -const SCRIPT_HOOKS = [ - ['Shared library', 'library_js'], - ['onInput', 'input_js'], - ['onModelContext', 'context_js'], - ['onOutput', 'output_js'], -] - -function ScriptsPanel({ advId }) { - const [scripts, setScripts] = useState(null) - const [openId, setOpenId] = useState(null) - - useEffect(() => { - api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([])) - }, [advId]) - - const [syncingId, setSyncingId] = useState(null) - - const toggle = async (script) => { - const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled }) - setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) - } - - // Pull the latest code from the library script this copy was made from. - const sync = async (script) => { - setSyncingId(script.id) - try { - const updated = await api.syncAdventureScript(advId, script.id) - setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) - } finally { - setSyncingId(null) - } - } - - // Download as an import-compatible bundle (matches /scripts export), so demo - // scripts can be forked into your own library via the Scripts page's Import. - const download = (s) => { - downloadJSON( - { - name: s.name, - description: s.description, - library: s.library_js, - input: s.input_js, - context: s.context_js, - output: s.output_js, - }, - `${(s.name || 'script').replace(/\W+/g, '-')}.json`, - ) - } - - if (!scripts) return
Loading…
- if (scripts.length === 0) { - return ( -
- No scripts on this adventure. Attach scripts to a scenario before starting an - adventure from it. -
- ) - } - return ( -
- {scripts.map((s) => { - const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim()) - const open = openId === s.id - return ( -
-
- -
- {s.out_of_date && ( - - )} - - -
-
- {s.description &&
{s.description}
} - {open && ( -
- {hooks.length === 0 ? ( -
This script has no code.
- ) : ( - hooks.map(([label, f]) => ( -
-
{label}
-
{s[f]}
-
- )) - )} -
- )} -
- ) - })} -
- ) -} - -// Renders one script-state value: primitives inline (typed/coloured), and -// objects/arrays as a collapsible, indented tree — recursing into nesting so -// deep state shows structure instead of a flat JSON blob. -function StateValue({ value, depth = 0 }) { - if (value === null || value === undefined) return null - if (typeof value === 'boolean') return {String(value)} - if (typeof value === 'number') return {value} - if (typeof value === 'string') return {value} - if (Array.isArray(value)) return [i, v])} empty="[ ]" depth={depth} /> - if (typeof value === 'object') return - return {String(value)} -} - -// Only the first level is expanded by default (depth 0); nested trees start -// collapsed and can be opened on demand. -function StateTree({ entries, empty, depth = 0 }) { - const [open, setOpen] = useState(depth < 1) - if (entries.length === 0) return {empty} - return ( -
- - {open && ( -
    - {entries.map(([k, v]) => ( -
  • - {k} - -
  • - ))} -
- )} -
- ) -} - -// Collapsible left rail showing the scripting `state` object — every variable -// scripts read/write via state.x, refreshed after each turn. -function StatusDrawer({ advId, refreshKey }) { - const [open, setOpen] = useState(false) - const [state, setState] = useState(null) - const [failed, setFailed] = useState(false) - - const load = useCallback(() => { - api.getScriptState(advId) - .then((r) => { setState(r.state || {}); setFailed(false) }) - .catch(() => setFailed(true)) - }, [advId]) - - // Only fetch while open; re-fetch after each turn so values stay live. - useEffect(() => { if (open) load() }, [open, refreshKey, load]) - - const entries = state ? Object.entries(state) : [] - - return ( -
- - {open && ( -
-
-

Script State

- -
- {failed ? ( -
Couldn’t load state.
- ) : entries.length === 0 ? ( -
- No variables yet. Scripts that use state will appear here after a turn. -
- ) : ( -
    - {entries.map(([k, v]) => ( -
  • - {k} -
    -
  • - ))} -
- )} -
- )} -
- ) -} - -// Word label for a value from a stat def's bands (mirrors worldstate.band_label). -function bandLabel(def, value) { - const bands = def?.bands - if (!Array.isArray(bands) || typeof value !== 'number') return null - for (const b of bands) { - if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2] - } - const last = bands[bands.length - 1] - if (last && value === last[1]) return last[2] - return null -} - -function StatRow({ name, def, value, editing, onChange }) { - const isText = def?.type === 'text' - if (editing) { - return ( -
-
- {name} -
- {isText ? ( - onChange(e.target.value)} /> - ) : ( - onChange(e.target.value === '' ? 0 : Number(e.target.value))} /> - )} -
- ) - } - if (isText) { - const text = typeof value === 'string' ? value : (def?.initial ?? '') - return ( -
-
- {name} - {text || '(unset)'} -
-
- ) - } - const val = typeof value === 'number' ? value : (def?.initial ?? 0) - const { min, max } = def || {} - const hasRange = typeof min === 'number' && typeof max === 'number' && max > min - const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null - const label = bandLabel(def, val) - return ( -
-
- {name} - - {val}{typeof max === 'number' ? `/${max}` : ''} - {label ? · {label} : null} - -
- {pct != null && ( -
- )} -
- ) -} - -// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode) -// is a slice of the drawer's flat path->value map for this group's prefix. -// `nested` = the caller already drew a heading (an NPC card), so this drops the -// group's own spacing and says "nothing here" rather than vanishing and leaving -// that heading dangling over empty space. -function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) { - const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object') - if (entries.length === 0) { - return nested ?
No tracked stats.
: null - } - return ( -
- {title &&

{title}

} - {entries.map(([name, def]) => ( - onEdit(name, v) : undefined} /> - ))} -
- ) -} - -// Flatten a schema+state into path->value pairs ("player.hp", "npc.gwen.trust", -// "flags.has_key", "milestones.escaped") to seed an edit-mode draft. -function buildWorldStateDraft(schema, state) { - const draft = {} - const addStats = (prefix, defs, values) => { - for (const [name, def] of Object.entries(defs || {})) { - if (!def || typeof def !== 'object') continue - const fallback = def.type === 'text' ? '' : 0 - draft[`${prefix}.${name}`] = values?.[name] ?? def.initial ?? fallback - } - } - addStats('world', schema.world, state.world) - addStats('player', schema.player, state.player) - const npcState = state.npc || {} - for (const [id, ndef] of Object.entries(schema.npcs || {})) { - addStats(`npc.${id}`, ndef?.stats, npcState[id]) - } - const flagState = state.flags || {} - for (const [name, def] of Object.entries(schema.flags || {})) { - draft[`flags.${name}`] = flagState[name] ?? !!def?.initial - } - const reached = state.milestones || {} - for (const mid of Object.keys(schema.milestones || {})) { - draft[`milestones.${mid}`] = !!reached[mid]?.reached - } - return draft -} - -// Slice a flat path->value draft down to one group's {statName: value} map. -function sliceDraft(draft, prefix) { - const out = {} - const p = `${prefix}.` - for (const [k, v] of Object.entries(draft || {})) { - if (k.startsWith(p)) out[k.slice(p.length)] = v - } - return out -} - -// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC -// stats with bands + bars, and a milestones checklist. Renders nothing unless -// the adventure's scenario defines a stat_schema. An edit mode lets the -// player/author directly override the live values (a manual correction, not -// a turn) — it never adds new stats, only edits ones the schema already defines. -function WorldStateDrawer({ advId, refreshKey }) { - const [open, setOpen] = useState(false) - const [data, setData] = useState(null) // { state, schema } - const [failed, setFailed] = useState(false) - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(null) // flat path->value, only while editing - const [saving, setSaving] = useState(false) - - const load = useCallback(() => { - api.getWorldState(advId) - .then((r) => { setData(r); setFailed(false) }) - .catch(() => setFailed(true)) - }, [advId]) - - // Load once to learn whether there's an RPG layer, then refresh after turns. - useEffect(() => { load() }, [load, refreshKey]) - - const schema = data?.schema - if (!schema) return null // no RPG layer for this adventure - - const state = data?.state || {} - const npcState = state.npc || {} - const npcs = Object.entries(schema.npcs || {}) // [id, def] — each with its own stats - const flags = Object.entries(schema.flags || {}) - const flagState = state.flags || {} - const milestones = Object.entries(schema.milestones || {}) - const reached = state.milestones || {} - - const startEdit = () => { setDraft(buildWorldStateDraft(schema, state)); setEditing(true) } - const cancelEdit = () => { setEditing(false); setDraft(null) } - const setPath = (path, value) => setDraft((d) => ({ ...d, [path]: value })) - const saveEdit = () => { - setSaving(true) - api.overrideWorldState(advId, draft) - .then(() => { setEditing(false); setDraft(null); load() }) - .catch(() => setFailed(true)) - .finally(() => setSaving(false)) - } - - return ( -
- - {open && ( -
-
-

World State

- {editing ? ( -
- - -
- ) : ( -
- - -
- )} -
- {failed ? ( -
Couldn’t load world state.
- ) : ( - <> - setPath(`world.${name}`, v)} /> - setPath(`player.${name}`, v)} /> - {npcs.length > 0 && ( -
-

Cast

- {npcs.map(([id, def]) => ( -
-
- - {def.name || id} -
- setPath(`npc.${id}.${name}`, v)} - nested /> -
- ))} -
- )} - {flags.length > 0 && ( -
-

Flags

-
    - {flags.map(([fid, def]) => { - const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial) - return ( -
  • - {editing ? ( - - ) : ( - <> - - {fid} - {on ? 'yes' : 'no'} - - )} -
  • - ) - })} -
-
- )} - {milestones.length > 0 && ( -
-

Milestones

-
    - {milestones.map(([mid, def]) => { - const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached - return ( -
  • - {editing ? ( - - ) : ( - <> - {done ? '☑' : '☐'} - {def.desc || mid} - - )} -
  • - ) - })} -
-
- )} - - )} -
- )} -
- ) -} - -// The takes of one turn: ‹ 2/4 ›, and nothing else. -// -// SP7 shipped chips instead, on the grounds that a pager can only step between -// takes while a chip could also offer "take this path". Driving it by hand said -// otherwise. The chip meant two different things depending on where the reader -// was standing — a real switch at the tip, a preview needing a second button -// above it — and two meanings in one control is what made the tree unusable. -// -// So the pager comes back, and stepping is all it does. Stepping is free: it -// tells the server nothing, because reading a take is not a decision. The -// decision is made by *writing* below one, and that is where the branch is -// created (SP9, `after_id`). -// -// One step still reaches the server, and it is not a fork either. A take that -// has a story of its own lives on its own branch, so going there is a branch -// switch — the story below has to change, and only the server can say to what. -// A take on this branch is a leaf by construction: whatever was played after -// this turn was played after the take that is live, so a take that is not live -// has nothing under it and the transcript simply ends there. -function TakePager({ - advId, action, busy, preview, takesKey, onPreview, onSwitchedBranch, onError, -}) { - const [takes, setTakes] = useState(null) - const [loading, setLoading] = useState(false) - // The cached list is only as good as the text in it. Editing a take - // rewrites one of those rows, so the page says so and the list is fetched - // again on the next step. - useEffect(() => { setTakes(null) }, [takesKey]) - const count = action.take_count - const live = action.take_index - const current = preview ? preview.index : live - - async function step(delta) { - const next = current + delta - if (next < 0 || next >= count || loading || busy) return - setLoading(true) - try { - // Fetched once per message, then cached — walking back and forth through - // the takes should not re-hit the server for a list that has not changed. - const list = takes || await api.listTakes(advId, action.id) - if (!takes) setTakes(list) - const target = list[next] - if (target.branch_id !== action.branch_id) { - // It has a story of its own. Only the server knows what is under it. - onPreview(null) - onSwitchedBranch(await api.switchBranch(advId, target.branch_id)) - } else if (next === live) { - onPreview(null) - } else { - onPreview({ - actionId: action.id, - index: next, - // The take's own node id, never its ordinal: the group renumbers - // whenever a take is added, and an ordinal held across that points - // at a different one. This is what `after_id` is given if the reader - // writes from here. - takeId: target.id, - text: target.text, - reasoning: target.reasoning, - }) - } - } catch (err) { - onError(err.message) - } finally { - setLoading(false) - } - } - - if (count < 2) return null - return ( -
- - {current + 1}/{count} - - {preview && !preview.written && ( - write below to keep this one - )} -
- ) -} - -// Every line the story has taken, and the three things you can do to one. -// -// Drawn from a single request: `fork_depth` says where a branch leaves its -// parent and `depth` where it currently ends, so the whole shape is two -// numbers a row rather than a walk. -// -// Delete is here rather than in some later subphase because nothing prunes a -// tree on its own — this panel is the first place a fork can be made, so it -// has to be the first place one can be unmade. -function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) { - const [branches, setBranches] = useState(null) - const [failed, setFailed] = useState(null) - const [busyId, setBusyId] = useState(null) - const [renaming, setRenaming] = useState(null) // { id, text } - const [confirming, setConfirming] = useState(null) - const [mapOpen, setMapOpen] = useState(false) - const [tick, setTick] = useState(0) - - useEffect(() => { - let cancelled = false - setFailed(null) - api.listBranches(advId) - .then((list) => { if (!cancelled) setBranches(list) }) - .catch((err) => { if (!cancelled) setFailed(err.message) }) - return () => { cancelled = true } - }, [advId, refreshKey, tick]) - - // Answers whether it worked. Both callers keep an editor open on a refusal — - // a rename the server turned down must not take the typed name with it. - async function run(branchId, work) { - setBusyId(branchId) - try { - await work() - setTick((t) => t + 1) - // Deleting a branch takes its memories with it, and nothing else on the - // screen would hear about that — no turn is played, and the story on the - // current path does not change by a single action. - onTreeChanged() - return true - } catch (err) { - onError(err.message) - return false - } finally { - setBusyId(null) - } - } - - // One copy of each operation. The list below and the map overlay both go - // through these, so a rule cannot hold in one view and not the other, and a - // failure is reported one way wherever it was asked for. - const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id))) - const renameTo = (b, name) => run(b.id, () => api.renameBranch(advId, b.id, name)) - const removeBranch = (b) => run(b.id, () => api.deleteBranch(advId, b.id)) - - const saveName = async (b) => { if (await renameTo(b, renaming.text)) setRenaming(null) } - const remove = async (b) => { if (await removeBranch(b)) setConfirming(null) } - - if (failed) return
Couldn’t read the branches — {failed}
- if (!branches) return
Reading the tree…
- - const lineage = headLineage(branches) - - return ( -
- {/* The list says which lines exist; the map says where they parted and - how much story each one is, which is the part a list cannot draw. */} - - {branches.length === 1 && ( -

- One thread so far. Retry a turn, then take an attempt the story moved - past — that is what makes a second one. -

- )} -
- {orderBranches(branches).map(({ branch, indent }) => { - const isRenaming = renaming?.id === branch.id - const isConfirming = confirming === branch.id - const busy = busyId === branch.id - // The server refuses to delete the line being read or any line it - // was forked from. The button said nothing about that and answered - // with a toast; it now says so before it is pressed. - const loadBearing = lineage.has(branch.id) - return ( -
-
- - {isRenaming ? ( - setRenaming({ ...renaming, text: e.target.value })} - onKeyDown={(e) => { - if (e.key === 'Enter') saveName(branch) - if (e.key === 'Escape') setRenaming(null) - }} - /> - ) : ( - {branchLabel(branch)} - )} - {branch.is_head && reading} -
- -
- {branch.own_actions} of its own - {branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`} - {` · ends at ${branch.depth + 1}`} -
- - {isConfirming ? ( -
- Delete this branch and everything forked from it? - - -
- ) : ( -
- {!branch.is_head && ( - - )} - {isRenaming ? ( - <> - - - - ) : ( - - )} - {/* The root holds the turns every other branch borrows, and - the server refuses it — so it is not offered. */} - {branch.parent_branch_id !== null && ( - - )} -
- )} -
- ) - })} -
- - {mapOpen && ( - setMapOpen(false)} - /> - )} -
- ) -} - -// Reasons the engine gives for refusing a change, in the player's words. -const REJECT_REASONS = { - 'not a number': 'expected a number', - 'not a string': 'expected text', - 'not a boolean': 'expected on or off', - 'not true': 'a milestone can only be set', - "counter can't decrease": 'this only counts up', - cooldown: 'changed too recently', - 'unknown stat': 'no such stat', - 'unknown npc': 'no such character', - 'unknown npc stat': 'no such stat', - 'unknown flag': 'no such flag', - 'unknown milestone': 'no such milestone', - 'unknown path': 'unrecognized name', -} - -// Compact chips shown under an AI message summarizing what state changed. -// -// Refused changes appear here too. A clamped stat is marked, and a stat whose -// clamp left it exactly where it started reads "no change" rather than "+0", -// which looked like an ordinary update. -function StateChangeChips({ changes }) { - if (!changes?.length) return null - const nice = (s) => String(s).replace(/_/g, ' ') - return ( -
- {changes.map((c, i) => { - if (c.kind === 'flag') { - return {nice(c.label)}: {c.on ? 'on' : 'off'} - } - if (c.kind === 'milestone') { - return ✓ {nice(c.label)} - } - if (c.kind === 'rejected') { - const why = REJECT_REASONS[c.reason] || c.reason - // The engine's own wording quotes the real limits, so prefer it. - return ( - - {nice(c.label)} refused — {why} - - ) - } - const d = c.delta - // A clamp that cancels the change entirely is its own outcome. It is - // neither an update nor a refusal, and "+0" read as the former. - const blocked = c.clamped && d === 0 - const dir = blocked ? 'refused' : typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat' - const txt = blocked - ? 'no change — at its limit' - : typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}` - const title = blocked - ? c.fix || 'The story asked to change this and it is already at the limit the scenario allows.' - : c.clamped - ? 'The scenario limits how far this can move in one turn, so the change was reduced.' - : undefined - return ( - - {nice(c.label)} {txt} - {c.clamped && !blocked ? (limited) : null} - - ) - })} -
- ) -} - -// Per-turn RPG state change, shown when inspecting a past turn's snapshot. -function WorldStateReport({ worldState }) { - if (!worldState) return null - const delta = worldState.delta || {} - const report = worldState.report || {} - const paths = Object.keys(delta) - const rejected = report.rejected || [] - const clamped = new Set((report.clamped || []).map((c) => c.path)) - if (paths.length === 0 && rejected.length === 0) { - return ( -
-
World State
-
No changes this turn.
-
- ) - } - return ( -
-
World State changes
-
    - {paths.map((p) => ( -
  • - {p} {String(delta[p])} - {clamped.has(p) && clamped} -
  • - ))} - {rejected.map((r, i) => ( -
  • - {r.path} rejected — {r.reason} -
  • - ))} -
-
- ) -} - -function ScriptReport({ script }) { - if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) { - return null - } - return ( -
-
Scripts
- {script.errors?.map((e, i) =>
⚠ {e}
)} - {script.logs?.length > 0 && ( -
{script.logs.join('\n')}
- )} - {script.context_changed && ( - <> -
-
- Context before script -
-
{script.context_before}
-
-
-
- Context after script (sent to AI) -
-
{script.context_after}
-
- - )} -
- ) -} - -// What the endpoint charged for the turn, and how much of the prompt it read -// back out of its cache instead of billing in full. Only shown on a past turn: -// the "next turn" view has not been sent anywhere yet, so it has no usage. A -// cached read costs a tenth of a fresh one, which is the whole reason the -// prompt is laid out static-first — so this is the number that says whether -// that layout is working. -function CacheReport({ usage }) { - if (!usage) return null - const prompt = usage.prompt_tokens || 0 - const cached = usage.prompt_tokens_details?.cached_tokens || 0 - if (!prompt) return null - const pct = Math.round((cached / prompt) * 100) - return ( -
- Prompt cache: {cached} of {prompt} prompt tokens read from cache ({pct}%) - {usage.cost != null && ` · cost $${Number(usage.cost).toFixed(5)}`} -
- ) -} - -const LEGEND_VISIBLE = 6 // enough to cover what actually moves the budget - -// Where the prompt's tokens actually went: one stacked bar scaled to the -// budget (so leftover width IS the remaining headroom) plus a matching legend. -// Bar and legend both run biggest-share-first — the order that answers "what is -// eating my context?" — rather than the prompt order the sections below use. -function TokenBreakdown({ sections, tokens, used, onJump }) { - const [hovered, setHovered] = useState(null) - const [expanded, setExpanded] = useState(false) - if (used <= 0) return null - const budget = tokens.budget || 0 - // Over budget there is no headroom to draw, so the bar scales to what the - // prompt actually costs and the total line above it carries the warning. - const scale = Math.max(budget, used) - const free = Math.max(0, budget - used) - const ranked = sections - .map((s, i) => ({ ...s, i, pct: (s.tokens / used) * 100 })) - .filter((s) => s.tokens > 0) - .sort((a, b) => b.tokens - a.tokens) - // The panel is narrow, so the legend is one column: list the sections that - // actually move the budget and fold the long tail behind a count. - const shown = expanded ? ranked : ranked.slice(0, LEGEND_VISIBLE) - const rest = ranked.slice(shown.length) - const restPct = rest.reduce((n, s) => n + s.pct, 0) - const describe = (s) => - `${SECTION_LABELS[s.label] || s.label} — ${s.tokens} tok · ${pctLabel(s.pct)}` - - return ( - <> -
budget ? 'over' : ''}`} - role="img" - aria-label={`Context breakdown: ${ranked.map(describe).join(', ')}`} - > - {ranked.map((s) => ( -
setHovered(s.i)} - onMouseLeave={() => setHovered(null)} - /> - ))} - {free > 0 && ( -
- )} -
-
- {shown.map((s) => ( - - ))} - {rest.length > 0 && ( - - )} - {expanded && ranked.length > LEGEND_VISIBLE && ( - - )} -
- - ) -} - -function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) { - const [report, setReport] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let stale = false // a slow earlier request must not clobber a newer one - setError(null) - const load = inspectActionId - ? api.getActionContext(advId, inspectActionId) - : api.getAdventureContext(advId) - load - .then((r) => { if (!stale) setReport(r) }) - .catch((err) => { if (!stale) { setReport(null); setError(err.message) } }) - return () => { stale = true } - }, [advId, inspectActionId, refreshKey]) - - if (error) return
{error}
- if (!report) return
Loading…
- - const { tokens, cards, history, sections } = report - const overBudget = tokens.total > tokens.budget - // The per-section sum, not tokens.total: the total also counts the separators - // between sections, and percentages have to add up to 100 for the reader. - const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0) - const jumpToSection = (i) => { - document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - - return ( -
-
- {inspectActionId ? ( -
- Snapshot of a past turn - -
- ) : ( -
What will be sent on the next turn
- )} -
- {tokens.total} / {tokens.budget} tokens -
- -
- History: {history.included} of {history.total} actions in context - {history.total > history.included && ' (older history trimmed)'} - {history.oldest_truncated && ' — oldest entry cut mid-text'} -
- - {cards.length > 0 && ( -
- {cards.map((c, i) => ( -
- ▸ {c.name || '(unnamed card)'} triggered on “{c.keyword}” - {!c.included && ' — dropped (over card budget)'} -
- ))} -
- )} - {report.memories && ( -
- {report.memories.error && ( -
⚠ Memory bank: {report.memories.error}
- )} - {report.memories.used?.map((m, i) => ( -
- ▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}): - {' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text} -
- ))} - {!report.memories.error && report.memories.used?.length === 0 && ( -
Memory bank on — no memories retrieved.
- )} -
- )} -
- - {sections.map((s, i) => ( -
-
- {SECTION_LABELS[s.label] || s.label} - - {s.tokens} tok - {sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`} - -
-
{s.text}
-
- ))} - - - {report.raw_output && ( -
-
- Raw AI output (before state block was stripped) -
-
{report.raw_output}
-
- )} -
- ) -} - -export default function Play() { - const { id } = useParams() - const navigate = useNavigate() - const [adventure, setAdventure] = useState(null) - const [actions, setActions] = useState([]) - const [mode, setMode] = useState('do') - const [input, setInput] = useState('') - const [streaming, setStreaming] = useState(null) - const [reasoningStream, setReasoningStream] = useState(null) - const [busy, setBusy] = useState(false) - const [toast, setToast] = useState(null) - const [editing, setEditing] = useState(null) - const [panel, setPanel] = useState(null) // null | 'plot' | 'insights' - // Bumped when something outside the turn loop changes the drawers' state - // (currently "Update from scenario"), which no action count would reflect. - const [stateKey, setStateKey] = useState(0) - const [inspectActionId, setInspectActionId] = useState(null) - // Which take is being read, when it is not the live one (see TakePager). - // One at a time; null when every message is showing the take the story tells. - // - // Purely local: the server is not told, because reading a take is not a - // decision. It becomes one when something is written below it, and that is - // what `after_id` carries. - const [preview, setPreview] = useState(null) - // The take a turn was just written below, held from the moment Send is - // pressed until the re-read lands. - // - // Writing below a take is the one moment the server IS told (`after_id`), and - // it obeys immediately — the take is made live before a single token is - // generated. The transcript only learns that from the resync afterwards, so - // dropping the preview at Send time put the *replaced* take back on screen - // for the whole length of the turn, and left it there for good if the resync - // never landed (a failed turn, a lost connection, a closed tab) — the story - // read one way and reloading the page read another. - // - // So the text stays pinned to what was chosen. Unlike `preview` it does not - // truncate the transcript below it: the turn being played goes there. - const [pinned, setPinned] = useState(null) - // Bumped when a take's stored text changes under the pagers, which cache the - // list they fetched. Nothing else invalidates it: a take is added by playing - // a turn, and that re-reads the whole window anyway. - const [takesKey, setTakesKey] = useState(0) - // The transcript is a window on the story, not the whole of it: the page - // load brings the newest page and older ones arrive as the reader scrolls - // up. `total` is the story's real length, for the "N earlier" line. - const [total, setTotal] = useState(0) - const [hasMore, setHasMore] = useState(false) - const [loadingOlder, setLoadingOlder] = useState(false) - const storyEndRef = useRef(null) - const abortRef = useRef(null) - const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom - const inputRef = useRef(null) - // Set just before older actions are prepended, read once afterwards to put - // the reader back where they were. See the layout effect below. - const restoreScrollRef = useRef(null) - const loadingOlderRef = useRef(false) - // Earliest time another attempt is allowed after a failure. See the catch - // in loadOlder. - const retryAfterRef = useRef(0) - - // The drop cap belongs to the story's first narrated beat. `start` is the - // scenario's opening prompt, so it's usually that; an adventure begun blank - // has no `start` action and the first AI reply takes it instead. Tracked by - // id rather than position so deleting earlier turns moves the cap correctly - // instead of stranding it on a removed row. - const firstNarrationId = useMemo( - () => actions.find((a) => a.type === 'start' || a.type === 'ai')?.id ?? null, - [actions], - ) - // Where the transcript stops while a take that is not the live one is being - // read. Such a take is a leaf by construction — whatever was played after - // this turn was played after the take that *is* live — so there is nothing - // under it, and showing the rest would attach one line's story to another's - // text. -1 while nothing is being previewed, which is the ordinary case. - const previewCutoff = useMemo( - () => (preview ? actions.findIndex((a) => a.id === preview.actionId) : -1), - [preview, actions], - ) - // send() sets streaming to '' before the request goes out; reasoningStream - // stays null until reasoning tokens (if any) arrive. Both still at those - // values means the request is in flight with nothing to show yet. - const waitingForFirstToken = streaming === '' && reasoningStream === null - - // Grow the action box with its content (CSS caps it at ~4 lines, then - // scrolls); shrinks back after send() clears the text. - useEffect(() => { - const el = inputRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${el.scrollHeight}px` - }, [input]) - - useEffect(() => { - api.getAdventure(id) - .then((adv) => { - setAdventure(adv) - setActions(adv.actions) - setTotal(adv.action_count ?? adv.actions.length) - setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) - }) - .catch(() => navigate('/')) - }, [id, navigate]) - - // Take the story the server just handed back, whole. - // - // Switching a branch and forking one both answer with the newest window of - // the story as it now stands, so there is nothing to merge — the window on - // screen belonged to a path that is no longer the one being read. Pinning - // back to the bottom is deliberate: a switch lands the reader at the tip of - // the line they moved to, which is where the next turn will appear. - const adoptWindow = useCallback((page) => { - pinnedRef.current = true - setPreview(null) - setPinned(null) - setActions(page.actions) - setTotal(page.total) - setHasMore(page.has_more) - // The script and world state come back to what that branch's tip left - // behind, so anything drawn from them is now showing another line's - // numbers until it re-reads. - setStateKey((k) => k + 1) - }, []) - - // Fetch the page above the one on screen and prepend it. - // - // Anchored on the oldest action we hold rather than on a count, so a turn - // landing while the reader scrolls cannot shift the page. Guarded by a ref - // as well as state because scroll fires far faster than React re-renders, - // and two in-flight requests would fetch the same page twice. - const loadOlder = useCallback(async () => { - if (loadingOlderRef.current || !hasMore) return - if (Date.now() < retryAfterRef.current) return - const oldest = actions[0] - if (!oldest) return - loadingOlderRef.current = true - setLoadingOlder(true) - try { - const page = await api.getActions(id, { beforeId: oldest.id }) - if (page.actions.length) { - // Reading backwards is the opposite of following along, so stop - // autoscrolling. Without this the bottom-pinning effect below fires on - // the same `actions` change and throws the reader to the end of the - // story — worst exactly where the button matters, on a window short - // enough that it never scrolled and so never un-pinned itself. - pinnedRef.current = false - // Record the height before the prepend; the layout effect below uses - // it to keep the reader looking at the same paragraph. - restoreScrollRef.current = { - height: document.documentElement.scrollHeight, - top: window.scrollY, - } - setActions((prev) => { - // Defensive: never let a page the reader already holds duplicate a - // message. Cheap, and the alternative is a visibly doubled turn. - const known = new Set(prev.map((a) => a.id)) - return [...page.actions.filter((a) => !known.has(a.id)), ...prev] - }) - } - setTotal(page.total) - setHasMore(page.has_more) - } catch { - // Leave hasMore alone: a failed fetch should let the reader try again - // by scrolling, not permanently hide the rest of their story. But hold - // off briefly first — parked near the top, momentum scrolling fires this - // dozens of times a second, and against an endpoint that is failing that - // is a retry storm rather than a retry. - retryAfterRef.current = Date.now() + 3000 - } finally { - loadingOlderRef.current = false - setLoadingOlder(false) - } - }, [actions, hasMore, id]) - - // Put the viewport back after a prepend. useLayoutEffect, not useEffect: - // this has to run before the browser paints, or the reader sees the story - // jump and then snap back. - useLayoutEffect(() => { - const mark = restoreScrollRef.current - if (!mark) return - restoreScrollRef.current = null - const grown = document.documentElement.scrollHeight - mark.height - if (grown > 0) window.scrollTo({ top: mark.top + grown }) - }, [actions]) - - useEffect(() => { - const onScroll = () => { - pinnedRef.current = - window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120 - // Start the next page before the reader reaches the top, so the story - // is usually already there by the time they would have noticed its end. - if (window.scrollY < 400) loadOlder() - } - window.addEventListener('scroll', onScroll, { passive: true }) - return () => window.removeEventListener('scroll', onScroll) - }, [loadOlder]) - - useEffect(() => { - // Snap to the real document bottom (below the sticky composer), not to - // storyEndRef — that ref sits above the composer, so block:'end' would - // stop short and fight a reader scrolling down. Instant, not smooth: at - // streaming speed a queued smooth animation never settles. - if (pinnedRef.current) { - window.scrollTo({ top: document.documentElement.scrollHeight }) - } - }, [actions, streaming, reasoningStream]) - - const handleScriptReport = useCallback((script) => { - if (!script) return - if (script.errors?.length) { - setToast({ text: `Script error: ${script.errors[0]}`, isError: true }) - } else if (script.message) { - setToast({ text: script.message, isError: false }) - } - }, []) - - const handleEvent = useCallback((event) => { - if (event.type === 'player') { - setActions((prev) => [...prev, event.action]) - // The window grew at the bottom, so the story did too. Kept in step by - // hand because nothing re-reads the count between turns. - setTotal((n) => n + 1) - } else if (event.type === 'chunk') { - setStreaming((prev) => (prev ?? '') + event.text) - } else if (event.type === 'reasoning') { - setReasoningStream((prev) => (prev ?? '') + event.text) - } else if (event.type === 'done') { - setStreaming(null) - setReasoningStream(null) - setActions((prev) => [...prev, event.action]) - setTotal((n) => n + 1) - handleScriptReport(event.script) - } else if (event.type === 'stopped') { - setStreaming(null) - setReasoningStream(null) - handleScriptReport(event.script) - } else if (event.type === 'error') { - setStreaming(null) - setReasoningStream(null) - setToast({ text: event.detail, isError: true }) - } - }, [handleScriptReport]) - - async function runTurn(run) { - const controller = new AbortController() - abortRef.current = controller - setBusy(true) - setToast(null) - setStreaming('') - pinnedRef.current = true - try { - await run(controller.signal) - } catch (err) { - if (err.name === 'AbortError') { - setToast({ text: 'Generation stopped.', isError: false }) - } else { - setToast({ text: err.message, isError: true }) - } - } finally { - abortRef.current = null - setStreaming(null) - setReasoningStream(null) - setBusy(false) - } - } - - function stopGeneration() { - abortRef.current?.abort() - } - - function send(type = mode) { - const text = input.trim() - // Where the reader is standing. Stepping to a take the story moved past - // told the server nothing; this is the moment it has to be told, and it is - // the moment the branch is made (SP9). - const after_id = preview?.takeId - // The window below belongs to the line being left, so it is re-read rather - // than appended to — same reasoning as `addTake`. - const run = (payload) => runTurn(async (signal) => { - try { - await api.sendAction(id, payload, handleEvent, signal) - } finally { - if (after_id) await resync() - } - }) - // Keep the chosen take on screen while the turn runs. The server has - // already been told to stand on it, so this is not optimism — it is the - // transcript catching up with a decision that is already made. - if (preview) setPinned({ ...preview, written: true }) - setPreview(null) - if (type === 'continue') { - // Continue never consumes typed text — leave it in the box. - run({ type: 'continue', text: '', after_id }) - return - } - const payload = { type: text ? type : 'continue', text, after_id } - setInput('') - run(payload) - } - - function retry() { - setPreview(null) - setActions((prev) => - prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev) - runTurn(async (signal) => { - try { - await api.retry(id, handleEvent, signal) - } catch (err) { - // Failed retry (409, network): the optimistically removed action may - // still exist server-side — resync instead of guessing. Resyncing - // collapses the transcript back to the newest window, which is the - // right call: the reader's place is already lost by the failure. - api.getAdventure(id).then((adv) => { - setActions(adv.actions) - setTotal(adv.action_count ?? adv.actions.length) - setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) - }).catch(() => {}) - throw err - } - }) - } - - async function undo() { - setToast(null) - setPreview(null) - setPinned(null) - try { - // A window, not the whole story — undo is the action most likely to be - // repeated several times running, so it must not re-fetch everything. - const page = await api.undo(id) - setActions(page.actions) - setTotal(page.total) - setHasMore(page.has_more) - } catch (err) { - setToast({ text: err.message, isError: true }) - } - } - - // Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field. - useEffect(() => { - const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' - const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start' - const onKey = (e) => { - if (!(e.ctrlKey || e.metaKey) || busy) return - if (e.target.closest?.('input, textarea, select, [contenteditable]')) return - if (e.key.toLowerCase() === 'z' && canUndo) { - e.preventDefault() - undo() - } else if (e.key.toLowerCase() === 'r' && lastIsAi) { - e.preventDefault() - retry() - } - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }) - - // A take-edit belongs to the preview that opened it. Anything that leaves - // that take — playing a turn, switching branch — takes the box with it, so - // the pending edit goes too rather than being saved onto a take nobody is - // looking at any more. - useEffect(() => { - if (editing?.take && preview?.takeId !== editing.id) setEditing(null) - }, [editing, preview]) - - async function saveEdit() { - const { id: actionId, text, fork, take } = editing - setEditing(null) - if (fork) { - // Not an edit at all: the turn is played again with this text, and what - // the story made of the old text is kept on the line it was written on. - addTake(actionId, text) - return - } - try { - const updated = await api.updateAction(id, actionId, text) - // A take that is only being read is not in `actions` — the row there is - // the live one — so the new text goes back into the preview, which is - // what that row is drawing. The pager holds the take list it fetched, so - // it is told to drop it: stepping away and back would otherwise show the - // words before the edit. - if (take) { - setPreview((p) => (p && p.takeId === actionId ? { ...p, text: updated.text } : p)) - setTakesKey((k) => k + 1) - } else { - setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a))) - } - } catch (err) { - setToast({ text: err.message, isError: true }) - } - } - - // Play a turn again, differently. Anywhere in the story, either kind of node. - // - // The transcript is re-read rather than appended to, which is the difference - // from an ordinary turn: a take above the tip leaves the line it was on and - // the whole window below it belongs to a story this branch no longer tells. - // `handleEvent` appends the new node as it streams; the resync afterwards is - // what drops everything that is no longer under it. - function addTake(actionId, text) { - setPreview(null) - runTurn(async (signal) => { - try { - await api.addTake(id, actionId, text, handleEvent, signal) - } finally { - await resync() - } - }) - } - - // Re-read the newest window from the server. - // - // For a turn that left the line it was on: `handleEvent` appends the new node - // as it streams, and everything already on screen below the take belongs to a - // story this branch no longer tells. Only the server can say what replaces - // it. A failed resync leaves the transcript stale rather than wrong, so it is - // swallowed — the next page load settles it. - async function resync() { - try { - const adv = await api.getAdventure(id) - // The window now says which take is live, so the pin has done its job. - // Left in place if this read fails, which is the whole point of it. - setPinned(null) - setActions(adv.actions) - setTotal(adv.action_count ?? adv.actions.length) - setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) - // The branch, the script state and the world state can all have moved. - setStateKey((k) => k + 1) - } catch { /* stale beats wrong */ } - } - - async function removeAction(actionId) { - try { - await api.deleteAction(id, actionId) - setActions((prev) => prev.filter((a) => a.id !== actionId)) - setTotal((n) => Math.max(0, n - 1)) - } catch (err) { - setToast({ text: err.message, isError: true }) - } - } - - function inspect(actionId) { - setInspectActionId(actionId) - setPanel('insights') - } - - if (!adventure) return null - - const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' - const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start' - - return ( -
- {/* Both drawers read per-adventure state that a branch switch puts back, - so neither can key on the story's length alone: switching between two - branches whose windows are both full changes every number in here - without changing `actions.length` by one. */} - - -
-
-

{adventure.title}

-
- - - - - -
-
- -
- {actions.length === 0 && streaming === null && ( -
A blank page. Type something below to begin your story.
- )} - {/* Scrolling up loads the rest. The button is not decoration: on a - short viewport the story may not be tall enough to scroll at all, - and a reader who cannot scroll must still be able to get back to - the beginning. */} - {hasMore && ( -
- {loadingOlder ? ( - Turning back the pages… - ) : ( - - )} -
- )} - {actions.map((action, i) => { - // Below the take being read there is nothing on this line yet. - if (previewCutoff !== -1 && i > previewCutoff) return null - const isPlayer = PLAYER_TYPES.includes(action.type) - // A player action opens a new turn, so that's where the ornamental - // break belongs — never above the very first line on the page. - const sceneBreak = isPlayer && i > 0 - // Drop cap goes on the first narrated beat only. `firstNarrationId` - // is derived once above rather than per row. - const opening = action.id === firstNarrationId - // Non-null while the reader is browsing an older attempt of this - // message without making it active (earlier turns only). - const previewing = preview?.actionId === action.id ? preview : null - // The take this row was written below, still on screen because the - // re-read has not landed yet. Same text override as a preview, but - // it never truncates the story under it — see `pinned`. - const shown = previewing - || (pinned?.actionId === action.id ? pinned : null) - - // The editor stands in for the row it was opened from. That row is - // keyed by the live node, so an edit on a take the pager is parked - // on carries the take's id instead and is matched through the - // preview. - const editingHere = editing - && (editing.take ? previewing?.takeId === editing.id : editing.id === action.id) - - return editingHere ? ( -
- setEditing({ ...editing, text: e.target.value })} - /> -
- - -
-
- ) : ( - - {sceneBreak && } -
- - {renderEmphasis(shown ? shown.text : action.text)} - {/* The chips describe the *active* attempt's state changes, - which the take on screen didn't make — so they're hidden - rather than shown against the wrong text. */} - {action.type === 'ai' && !shown && ( - - )} - {/* On every kind of node, not only the AI's: a player's own - turn can be played again too (SP9), so it can have takes - to step through. The pager draws nothing for a count of - one, which is most turns. */} - setToast({ text: message, isError: true })} - /> - {!busy && ( - - {action.type === 'ai' && ( - - )} - {/* Edits the take that is *on screen*, which is not the - live one while the pager is parked on another. The - row is keyed by the live node's id, so seeding from - `action` here opened the editor on take 4/4's text - while 2/4 was being read — and saved over it. A take - is an ordinary row to the edit endpoint, on the path - or not, so its own id is all this needs. */} - - {/* Play this turn again, differently. On the AI's turn - that is a regeneration; on your own it opens the text - so you can say something else. Either way the story - that followed the old take is kept, on the line it - was written on. - - The id stays the live node's even while another take - is being read: adding a take branches just above the - turn, and the server only accepts a turn that is on - the path. Only the seeded text follows the screen, so - varying the take you are reading starts from its - words. */} - {action.type !== 'start' && ( - - )} - - - )} -
-
- ) - })} - {waitingForFirstToken && ( -
- - Weaving -
- )} - {streaming !== null && !waitingForFirstToken && ( -
- - {renderEmphasis(streaming)} - ▋ -
- )} -
-
- -
-
- - - -
-
-
- {MODES.map((m) => ( - - ))} -
-