From 364a911794f63f621487cf060e415fe5e869f1eb Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 12:45:06 -0600 Subject: [PATCH] feat: story library, player and builder UI Co-Authored-By: Claude Sonnet 5.5 --- src/app.test.tsx | 46 ++++-- src/lib/play.ts | 35 +++++ src/lib/stories.test.ts | 87 +++++++++++ src/lib/stories.ts | 215 +++++++++++++++++++++++++++ src/main.tsx | 1 + src/routes.tsx | 10 +- src/routes/Build.tsx | 313 +++++++++++++++++++++++++++++++++++++++ src/routes/BuildList.tsx | 59 ++++++++ src/routes/Home.tsx | 24 --- src/routes/Layout.tsx | 5 +- src/routes/Library.tsx | 31 ++++ src/routes/Play.tsx | 79 ++++++++++ src/routes/ui.test.tsx | 119 +++++++++++++++ src/styles.css | 166 +++++++++++++++++++++ 14 files changed, 1149 insertions(+), 41 deletions(-) create mode 100644 src/lib/play.ts create mode 100644 src/lib/stories.test.ts create mode 100644 src/lib/stories.ts create mode 100644 src/routes/Build.tsx create mode 100644 src/routes/BuildList.tsx delete mode 100644 src/routes/Home.tsx create mode 100644 src/routes/Library.tsx create mode 100644 src/routes/Play.tsx create mode 100644 src/routes/ui.test.tsx create mode 100644 src/styles.css diff --git a/src/app.test.tsx b/src/app.test.tsx index ff5e2b2..871d930 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -26,26 +26,44 @@ afterEach(() => { }); describe("app", () => { - it("home shows the app name from runtime config and the build sha", async () => { + it("library lists published stories and links to play", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + stories: [ + { + id: "s1", + title: "Cave", + description: "Dark", + nodeCount: 3, + published: true, + }, + ], + }), + ), + ), + ); renderAt("/"); expect( - await screen.findByRole("heading", { name: "test-app" }), + await screen.findByRole("heading", { name: "Cave" }), ).toBeInTheDocument(); - expect(screen.getByTestId("build-sha")).toHaveTextContent("dev"); + expect( + screen.getAllByRole("link", { name: "Play" }).at(-1)!, + ).toHaveAttribute("href", "/play/s1"); }); - it("home hides the banner while hello-banner is off", async () => { - renderAt("/"); - await screen.findByRole("heading", { name: "test-app" }); - expect(screen.queryByTestId("banner")).not.toBeInTheDocument(); - }); - - it("home shows the banner while hello-banner is on", async () => { - flagsOn.add("hello-banner"); - renderAt("/"); - expect(await screen.findByTestId("banner")).toHaveTextContent( - "hello-banner flag is on", + it("library shows a builder prompt when empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ stories: [] }))), ); + renderAt("/"); + expect( + await screen.findByText(/No published stories yet/), + ).toBeInTheDocument(); }); it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => { diff --git a/src/lib/play.ts b/src/lib/play.ts new file mode 100644 index 0000000..20b73d1 --- /dev/null +++ b/src/lib/play.ts @@ -0,0 +1,35 @@ +import type { StoryDoc } from "./stories"; + +export interface PlayState { + path: string[]; // node keys visited, last is current +} + +export const initial = (doc: StoryDoc): PlayState => ({ path: [doc.start] }); + +export function choose(doc: StoryDoc, s: PlayState, index: number): PlayState { + const cur = s.path[s.path.length - 1]!; + const choice = doc.nodes[cur]?.choices[index]; + if (!choice || !doc.nodes[choice.to] || doc.nodes[cur]?.ending) return s; + return { path: [...s.path, choice.to] }; +} + +export const back = (s: PlayState): PlayState => + s.path.length > 1 ? { path: s.path.slice(0, -1) } : s; + +/** Drop saved paths that no longer fit the (possibly edited) story. */ +export function restore(doc: StoryDoc, raw: string | null): PlayState { + try { + const path = (JSON.parse(raw ?? "null") as { path?: unknown } | null)?.path; + if ( + Array.isArray(path) && + path.length > 0 && + path[0] === doc.start && + path.every((k) => typeof k === "string" && doc.nodes[k]) + ) { + return { path: path as string[] }; + } + } catch { + // fall through to a fresh game + } + return initial(doc); +} diff --git a/src/lib/stories.test.ts b/src/lib/stories.test.ts new file mode 100644 index 0000000..7ed3cb5 --- /dev/null +++ b/src/lib/stories.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { back, choose, initial, restore } from "./play"; +import { + analyze, + emptyDoc, + nextKey, + removeNode, + renameNode, + type StoryDoc, +} from "./stories"; + +const doc: StoryDoc = { + start: "a", + nodes: { + a: { + title: "A", + text: "", + choices: [ + { label: "to b", to: "b" }, + { label: "to c", to: "c" }, + ], + }, + b: { title: "B", text: "", ending: "win", choices: [] }, + c: { title: "C", text: "", choices: [{ label: "back", to: "a" }] }, + }, +}; + +describe("play", () => { + it("walks, undoes, and refuses to move past an ending", () => { + let s = initial(doc); + s = choose(doc, s, 1); + expect(s.path).toEqual(["a", "c"]); + s = back(s); + expect(s.path).toEqual(["a"]); + s = choose(doc, s, 0); + expect(choose(doc, s, 0)).toBe(s); + expect(back(initial(doc)).path).toEqual(["a"]); + }); + + it("restores a valid saved path and discards a stale one", () => { + expect(restore(doc, JSON.stringify({ path: ["a", "c"] })).path).toEqual([ + "a", + "c", + ]); + expect(restore(doc, JSON.stringify({ path: ["a", "gone"] })).path).toEqual([ + "a", + ]); + expect(restore(doc, JSON.stringify({ path: ["c"] })).path).toEqual(["a"]); + expect(restore(doc, "not json").path).toEqual(["a"]); + expect(restore(doc, null).path).toEqual(["a"]); + }); +}); + +describe("editor helpers", () => { + it("a fresh doc has the start dead end flagged", () => { + const msgs = analyze(emptyDoc()).map((i) => i.message); + expect(msgs).toContain("dead end: no choices and not marked as an ending"); + expect(msgs).toContain("no ending is reachable from the start node"); + }); + + it("a valid doc has no errors", () => { + expect(analyze(doc).filter((i) => i.level === "error")).toEqual([]); + }); + + it("renameNode rewrites start and choice targets; ignores collisions", () => { + const r = renameNode(doc, "a", "intro"); + expect(r.start).toBe("intro"); + expect(r.nodes.c!.choices[0]!.to).toBe("intro"); + expect(r.nodes.a).toBeUndefined(); + expect(renameNode(doc, "a", "b")).toBe(doc); + }); + + it("removeNode drops inbound choices and repoints start", () => { + const r = removeNode(doc, "b"); + expect(r.nodes.a!.choices.map((c) => c.to)).toEqual(["c"]); + expect(removeNode(doc, "a").start).toBe("b"); + }); + + it("nextKey avoids collisions", () => { + expect( + nextKey({ + start: "a", + nodes: { "node-2": doc.nodes.a!, a: doc.nodes.a! }, + }), + ).toBe("node-3"); + }); +}); diff --git a/src/lib/stories.ts b/src/lib/stories.ts new file mode 100644 index 0000000..06b990a --- /dev/null +++ b/src/lib/stories.ts @@ -0,0 +1,215 @@ +import { getConfig } from "./config"; + +export type Ending = "win" | "lose" | "neutral"; +export interface Choice { + label: string; + to: string; +} +export interface StoryNode { + title: string; + text: string; + ending?: Ending; + choices: Choice[]; +} +export interface StoryDoc { + start: string; + nodes: Record; +} +export interface Story { + id: string; + title: string; + description: string; + doc: StoryDoc; + published: boolean; + updatedAt: string; +} +export interface StorySummary extends Omit { + nodeCount: number; +} +export interface Issue { + level: "error" | "warning"; + node?: string; + message: string; +} +export interface StoryInput { + title: string; + description: string; + doc: StoryDoc; +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly issues: Issue[] = [], + ) { + super(message); + } +} + +async function call( + method: string, + path: string, + body?: unknown, +): Promise { + const base = getConfig().API_BASE_URL.replace(/\/$/, ""); + const res = await fetch(`${base}${path}`, { + method, + headers: { + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + let data: Record = {}; + try { + data = JSON.parse(text); + } catch { + // non-JSON error body + } + if (!res.ok) { + throw new ApiError( + (data.error as string | undefined) ?? `request failed (${res.status})`, + res.status, + (data.issues as Issue[] | undefined) ?? [], + ); + } + return data as T; +} + +export const listStories = (mine: boolean) => + call<{ stories: StorySummary[] }>( + "GET", + `/stories${mine ? "?mine=1" : ""}`, + ).then((r) => r.stories); +export const getStory = (id: string) => + call<{ story: Story; issues: Issue[] }>("GET", `/stories/${id}`); +export const createStory = (input: StoryInput) => + call<{ story: Story; issues: Issue[] }>("POST", "/stories", input); +export const saveStory = (id: string, input: StoryInput) => + call<{ story: Story; issues: Issue[] }>("PUT", `/stories/${id}`, input); +export const publishStory = (id: string) => + call<{ story: Story; issues: Issue[] }>("POST", `/stories/${id}/publish`); +export const unpublishStory = (id: string) => + call<{ story: Story; issues: Issue[] }>("POST", `/stories/${id}/unpublish`); +export const deleteStory = (id: string) => + call("DELETE", `/stories/${id}`); + +export const KEY_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/; + +/** Mirror of the backend's analyze(), for live feedback while editing. */ +export function analyze(doc: StoryDoc): Issue[] { + const issues: Issue[] = []; + const nodes = doc.nodes; + if (!nodes[doc.start]) { + issues.push({ + level: "error", + message: `start node "${doc.start}" does not exist`, + }); + } + for (const [k, n] of Object.entries(nodes)) { + if (!KEY_RE.test(k)) { + issues.push({ + level: "error", + node: k, + message: "invalid node key (use a-z, 0-9, - _)", + }); + } + for (const c of n.choices) { + if (!nodes[c.to]) { + issues.push({ + level: "error", + node: k, + message: `choice "${c.label}" points to missing node "${c.to}"`, + }); + } + } + if (!n.ending && n.choices.length === 0) { + issues.push({ + level: "error", + node: k, + message: "dead end: no choices and not marked as an ending", + }); + } + if (n.ending && n.choices.length > 0) { + issues.push({ + level: "warning", + node: k, + message: "ending node has choices that will never be shown", + }); + } + } + if (nodes[doc.start]) { + const seen = new Set(); + const stack = [doc.start]; + while (stack.length) { + const k = stack.pop()!; + const n = nodes[k]; + if (seen.has(k) || !n) continue; + seen.add(k); + if (!n.ending) for (const c of n.choices) stack.push(c.to); + } + for (const k of Object.keys(nodes)) { + if (!seen.has(k)) { + issues.push({ + level: "warning", + node: k, + message: "unreachable from the start node", + }); + } + } + if (![...seen].some((k) => nodes[k]?.ending)) { + issues.push({ + level: "error", + message: "no ending is reachable from the start node", + }); + } + } + return issues; +} + +export function emptyDoc(): StoryDoc { + return { + start: "start", + nodes: { + start: { + title: "The beginning", + text: "Your story starts here.", + choices: [], + }, + }, + }; +} + +/** Rename a node key and rewrite every reference to it. */ +export function renameNode(doc: StoryDoc, from: string, to: string): StoryDoc { + if (from === to || doc.nodes[to]) return doc; + const nodes: Record = {}; + for (const [k, n] of Object.entries(doc.nodes)) { + nodes[k === from ? to : k] = { + ...n, + choices: n.choices.map((c) => (c.to === from ? { ...c, to } : c)), + }; + } + return { start: doc.start === from ? to : doc.start, nodes }; +} + +export function removeNode(doc: StoryDoc, key: string): StoryDoc { + const nodes: Record = {}; + for (const [k, n] of Object.entries(doc.nodes)) { + if (k !== key) + nodes[k] = { ...n, choices: n.choices.filter((c) => c.to !== key) }; + } + return { + start: doc.start === key ? (Object.keys(nodes)[0] ?? "") : doc.start, + nodes, + }; +} + +export function nextKey(doc: StoryDoc): string { + for (let i = Object.keys(doc.nodes).length + 1; ; i++) { + if (!doc.nodes[`node-${i}`]) return `node-${i}`; + } +} diff --git a/src/main.tsx b/src/main.tsx index c239bd7..7093015 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; import { FlagsProvider } from "./lib/flags"; import { routes } from "./routes"; +import "./styles.css"; const router = createBrowserRouter(routes); diff --git a/src/routes.tsx b/src/routes.tsx index 066c087..2006760 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,7 +1,10 @@ import type { RouteObject } from "react-router"; +import { Build, buildLoader } from "./routes/Build"; +import { BuildList, buildListLoader } from "./routes/BuildList"; import { ErrorPage } from "./routes/ErrorPage"; -import { Home } from "./routes/Home"; import { Layout } from "./routes/Layout"; +import { Library, libraryLoader } from "./routes/Library"; +import { Play, playLoader } from "./routes/Play"; import { Status, statusLoader } from "./routes/Status"; // Data-mode route table. Add pages here; loaders live next to their component. @@ -11,7 +14,10 @@ export const routes: RouteObject[] = [ element: , errorElement: , children: [ - { index: true, element: }, + { index: true, element: , loader: libraryLoader }, + { path: "play/:id", element: , loader: playLoader }, + { path: "build", element: , loader: buildListLoader }, + { path: "build/:id", element: , loader: buildLoader }, { path: "status", element: , loader: statusLoader }, ], }, diff --git a/src/routes/Build.tsx b/src/routes/Build.tsx new file mode 100644 index 0000000..0e9223b --- /dev/null +++ b/src/routes/Build.tsx @@ -0,0 +1,313 @@ +import { useMemo, useState } from "react"; +import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router"; +import { + analyze, + ApiError, + getStory, + KEY_RE, + nextKey, + publishStory, + removeNode, + renameNode, + saveStory, + unpublishStory, + type Ending, + type Issue, + type Story, + type StoryDoc, + type StoryNode, +} from "../lib/stories"; + +export async function buildLoader({ params }: LoaderFunctionArgs) { + return (await getStory(params.id!)).story; +} + +export function Build() { + return ; +} + +export function Editor({ story }: { story: Story }) { + const [title, setTitle] = useState(story.title); + const [description, setDescription] = useState(story.description); + const [doc, setDoc] = useState(story.doc); + const [published, setPublished] = useState(story.published); + const [dirty, setDirty] = useState(false); + const [message, setMessage] = useState(null); + const [serverIssues, setServerIssues] = useState(null); + + const issues = useMemo(() => analyze(doc), [doc]); + const keys = Object.keys(doc.nodes); + + const edit = (fn: (d: StoryDoc) => StoryDoc) => { + setDoc(fn); + setDirty(true); + }; + const patchNode = (k: string, patch: Partial) => + edit((d) => ({ + ...d, + nodes: { ...d.nodes, [k]: { ...d.nodes[k]!, ...patch } }, + })); + + async function save(): Promise { + try { + await saveStory(story.id, { title, description, doc }); + setDirty(false); + setMessage("Saved."); + setServerIssues(null); + return true; + } catch (e) { + setMessage( + e instanceof Error ? `Save failed: ${e.message}` : "Save failed", + ); + return false; + } + } + + async function togglePublish() { + if (dirty && !(await save())) return; + try { + if (published) { + await unpublishStory(story.id); + setPublished(false); + setMessage("Unpublished."); + } else { + await publishStory(story.id); + setPublished(true); + setMessage("Published!"); + } + } catch (e) { + if (e instanceof ApiError) setServerIssues(e.issues); + setMessage(e instanceof Error ? e.message : "Publish failed"); + } + } + + return ( +
+

+ ← Your stories +

+

Edit story

+ +