The pager could only step between attempts, and stepping has nothing to say about the thing the tree exists for: taking a path the story moved past and keeping both. Every attempt has been its own node since SP4, so a chip is a node now, and "take this path" forks — or simply switches, when the turn is still the tip and its attempts are leaves nobody has built on. Beside it, a Branches panel: every line the story has taken, with where each left its parent and where it ends, and switch, rename and delete-with-confirm. It sits with Plot/Memory/Scripts/Insights rather than inventing a new place to put a rail. An unnamed branch is drawn from its fork depth, never from its position in the list — a position shifts the moment a branch above it goes. A spatial per-node map was considered and deliberately not built. At the size this has to be verified against it is a second windowing problem, and it can be added later without a new endpoint, since the rail and a map read the same GET /branches. VariantOut grows an id because a fork is addressed by the node being taken, not by an ordinal in a group that renumbers. Driven by hand against the 602-action fixture, which found one bug that no test could: the panel refreshed on actions.length, and a fork swaps a 60-action window for another 60-action window, so it went on drawing a one-branch tree while the story was already on the second. It keys off the counter adoptWindow bumps now. The scroll path was driven at the same time — three prepends of ~16,200 px, the same node holding viewport top 792 to 787, never thrown to the end. That closes the standing gap in this project. Console clean. 396 tests, build clean, no new lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
195 lines
9.3 KiB
JavaScript
195 lines
9.3 KiB
JavaScript
// Multi-user mode: a 401 means our session cookie is missing/stale. Hitting
|
|
// /api/auth/me creates a fresh guest session, after which the original call
|
|
// is retried once.
|
|
async function ensureSession() {
|
|
await fetch('/api/auth/me')
|
|
}
|
|
|
|
async function request(path, options = {}, isRetry = false) {
|
|
const resp = await fetch(`/api${path}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options,
|
|
})
|
|
if (resp.status === 401 && !isRetry && path !== '/auth/me') {
|
|
await ensureSession()
|
|
return request(path, options, true)
|
|
}
|
|
if (!resp.ok) {
|
|
let detail = resp.statusText
|
|
try {
|
|
const body = await resp.json()
|
|
detail = body.detail || detail
|
|
} catch { /* non-JSON error body */ }
|
|
throw new Error(detail)
|
|
}
|
|
if (resp.status === 204) return null
|
|
return resp.json()
|
|
}
|
|
|
|
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
|
|
async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
|
|
const resp = await fetch(`/api${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
})
|
|
if (resp.status === 401 && !isRetry) {
|
|
await ensureSession()
|
|
return streamSSE(path, payload, onEvent, signal, true)
|
|
}
|
|
if (!resp.ok) {
|
|
let detail = resp.statusText
|
|
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
|
|
throw new Error(detail)
|
|
}
|
|
const reader = resp.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const events = buffer.split('\n\n')
|
|
buffer = events.pop() // keep incomplete tail
|
|
for (const block of events) {
|
|
for (const line of block.split('\n')) {
|
|
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
// Auth (Phase 8 — no-ops in local mode beyond getMe)
|
|
getMe: () => request('/auth/me'),
|
|
register: (email, password) =>
|
|
request('/auth/register', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
|
login: (email, password) =>
|
|
request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
|
logout: () => request('/auth/logout', { method: 'POST' }),
|
|
|
|
// Scenarios
|
|
listScenarios: () => request('/scenarios'),
|
|
getScenario: (id) => request(`/scenarios/${id}`),
|
|
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
|
|
|
|
// Adventures
|
|
listAdventures: () => request('/adventures'),
|
|
getAdventure: (id) => request(`/adventures/${id}`),
|
|
getScriptState: (id) => request(`/adventures/${id}/script-state`),
|
|
getWorldState: (id) => request(`/adventures/${id}/world-state`),
|
|
overrideWorldState: (id, overrides) =>
|
|
request(`/adventures/${id}/world-state`, { method: 'PUT', body: JSON.stringify(overrides) }),
|
|
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
|
// A page of the story, walking backwards. `beforeId` is the oldest action
|
|
// already on screen; omit it for the newest window. Anchored on an action
|
|
// rather than an offset so a turn landing mid-scroll cannot shift the page
|
|
// out from under the reader.
|
|
getActions: (advId, { beforeId, limit } = {}) => {
|
|
const params = new URLSearchParams()
|
|
if (beforeId != null) params.set('before_id', beforeId)
|
|
if (limit != null) params.set('limit', limit)
|
|
const query = params.toString()
|
|
return request(`/adventures/${advId}/actions${query ? `?${query}` : ''}`)
|
|
},
|
|
updateAction: (advId, actionId, text) =>
|
|
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
|
deleteAction: (advId, actionId) =>
|
|
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
|
|
// Retry history. The adventure payload carries only the counts, so the
|
|
// attempts themselves are fetched when the reader actually pages through.
|
|
listVariants: (advId, actionId) =>
|
|
request(`/adventures/${advId}/actions/${actionId}/variants`),
|
|
selectVariant: (advId, actionId, index) =>
|
|
request(`/adventures/${advId}/actions/${actionId}/variant`, {
|
|
method: 'POST', body: JSON.stringify({ index }),
|
|
}),
|
|
|
|
// The story tree (Phase 14). One request draws the whole shape however many
|
|
// forks there are. The three that change it answer with the story as it now
|
|
// stands, so the caller replaces its window instead of reloading everything.
|
|
listBranches: (advId) => request(`/adventures/${advId}/branches`),
|
|
switchBranch: (advId, branchId) =>
|
|
request(`/adventures/${advId}/branches/${branchId}/switch`, { method: 'POST' }),
|
|
renameBranch: (advId, branchId, name) =>
|
|
request(`/adventures/${advId}/branches/${branchId}`, {
|
|
method: 'PATCH', body: JSON.stringify({ name }),
|
|
}),
|
|
deleteBranch: (advId, branchId) =>
|
|
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
|
// Take the story down one attempt. A fork only when it has to be: while the
|
|
// attempts are still at the tip they are leaves, and the server switches.
|
|
forkFromAttempt: (advId, actionId) =>
|
|
request(`/adventures/${advId}/actions/${actionId}/fork`, { method: 'POST' }),
|
|
|
|
sendAction: (advId, payload, handlers, signal) =>
|
|
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
|
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
|
|
exportAdventure: (id) => request(`/adventures/${id}/export`),
|
|
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
|
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
|
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
|
|
// "Update from scenario": GET describes what would change, POST applies it.
|
|
previewRefresh: (advId) => request(`/adventures/${advId}/refresh`),
|
|
refreshFromScenario: (advId, placeholders = {}) =>
|
|
request(`/adventures/${advId}/refresh`, {
|
|
method: 'POST', body: JSON.stringify({ placeholders }),
|
|
}),
|
|
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
|
|
|
|
// Memory bank
|
|
listMemories: (advId) => request(`/adventures/${advId}/memories`),
|
|
createMemory: (advId, text) =>
|
|
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
|
|
updateMemory: (advId, memoryId, data) =>
|
|
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteMemory: (advId, memoryId) =>
|
|
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
|
|
|
|
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
|
|
updateAdventureScript: (advId, scriptId, data) =>
|
|
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
syncAdventureScript: (advId, scriptId) =>
|
|
request(`/adventures/${advId}/scripts/${scriptId}/sync`, { method: 'POST' }),
|
|
|
|
// Scripts
|
|
listScripts: () => request('/scripts'),
|
|
getScript: (id) => request(`/scripts/${id}`),
|
|
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
|
|
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
|
|
exportScript: (id) => request(`/scripts/${id}/export`),
|
|
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
|
|
|
// Scenario import/export
|
|
exportScenario: (id) => request(`/scenarios/${id}/export`),
|
|
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
|
|
|
// Story cards
|
|
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
|
|
// Bulk import/export in AI Dungeon world-info format. `owner` is
|
|
// { scenario_id } or { adventure_id }.
|
|
exportStoryCards: (owner) => request(`/story-cards/export?${new URLSearchParams(owner)}`),
|
|
importStoryCards: (payload) => request('/story-cards/import', { method: 'POST', body: JSON.stringify(payload) }),
|
|
|
|
// AI Chat (power users only — 404 for everyone else)
|
|
getChatConfig: () => request('/chat/config'),
|
|
chatStream: (payload, handlers, signal) => streamSSE('/chat/stream', payload, handlers, signal),
|
|
|
|
// Debug
|
|
getDebugRequests: () => request('/debug/requests'),
|
|
|
|
// Settings
|
|
getSettings: () => request('/settings'),
|
|
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
|
testConnection: () => request('/settings/test', { method: 'POST' }),
|
|
}
|