From 4385a679e7a1cf7cd70ab14962b4761b072af251 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 03:29:12 +0700 Subject: [PATCH 1/3] feat(viewer): host OpenWorlds surface --- THIRD_PARTY_NOTICES.md | 17 + viewer/openworlds/SOURCE.md | 48 + viewer/openworlds/app.jsx | 167 + viewer/openworlds/camp-sidebar.jsx | 592 + viewer/openworlds/chrome.jsx | 375 + viewer/openworlds/data.js | 388 + viewer/openworlds/index.html | 42 + viewer/openworlds/screen-acts.jsx | 305 + viewer/openworlds/screen-bestiary.jsx | 398 + viewer/openworlds/screen-character.jsx | 597 + viewer/openworlds/screen-combat.jsx | 402 + viewer/openworlds/screen-create.jsx | 671 + viewer/openworlds/screen-dialogue.jsx | 281 + viewer/openworlds/screen-forge.jsx | 393 + viewer/openworlds/screen-inventory.jsx | 307 + viewer/openworlds/screen-journal.jsx | 206 + viewer/openworlds/screen-launcher.jsx | 382 + viewer/openworlds/screen-map.jsx | 330 + viewer/openworlds/screen-merchant.jsx | 286 + viewer/openworlds/screen-relations.jsx | 500 + viewer/openworlds/screen-seed.jsx | 319 + viewer/openworlds/screen-settings.jsx | 468 + viewer/openworlds/screen-table.jsx | 372 + viewer/openworlds/styles.css | 805 + viewer/openworlds/toast.jsx | 152 + viewer/openworlds/tooltip.jsx | 100 + .../openworlds/vendor/THIRD_PARTY_NOTICES.md | 47 + .../vendor/babel-standalone-7.29.0.min.js | 4 + .../8vIU7ww63mVu7gtR-kwKxNvkNOjw-gjgTYo.ttf | Bin 0 -> 46116 bytes .../8vIU7ww63mVu7gtR-kwKxNvkNOjw-jHgTYo.ttf | Bin 0 -> 46124 bytes .../8vIU7ww63mVu7gtR-kwKxNvkNOjw-tbnTYo.ttf | Bin 0 -> 45884 bytes .../8vIU7ww63mVu7gtR-kwKxNvkNOjw-uTnTYo.ttf | Bin 0 -> 46048 bytes .../fonts/Ktk1ALSLW8zDe0rthJysWrnLsAz3Fw.ttf | Bin 0 -> 189680 bytes .../Ktk3ALSLW8zDe0rthJysWrnLsAzHFaOd.ttf | Bin 0 -> 197028 bytes ...bLeY9MK7whWMhyjYrGFEsdtdc62E6zd58jDOjw.ttf | Bin 0 -> 293036 bytes ...bLeY9MK7whWMhyjYrGFEsdtdc62E6zd5wDDOjw.ttf | Bin 0 -> 293244 bytes ...Li8bLeY9MK7whWMhyjypVO7abI26QOD_hg9GnM.ttf | Bin 0 -> 289992 bytes ...Li8bLeY9MK7whWMhyjypVO7abI26QOD_iE9GnM.ttf | Bin 0 -> 290392 bytes ...Li8bLeY9MK7whWMhyjypVO7abI26QOD_s06GnM.ttf | Bin 0 -> 290496 bytes ...Li8bLeY9MK7whWMhyjypVO7abI26QOD_v86GnM.ttf | Bin 0 -> 290236 bytes ...lEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8-qxjPQ.ttf | Bin 0 -> 112204 bytes ...lEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf | Bin 0 -> 112172 bytes viewer/openworlds/vendor/google-fonts.css | 98 + .../vendor/react-18.3.1.development.js | 3343 ++ .../vendor/react-dom-18.3.1.development.js | 29924 ++++++++++++++++ viewer/server.py | 67 +- viewer/tests/test_openworlds_static.py | 101 + 47 files changed, 42486 insertions(+), 1 deletion(-) create mode 100644 viewer/openworlds/SOURCE.md create mode 100644 viewer/openworlds/app.jsx create mode 100644 viewer/openworlds/camp-sidebar.jsx create mode 100644 viewer/openworlds/chrome.jsx create mode 100644 viewer/openworlds/data.js create mode 100644 viewer/openworlds/index.html create mode 100644 viewer/openworlds/screen-acts.jsx create mode 100644 viewer/openworlds/screen-bestiary.jsx create mode 100644 viewer/openworlds/screen-character.jsx create mode 100644 viewer/openworlds/screen-combat.jsx create mode 100644 viewer/openworlds/screen-create.jsx create mode 100644 viewer/openworlds/screen-dialogue.jsx create mode 100644 viewer/openworlds/screen-forge.jsx create mode 100644 viewer/openworlds/screen-inventory.jsx create mode 100644 viewer/openworlds/screen-journal.jsx create mode 100644 viewer/openworlds/screen-launcher.jsx create mode 100644 viewer/openworlds/screen-map.jsx create mode 100644 viewer/openworlds/screen-merchant.jsx create mode 100644 viewer/openworlds/screen-relations.jsx create mode 100644 viewer/openworlds/screen-seed.jsx create mode 100644 viewer/openworlds/screen-settings.jsx create mode 100644 viewer/openworlds/screen-table.jsx create mode 100644 viewer/openworlds/styles.css create mode 100644 viewer/openworlds/toast.jsx create mode 100644 viewer/openworlds/tooltip.jsx create mode 100644 viewer/openworlds/vendor/THIRD_PARTY_NOTICES.md create mode 100644 viewer/openworlds/vendor/babel-standalone-7.29.0.min.js create mode 100644 viewer/openworlds/vendor/fonts/8vIU7ww63mVu7gtR-kwKxNvkNOjw-gjgTYo.ttf create mode 100644 viewer/openworlds/vendor/fonts/8vIU7ww63mVu7gtR-kwKxNvkNOjw-jHgTYo.ttf create mode 100644 viewer/openworlds/vendor/fonts/8vIU7ww63mVu7gtR-kwKxNvkNOjw-tbnTYo.ttf create mode 100644 viewer/openworlds/vendor/fonts/8vIU7ww63mVu7gtR-kwKxNvkNOjw-uTnTYo.ttf create mode 100644 viewer/openworlds/vendor/fonts/Ktk1ALSLW8zDe0rthJysWrnLsAz3Fw.ttf create mode 100644 viewer/openworlds/vendor/fonts/Ktk3ALSLW8zDe0rthJysWrnLsAzHFaOd.ttf create mode 100644 viewer/openworlds/vendor/fonts/co3smX5slCNuHLi8bLeY9MK7whWMhyjYrGFEsdtdc62E6zd58jDOjw.ttf create mode 100644 viewer/openworlds/vendor/fonts/co3smX5slCNuHLi8bLeY9MK7whWMhyjYrGFEsdtdc62E6zd5wDDOjw.ttf create mode 100644 viewer/openworlds/vendor/fonts/co3umX5slCNuHLi8bLeY9MK7whWMhyjypVO7abI26QOD_hg9GnM.ttf create mode 100644 viewer/openworlds/vendor/fonts/co3umX5slCNuHLi8bLeY9MK7whWMhyjypVO7abI26QOD_iE9GnM.ttf create mode 100644 viewer/openworlds/vendor/fonts/co3umX5slCNuHLi8bLeY9MK7whWMhyjypVO7abI26QOD_s06GnM.ttf create mode 100644 viewer/openworlds/vendor/fonts/co3umX5slCNuHLi8bLeY9MK7whWMhyjypVO7abI26QOD_v86GnM.ttf create mode 100644 viewer/openworlds/vendor/fonts/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8-qxjPQ.ttf create mode 100644 viewer/openworlds/vendor/fonts/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf create mode 100644 viewer/openworlds/vendor/google-fonts.css create mode 100644 viewer/openworlds/vendor/react-18.3.1.development.js create mode 100644 viewer/openworlds/vendor/react-dom-18.3.1.development.js create mode 100644 viewer/tests/test_openworlds_static.py diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 95e1598f..3922630b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,6 +22,23 @@ ClawDnD's MCP servers depend on these packages: - **httpx** (BSD-3-Clause) — HTTP client for the dnd5eapi.co fallback. - **kokoro** (Apache-2.0) — local text-to-speech (added with the voice backend). +## Bundled browser runtime assets + +The local OpenWorlds viewer surface vendors these files under +`viewer/openworlds/vendor/` so the packaged macOS app can render without live CDN +dependencies: + +- **React 18.3.1** (`react-18.3.1.development.js`, MIT) +- **ReactDOM 18.3.1** (`react-dom-18.3.1.development.js`, MIT) +- **Babel Standalone 7.29.0** (`babel-standalone-7.29.0.min.js`, MIT) +- **Google Fonts families** (`Cinzel`, `Cormorant Garamond`, + `IM Fell English`, `JetBrains Mono`, open font licenses) + +See `viewer/openworlds/vendor/THIRD_PARTY_NOTICES.md` for source URLs and file +hashes. These development-mode runtime files are a fidelity bridge for the first +local packaged surface; release hardening should replace Babel-in-browser with a +compiled bundle while keeping all assets local. + ## Structural references — learn-from only (NO code copied) These projects informed ClawDnD's design. After verifying their licensing diff --git a/viewer/openworlds/SOURCE.md b/viewer/openworlds/SOURCE.md new file mode 100644 index 00000000..ad69fc4b --- /dev/null +++ b/viewer/openworlds/SOURCE.md @@ -0,0 +1,48 @@ +# OpenWorlds Surface Source Contract + +This directory hosts the first exact-fidelity OpenWorlds browser surface for the +local viewer. It is intentionally a viewer asset bundle, not a new engine, +rules, voice, or campaign-state authority. + +## Source Artifact + +- Source artifact: `OpenWorlds.zip` +- Source SHA256: `8e9e2b885764fd3492b74b2d02eda5db9827eb087121054e8ca52e9ace10fd0a` +- Extraction label used during review: `openworlds-design-2026-05-25/openworlds` +- Provenance: internal design handoff for ClawDnD/OpenWorlds implementation, not + a blanket license for third-party screenshots, uploads, or generated images. + +## Included Source Files + +The production viewer route maps source files into this directory as follows: + +| Source artifact entry | Repo path | +| --- | --- | +| `openworlds/Open Worlds.html` | `viewer/openworlds/index.html` | +| `openworlds/styles.css` | `viewer/openworlds/styles.css` | +| `openworlds/data.js` | `viewer/openworlds/data.js` | +| `openworlds/app.jsx` | `viewer/openworlds/app.jsx` | +| `openworlds/chrome.jsx` | `viewer/openworlds/chrome.jsx` | +| `openworlds/tooltip.jsx` | `viewer/openworlds/tooltip.jsx` | +| `openworlds/toast.jsx` | `viewer/openworlds/toast.jsx` | +| `openworlds/camp-sidebar.jsx` | `viewer/openworlds/camp-sidebar.jsx` | +| `openworlds/screen-*.jsx` | `viewer/openworlds/screen-*.jsx` | + +## Intentional Exclusions + +- `openworlds/tweaks-panel.jsx` is excluded from the production route. The app + still keeps the prototype tweak defaults in `app.jsx`, but no Open Design + sandbox panel is loaded. +- `openworlds/screenshots/*`, uploaded images, and reference captures are not + included. They remain reference-only unless a later PR clears provenance. +- Live CDN runtime imports are not used. React, ReactDOM, Babel standalone, and + the Google font files required by the prototype are vendored under + `viewer/openworlds/vendor/`. + +## State Boundary + +For this PR, `data.js` is non-canonical demo data used only to render the exact +surface. Future PRs replace those rows with read-only viewer APIs. The browser +may read viewer endpoints and, once backed actions exist, post player intent to +`POST /move`; it must not write `snapshot.json`, `play-state`, `qa/state`, +inventory, quests, XP, world clocks, or companion state directly. diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx new file mode 100644 index 00000000..44114cff --- /dev/null +++ b/viewer/openworlds/app.jsx @@ -0,0 +1,167 @@ +/* App router + tweaks */ + +const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ + "palette": "warm", + "ornaments": true, + "candle": true +}/*EDITMODE-END*/; + +function App() { + const [state, setState] = React.useState(window.INITIAL_STATE); + const [screen, setScreen] = React.useState("launcher"); + const [campMode, setCampMode] = React.useState(false); + const [t, setTweak] = (window.useTweaks + ? window.useTweaks(TWEAK_DEFAULTS) + : [TWEAK_DEFAULTS, () => {}]); + + React.useEffect(() => { + document.documentElement.setAttribute("data-palette", t.palette || "warm"); + }, [t.palette]); + + // Keyboard shortcuts + React.useEffect(() => { + const onKey = (e) => { + if (e.target.matches("input, textarea")) return; + const map = { + "t": "table", + "x": "combat", + "p": "dialogue", + "m": "map", + "c": "character", + "i": "inventory", + "f": "forge", + "r": "relations", + "j": "journal", + "b": "bestiary", + "a": "acts", + "$": "merchant", + "w": "launcher", + "n": "create", + "s": "seed", + ",": "settings", + "?": "settings", + }; + const id = map[e.key.toLowerCase()]; + if (id) { + e.preventDefault(); + setScreen(id); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const navigate = (id, opts) => { + if (opts?.openCamp) setCampMode(true); + setScreen(id); + }; + + const current = state.campaigns.find((c) => c.id === state.activeCampaign) || state.campaigns[0]; + + return ( +
+ +
+ +
+ +
+
+
+ +
+
+
+
+
+ + {window.TweaksPanel && ( + + + setTweak("palette", v)} + options={[ + { value: "warm", label: "Warm" }, + { value: "cool", label: "Aged" }, + { value: "dark", label: "Walnut" }, + ]} + /> + + + setTweak("candle", v)} + /> + setTweak("ornaments", v)} + /> + + + {[...window.NAV_GROUPS, window.NAV_BOTTOM].flatMap((g) => g.tabs).map((t) => ( + navigate(t.id)} + /> + ))} + + )} +
+ ); +} + +const SCREEN_TITLES = { + launcher: "Chronicles", + table: "The Session", + combat: "Battle", + character: "Heroes", + create: "Creation Plane", + forge: "Item Forge", + relations: "Relations", + acts: "Acts", + seed: "World Seed", + inventory: "Stash", + map: "World Atlas", + journal: "Quest Journal", + bestiary: "Codex", + merchant: "The Market", + dialogue: "Parley", + settings: "Setting", +}; + +function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMode }) { + switch (screen) { + case "launcher": return ; + case "table": return ; + case "combat": return ; + case "character": return ; + case "create": return ; + case "forge": return ; + case "relations": return ; + case "acts": return ; + case "seed": return ; + case "inventory": return ; + case "map": return ; + case "journal": return ; + case "bestiary": return ; + case "merchant": return ; + case "dialogue": return ; + case "settings": return ; + default: return ; + } +} + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/viewer/openworlds/camp-sidebar.jsx b/viewer/openworlds/camp-sidebar.jsx new file mode 100644 index 00000000..ff3815f3 --- /dev/null +++ b/viewer/openworlds/camp-sidebar.jsx @@ -0,0 +1,592 @@ +/* Camp Sidebar — Pathfinder Kingmaker-style party role assignment */ + +function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) { + // Role assignments (each portrait slot maps to a hero id, or null) + const [roles, setRoles] = React.useState({ + hunting: "mira", + camouflage: null, + cooking: "linzi", + watch1: "cassian", + watch2: "vell", + }); + const [recipe, setRecipe] = React.useState("hearty"); + const [healing, setHealing] = React.useState("spells"); + const [draggingHero, setDraggingHero] = React.useState(null); + + // Get the special-roles cards for any companion not assigned to a primary role + const assignedIds = new Set(Object.values(roles).filter(Boolean)); + const specialRoles = state.party + .filter((p) => !assignedIds.has(p.id)) + .map((p) => ({ hero: p, role: SPECIAL_ROLES[p.id] || { name: "Stand watch", detail: "Quiet hours." } })); + + // Time calculation — hunting=2h, cooking=1h, baseline rest=8h + const huntingHours = roles.hunting ? 2 : 0; + const totalHours = 8 + huntingHours; + const ration = (roles.hunting ? 0 : 5); // hunting → 0 needed; otherwise 5 + + const onDrop = (slot) => { + if (!draggingHero) return; + setRoles((r) => { + const newRoles = { ...r }; + // Remove hero from any existing slot + Object.keys(newRoles).forEach((k) => { if (newRoles[k] === draggingHero) newRoles[k] = null; }); + newRoles[slot] = draggingHero; + return newRoles; + }); + setDraggingHero(null); + }; + + const clearSlot = (slot) => setRoles((r) => ({ ...r, [slot]: null })); + + return ( +
+ + {/* Time progression bar */} + +
+
23 Gozran
+
Camp
+
24 Gozran
+
+ +
+ + Resting will take {totalHours} hours. + Wake at {((17 + totalHours) % 24).toString().padStart(2, "0")}:00. + +
+
+ + {/* Manage / Rations */} + +
+ Rations + Manage +
+
+ + +
+ {ration > 6 && ( +
+ Hunting is required for a rest. +
+ )} + +
+ + {/* Healing */} + + Healing + + + + {/* Role slots */} + + p.id === roles.hunting)} + onDrop={() => onDrop("hunting")} + onClear={() => clearSlot("hunting")} + onDragOver={(e) => e.preventDefault()} + /> + p.id === roles.camouflage)} + onDrop={() => onDrop("camouflage")} + onClear={() => clearSlot("camouflage")} + onDragOver={(e) => e.preventDefault()} + /> + + {recipe ? RECIPES[recipe].name : "No meal"} · Cooking DC: 20 +
{recipe ? RECIPES[recipe].bonus : ""}
+ + } + hero={state.party.find((p) => p.id === roles.cooking)} + onDrop={() => onDrop("cooking")} + onClear={() => clearSlot("cooking")} + onDragOver={(e) => e.preventDefault()} + /> + {roles.cooking && ( +
+
Recipes
+
+ {Object.entries(RECIPES).map(([id, r]) => ( + + ))} +
+
+ )} +
+ + {/* Special Roles (auto-assigned to idle) */} + {specialRoles.length > 0 && ( + + Special Roles +
+ {specialRoles.map(({ hero, role }) => ( + + ))} +
+
+ )} + + {/* Watch */} + + Watch Order +
+ p.id === roles.watch1)} + value="+8" + onDrop={() => onDrop("watch1")} + onClear={() => clearSlot("watch1")} + onDragOver={(e) => e.preventDefault()} + /> + p.id === roles.watch2)} + value="+8" + onDrop={() => onDrop("watch2")} + onClear={() => clearSlot("watch2")} + onDragOver={(e) => e.preventDefault()} + /> +
+
+ Summary · Perception. The watch will guard during the whole rest. +
+
+ + {/* Companions — drag source + talk affordance */} + + drag to assign + }>The Party +
+ {state.party.map((p) => { + const wantsToTalk = TALK_PROMPTS[p.id]?.openingPrompt; + const isDragging = draggingHero === p.id; + const isAssigned = assignedIds.has(p.id); + return ( +
+ +
+ {p.name.split(" ")[0]} +
+ {/* Talk-to-companion quill */} + {wantsToTalk && ( + + )} +
+ ); + })} +
+
+ + {/* Inline conversation panel */} + {talkPartner && p.id === talkPartner)} onClose={() => onTalk(null)} />} + + {/* Begin resting */} +
+ Leave camp + + ✺ Begin Resting + +
+
+ ); +} + +function TimelineBar({ startHour, hours }) { + // Render a 24-hour ribbon with day/night gradient and a wax-seal cursor + const totalSpan = 24; + const segments = Array.from({ length: totalSpan }).map((_, i) => (startHour + i) % 24); + return ( +
+
+
+ {segments.map((h, i) => ( +
{h}
+ ))} +
+ {/* Wax-seal cursor */} +
+
{hours}
+
+
+ ); +} + +function RationStat({ label, value }) { + return ( +
+
+
+
{label}
+
{value}
+
+
+ ); +} + +function RoleSlot({ icon, label, summary, summaryValue, detail, hero, onDrop, onClear, onDragOver }) { + return ( +
+ {/* Portrait slot */} +
+ {hero ? ( + <> + + + + ) : ( +
drag
here
+ )} +
+ +
+
+ {icon} + {label} +
+
{summary} {summaryValue}
+
+ {detail} +
+
+
+ ); +} + +function WatchSlot({ num, hero, value, onDrop, onClear, onDragOver }) { + return ( +
+ {num} + {hero ? ( + <> + +
+ {hero.name.split(" ")[0]} {value} +
+ + + ) : ( +
drag
+ )} +
+ ); +} + +function SpecialRoleRow({ hero, role }) { + return ( +
+ +
+
{hero.name.split(" ")[0]}
+
+ {role.name} +
+
{role.detail}
+
+
+ ); +} + +function CampRadio({ value, onChange, options }) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +function TalkPanel({ hero, onClose }) { + const conv = TALK_PROMPTS[hero.id] || TALK_PROMPTS._default; + const [reply, setReply] = React.useState(null); + + return ( + +
+
+
At the fire
+
+ With {hero.name.split(" ")[0]} +
+
+ +
+ +
+ +
+ " + {reply ? reply.heroReply : conv.openingPrompt} +
+
+ + {!reply && ( +
+ {conv.responses.map((r, i) => ( + + ))} +
+ )} + + {reply && ( +
+ Bank the fire +
+ )} +
+ ); +} + +const SPECIAL_ROLES = { + cassian: { name: "Maintain Armor", detail: "Sees to the buckles. +1 AC for tomorrow." }, + vell: { name: "Sharpen Weapons", detail: "Iron on whetstone. +1 damage for tomorrow." }, + mira: { name: "Patrol Perimeter", detail: "Quiet steps. -2 to ambush chance." }, + linzi: { name: "Inspire Competence", detail: "Linzi's enthusiasm gives camp-duty rolls a +2 competence bonus." }, +}; + +const RECIPES = { + hearty: { name: "Hearty Meal", bonus: "+2 Fortitude saves until next rest." }, + pheasant: { name: "Roast Pheasant", bonus: "+1 to all attack rolls until next rest." }, + stew: { name: "Trail Stew", bonus: "+1 hp/level on next long rest." }, +}; + +const TALK_PROMPTS = { + cassian: { + openingPrompt: "I will tell you a thing I have not told the others — I have not drawn a blade in a Warden hall in seven years. I am not certain whether that is honour or its imitation.", + responses: [ + { tag: "Lawful Good", text: "Honour. There is no version of that that is imitation.", heroReply: "You make me want to believe you. That is a useful thing in a captain." }, + { tag: "Chaotic", text: "It is whatever you need it to be tomorrow. Tonight it is rest.", heroReply: "You are a poor philosopher and a good companion. I will take both." }, + { text: "Why have you not drawn?", heroReply: "Because the one I would have drawn against was my teacher. He died first. I was not given the chance to learn whether I would have." }, + ], + }, + mira: { + openingPrompt: "I have written you down badly twice this week. The first time I called you brave. The second I left out an adjective entirely. I am embarrassed about both.", + responses: [ + { text: "Brave is not wrong.", heroReply: "Brave is what people put in margins. I am trying for the body of the page." }, + { tag: "Chaotic", text: "Leave more adjectives out. I am tired of them.", heroReply: "That is the most useful thing you have said to me. I will start tonight." }, + { text: "Read me what you have so far.", heroReply: "Tomorrow. I want to fix one more thing before you hear it as written." }, + ], + }, + vell: { + openingPrompt: "The Iron-Shod taught me one prayer. I do not say it any more. I was wondering if you would mind me telling you why.", + responses: [ + { text: "Tell me.", heroReply: "The prayer asks the stone to remember you. The stone does not need to be asked. That is the whole of it." }, + { tag: "Lawful Good", text: "If you do not wish to say it, you do not have to.", heroReply: "I thank you for that. I think I will say it tomorrow regardless. To the camp, before we leave it. So it has been said once." }, + { text: "Why did you leave them?", heroReply: "Because I asked a question. They thought it impolite. I thought it the only question. We were both correct." }, + ], + }, + linzi: { + openingPrompt: "This is the part of the night when I write down what we did today. I am asking your permission, formally, before I write down what you did today.", + responses: [ + { text: "Write what you saw.", heroReply: "I always do. I will note that you said so." }, + { tag: "Chaotic", text: "Write down what I would have done if I were braver.", heroReply: "I will write down what you did. The chronicle is generous about counterfactuals when read aloud, not when read." }, + { text: "Don't write the part with the door.", heroReply: "I will write the door. I will not write what you said about it. That is a compromise." }, + ], + }, + _default: { + openingPrompt: "Sit. The fire is low. There is room.", + responses: [ + { text: "I'll sit a while.", heroReply: "Good. We do not have to fill it." }, + { text: "Walk with me first.", heroReply: "Then walk." }, + ], + }, +}; + +Object.assign(window, { CampSidebar, RoleSlot, WatchSlot, RationStat, TimelineBar, SpecialRoleRow, CampRadio, TalkPanel, SPECIAL_ROLES, RECIPES, TALK_PROMPTS }); diff --git a/viewer/openworlds/chrome.jsx b/viewer/openworlds/chrome.jsx new file mode 100644 index 00000000..9d5fc2f9 --- /dev/null +++ b/viewer/openworlds/chrome.jsx @@ -0,0 +1,375 @@ +/* Shared chrome: window frame, nav rail, title bar */ + +const NAV_GROUPS = [ + { + id: "g_table", label: "Table", glyph: "dice", + tabs: [ + { id: "table", label: "Session" }, + { id: "combat", label: "Battle" }, + { id: "dialogue", label: "Parley" }, + ], + }, + { + id: "g_map", label: "Map", glyph: "map", + tabs: [{ id: "map", label: "Atlas" }], + }, + { + id: "g_party", label: "Party", glyph: "shield", + tabs: [ + { id: "character", label: "Heroes" }, + { id: "inventory", label: "Stash" }, + { id: "forge", label: "Forge" }, + { id: "relations", label: "Relations" }, + ], + }, + { + id: "g_journal", label: "Journal", glyph: "book", + tabs: [ + { id: "journal", label: "Quests" }, + { id: "bestiary", label: "Codex" }, + { id: "acts", label: "Acts" }, + ], + }, + { + id: "g_market", label: "Market", glyph: "coins", + tabs: [{ id: "merchant", label: "The Market" }], + }, +]; + +const NAV_BOTTOM = { + id: "g_worlds", label: "Worlds", glyph: "compass", + tabs: [ + { id: "launcher", label: "Chronicles" }, + { id: "create", label: "Creation Plane" }, + { id: "seed", label: "World Seed" }, + { id: "settings", label: "Settings" }, + ], +}; + +// Build flat lookup of screenId → group +const ALL_NAV = [...NAV_GROUPS, NAV_BOTTOM]; +function getGroupForScreen(screenId) { + return ALL_NAV.find((g) => g.tabs.some((t) => t.id === screenId)); +} +function getDefaultScreen(groupId) { + const g = ALL_NAV.find((x) => x.id === groupId); + return g ? g.tabs[0].id : "launcher"; +} + +function Glyph({ kind, size = 22 }) { + const stroke = { stroke: "currentColor", strokeWidth: 1.4, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }; + switch (kind) { + case "compass": + return ( + + + + + + ); + case "dice": + return ( + + + + + + + + ); + case "shield": + return ( + + + + + ); + case "pouch": + return ( + + + + + + ); + case "map": + return ( + + + + + ); + case "book": + return ( + + + + + ); + case "scroll": + return ( + + + + + ); + case "quill": + return ( + + + + ); + case "sword": + return ( + + + + + + ); + case "anvil": + return ( + + + + + + ); + case "feather": + return ( + + + + + ); + case "coins": + return ( + + + + + + + ); + case "settings": + return ( + + + + + ); + default: + return null; + } +} + +function CornerOrnament({ corner }) { + return ( + + ); +} + +function Divider({ children }) { + return ( + + ); +} + +function SectionTitle({ ordinal, children, right }) { + return ( +
+ {ordinal && {ordinal}} + {children} + {right && {right}} +
+ ); +} + +function Pill({ children, tone, dot }) { + return {children}; +} + +function Placeholder({ label, w, h, framed, style, children, className }) { + return ( +
+ {children || {label}} +
+ ); +} + +function IconPlate({ size = 56, label, framed = true, glyph, tone, children, onClick, active, style }) { + return ( + + ); +} + +function BrassButton({ children, onClick, tone, size, disabled, style, type }) { + const cls = ["btn", tone, size, disabled ? "disabled" : ""].filter(Boolean).join(" "); + return ( + + ); +} + +function Panel({ children, framed, dark, className, style, ornaments = true }) { + const cls = ["panel", framed ? "framed" : "", dark ? "dark" : "", className || ""].filter(Boolean).join(" "); + return ( +
+ {ornaments && framed && ( + + + + + + + )} + {children} +
+ ); +} + +function NavRail({ current, onNavigate }) { + const currentGroup = getGroupForScreen(current); + return ( + + ); +} + +function TabBar({ current, onNavigate }) { + const group = getGroupForScreen(current); + if (!group || group.tabs.length < 2) return null; + return ( +
+ {/* Group label as quill */} +
+ {group.label} +
+ + {group.tabs.map((tab) => ( + + ))} +
+ ); +} + +function TitleBar({ campaign, location, day }) { + return ( +
+ +
+ Open Worlds·{campaign || "The Long Road to Odrun"} + {location && (<>·{location})} +
+
+ {day && {day}} +
+
+ ); +} + +Object.assign(window, { + NAV_GROUPS, NAV_BOTTOM, ALL_NAV, getGroupForScreen, getDefaultScreen, + Glyph, CornerOrnament, Divider, SectionTitle, Pill, + Placeholder, IconPlate, BrassButton, Panel, NavRail, TabBar, TitleBar, +}); diff --git a/viewer/openworlds/data.js b/viewer/openworlds/data.js new file mode 100644 index 00000000..71bcf3ce --- /dev/null +++ b/viewer/openworlds/data.js @@ -0,0 +1,388 @@ +/* Non-canonical prototype data for the OpenWorlds fidelity surface. + This file is a visual/demo seed only until viewer APIs bind real ClawDnD + read models. Do not treat it as campaign, world, lore, or rules truth. */ + +const INITIAL_STATE = { + activeCampaign: "odrun", + campaigns: [ + { + id: "odrun", + title: "The Long Road to Odrun", + subtitle: "Chapter II — Of Toll Roads and Quiet Inns", + system: "Pathfinder 1e", + chapter: "II", + lastPlayed: "Tuesday past", + sessions: 14, + region: "The Stolen Marches", + day: "Day 12 · 29 Gozran", + party: [ + { name: "Cassian", short: "portrait" }, + { name: "Mira", short: "portrait" }, + { name: "Vell", short: "portrait" }, + { name: "Linzi", short: "portrait" }, + ], + recap: "On the eleventh evening the party crossed the Thorn Ford and made for the Lanternrest, where a crow keeps the gable and the lantern over the door has not been lit in seven years. Mira swore she heard floorboards inside. Cassian did not disagree.", + }, + { + id: "bone-kings", + title: "The Bone Kings of Aelven", + subtitle: "Chapter VI — Beneath the Drowned Cathedral", + system: "D&D 5e", + chapter: "VI", + lastPlayed: "three weeks past", + sessions: 27, + region: "Aelven Reach", + day: "Day 41 · winter", + party: [ + { name: "Brand", short: "portrait" }, + { name: "Sera", short: "portrait" }, + { name: "Ord", short: "portrait" }, + ], + recap: "Sera lit the brazier on the altar and the cathedral answered with bells none of you had heard struck. The doors closed. The water began to rise.", + }, + { + id: "lantern", + title: "Songs for the Last Lantern", + subtitle: "Prelude", + system: "Free Form", + chapter: "0", + lastPlayed: "last spring", + sessions: 2, + region: "The Greylands", + day: "Day 3", + party: [ + { name: "Wren", short: "portrait" }, + { name: "Tor", short: "portrait" }, + ], + recap: "The road south of Hollowfell is not on any map. You have made it three days of walking. The last lantern, you are told, knows it is the last.", + }, + ], + + party: [ + { + id: "cassian", + name: "Cassian Frostbreaker", + short: "portrait", + race: "Human", + class: "Magus", + archetype: "Eldritch Scion", + alignment: "Neutral Good", + level: 3, + xp: 3880, xpMax: 5000, + hp: 24, hpMax: 24, + stats: { + str: 16, dex: 12, con: 12, int: 10, wis: 8, cha: 19, + ac: 17, flat: 16, touch: 13, + fort: 6, reflex: 4, will: 4, + bab: 2, melee: 5, ranged: 3, cmb: 5, cmd: 18, + initiative: 1, speed: 30, + }, + equipped: [ + { slot: "Head", name: "Hood of Embers", glyph: "hood" }, + { slot: "Body", name: "Studded leather", glyph: "armor" }, + { slot: "Main", name: "Longsword +1", glyph: "longsword" }, + { slot: "Off", name: "Bound focus", glyph: "focus" }, + { slot: "Boots", name: "Travel boots", glyph: "boots" }, + { slot: "Ring", name: "Ring of clarity", glyph: "ring" }, + ], + abilities: [ + { name: "Extended Magic", glyph: "spell·extend", detail: "Once per day, double the duration of one magus spell." }, + { name: "Dragon Resistances", glyph: "resist·flame", detail: "Energy resistance 10 (fire). Hide tougher than it looks." }, + { name: "Mage Armor", glyph: "ward·mage", detail: "Sheath of force grants +4 AC for 1 hr/lvl." }, + { name: "Spellstrike", glyph: "spell·strike", detail: "Channel a touch spell through a melee weapon attack." }, + ], + feats: [ + { name: "Weapon Focus (Longsword)", glyph: "swordfocus", detail: "+1 to attack rolls with longswords." }, + { name: "Combat Casting", glyph: "concentrate", detail: "+4 concentration when casting on the defensive." }, + { name: "Arcane Strike", glyph: "spellstrike", detail: "As a swift action, weapons gain +1 damage and become magic." }, + ], + skills: [ + { name: "Athletics", mod: 3 }, { name: "Mobility", mod: 1 }, + { name: "Persuasion", mod: 12 }, { name: "Perception", mod: 5 }, + { name: "Knowledge (Arcana)", mod: 6 }, { name: "Stealth", mod: 4 }, + { name: "Knowledge (World)", mod: 0 }, { name: "Trickery", mod: 1 }, + { name: "Lore (Nature)", mod: -1 }, { name: "Use Magic Device", mod: 4 }, + { name: "Lore (Religion)", mod: -1 }, { name: "Spellcraft", mod: 6 }, + ], + spells: [ + { level: 1, list: [ + { name: "Mage Armor", school: "Abj", time: "1 std", glyph: "armor·spell" }, + { name: "Magic Missile", school: "Evo", time: "1 std", glyph: "missile" }, + { name: "Shocking Grasp", school: "Evo", time: "1 std", glyph: "spark" }, + { name: "True Strike", school: "Div", time: "1 std", glyph: "eye" }, + ]}, + { level: 0, list: [ + { name: "Detect Magic", school: "Div", time: "1 std", glyph: "ward" }, + { name: "Light", school: "Evo", time: "1 std", glyph: "light" }, + { name: "Prestidigitation", school: "Univ", time: "1 std", glyph: "spark·sm" }, + ]}, + ], + proficiencies: [ + "Simple Weapons", "Martial Weapons", + "Longsword", "Rapier", "Greatsword", + "Light Armor", "Buckler", + ], + classFeatures: [ + { name: "Spell Combat", detail: "Wield a weapon in one hand while casting with the other." }, + { name: "Magus Arcana", detail: "Choose one magus arcana at 3rd level and every 3 levels thereafter." }, + ], + traits: [ + { name: "Cold-blooded", detail: "Hails from the Frostbreak peaks. Cold weather is no inconvenience." }, + { name: "Sworn", detail: "Bound by oath to the Road Wardens. May not draw a blade in their hall." }, + ], + dr: { value: "None", energy: "Fire 10" }, + lineage: "Born to a stonemason and a hedge-witch in the high town of Frostbreak. Apprenticed to the Magus circle of Restov at twelve. Took up the longsword the morning after his teacher buried his." + }, + { + id: "mira", + name: "Mira of the Inkstain", + short: "portrait", + race: "Halfling", + class: "Bard", + archetype: "Lorekeeper", + alignment: "Chaotic Good", + level: 3, xp: 3120, xpMax: 5000, hp: 18, hpMax: 22, + stats: { str: 8, dex: 16, con: 12, int: 16, wis: 12, cha: 18, ac: 15, flat: 12, touch: 14, fort: 2, reflex: 6, will: 4, bab: 2, melee: 1, ranged: 5, cmb: 1, cmd: 14, initiative: 3, speed: 20 }, + equipped: [ + { slot: "Head", name: "Bandana", glyph: "hood" }, + { slot: "Body", name: "Studded leather", glyph: "armor" }, + { slot: "Main", name: "Rapier", glyph: "rapier" }, + { slot: "Off", name: "Lute of Whispers", glyph: "lute" }, + { slot: "Boots", name: "Soft boots", glyph: "boots" }, + { slot: "Ring", name: "—", glyph: "" }, + ], + abilities: [ + { name: "Inspire Courage", glyph: "song", detail: "All allies within 30 ft gain +1 morale on attacks and saves." }, + { name: "Cantrip Repertoire", glyph: "scroll", detail: "Knows every cantrip she has ever overheard sung." }, + ], + feats: [ + { name: "Lingering Performance", glyph: "echo", detail: "Bardic performance lasts 2 rounds after stopping." }, + ], + skills: [ + { name: "Persuasion", mod: 9 }, { name: "Perception", mod: 6 }, + { name: "Stealth", mod: 9 }, { name: "Trickery", mod: 7 }, + { name: "Knowledge (Lore)", mod: 11 }, { name: "Performance", mod: 12 }, + { name: "Knowledge (Arcana)", mod: 6 }, { name: "Use Magic Device", mod: 7 }, + ], + spells: [ + { level: 1, list: [ + { name: "Charm Person", school: "Ench", time: "1 std", glyph: "heart" }, + { name: "Sleep", school: "Ench", time: "1 std", glyph: "moon" }, + { name: "Cure Light", school: "Conj", time: "1 std", glyph: "leaf" }, + ]}, + { level: 0, list: [ + { name: "Dancing Lights", school: "Evo", time: "1 std", glyph: "light·sm" }, + { name: "Mage Hand", school: "Tns", time: "1 std", glyph: "hand" }, + { name: "Ghost Sound", school: "Ill", time: "1 std", glyph: "echo·sm" }, + ]}, + ], + proficiencies: ["Simple Weapons", "Rapier", "Light Armor"], + classFeatures: [ + { name: "Versatile Performance", detail: "Use Performance ranks in place of certain skill checks." }, + { name: "Lorekeeper", detail: "Add half level to all Knowledge checks." }, + ], + traits: [ + { name: "Footloose", detail: "+1 to initiative checks. Cannot sit still for council." }, + { name: "Memory of Names", detail: "Always recalls a name once heard, whether it was offered or not." }, + ], + dr: { value: "None", energy: "None" }, + lineage: "Found, not born — the inkstain on her left hand is the only thing she has from before. Trained by the chronicler-monks of Pitax until they realised she was writing better songs than psalms." + }, + { + id: "vell", + name: "Vell Iron-Shod", + short: "portrait", + race: "Dwarf", + class: "Fighter", + archetype: "Two-Handed", + alignment: "Lawful Neutral", + level: 3, xp: 3500, xpMax: 5000, hp: 28, hpMax: 30, + stats: { str: 18, dex: 10, con: 16, int: 10, wis: 12, cha: 8, ac: 18, flat: 18, touch: 10, fort: 7, reflex: 1, will: 2, bab: 3, melee: 7, ranged: 3, cmb: 7, cmd: 17, initiative: 0, speed: 20 }, + equipped: [ + { slot: "Head", name: "Iron helm", glyph: "helm" }, + { slot: "Body", name: "Chainmail", glyph: "chain" }, + { slot: "Main", name: "Greataxe", glyph: "axe" }, + { slot: "Off", name: "—", glyph: "" }, + { slot: "Boots", name: "Iron-shod boots", glyph: "boots" }, + { slot: "Ring", name: "Ring of stamina", glyph: "ring" }, + ], + abilities: [ + { name: "Cleave", glyph: "cleave", detail: "When you fell a foe, take a free attack at an adjacent enemy." }, + { name: "Stonecunning", glyph: "rune", detail: "+2 Perception to notice unusual stonework." }, + ], + feats: [ + { name: "Power Attack", glyph: "smash", detail: "Trade attack accuracy for crushing damage." }, + { name: "Weapon Focus (Greataxe)", glyph: "axefocus", detail: "+1 to attack rolls with greataxes." }, + ], + skills: [ + { name: "Athletics", mod: 9 }, { name: "Perception", mod: 5 }, + { name: "Intimidate", mod: 5 }, { name: "Mobility", mod: 0 }, + { name: "Survival", mod: 6 }, { name: "Knowledge (Engineering)", mod: 4 }, + ], + spells: [], + proficiencies: ["All Simple", "All Martial", "Heavy Armor", "Shields"], + classFeatures: [ + { name: "Bravery", detail: "+1 to Will saves against fear." }, + { name: "Armor Training", detail: "Reduce armor check penalty by 1." }, + ], + traits: [ + { name: "Sworn-Hammer", detail: "Once swore the Sworn-Hammer oath. Has not yet broken it." }, + { name: "Iron Liver", detail: "Cannot be poisoned by mortal drink." }, + ], + dr: { value: "1/—", energy: "None" }, + lineage: "Of the Iron-Shod clan of the Five Kings. Left after a disagreement involving a hammer, a magistrate, and an unanswered question." + }, + ], + + stash: [ + { id: "i1", name: "Iron rations", type: "common", glyph: "iron rations", qty: 6, weight: "6 lb", value: "18 gp", desc: "Six days of hard road food. Required for a safe rest outside town.", properties: ["consumable"], slot: "Pack" }, + { id: "i2", name: "Lanternrest key", type: "quest", glyph: "brass key", qty: 1, weight: "—", value: "story", desc: "Warm brass key with an inn mark scratched away.", origin: "Lanternrest", properties: ["bound"], lore: "It is warm, and should not be." }, + { id: "i3", name: "Ward chalk", type: "spell", glyph: "white chalk", qty: 1, weight: "1 lb", value: "44 gp", desc: "Lets the magus sketch a one-room detection ward before resting.", properties: ["1 use"] }, + { id: "i4", name: "Broken arrow", type: "rare", glyph: "broken arrow", qty: 1, weight: "1 lb", value: "75 gp", desc: "A relic from the first Road Warden company. Mira thinks it points to a buried oath.", origin: "Lanternrest cellar", lore: "This item once belonged to an ancient Brevan. It may be of interest to an antiquities expert.", loreBy: "Linzi, scribe", properties: ["relic"] }, + { id: "i5", name: "Blue candle", type: "spell", glyph: "blue wax candle", qty: 2, weight: "0.5 lb", value: "30 gp", desc: "Burns only when a pact is being violated nearby.", properties: ["alchemical"] }, + { id: "i6", name: "Odrun toll writ", type: "quest", glyph: "stamped scroll", qty: 1, weight: "—", value: "access", desc: "A stamped writ that can open one minor gate if the seal is not questioned." }, + { id: "i7", name: "Shield oil", type: "common", glyph: "small flask", qty: 3, weight: "2 lb", value: "9 gp", desc: "One camp use. Adds polish and removes rust from road rain." }, + { id: "i8", name: "Fate die", type: "rare", glyph: "bone die", qty: 3, weight: "—", value: "table", desc: "Spend to ask the chronicle for a dramatic complication instead of a simple failure.", properties: ["table"] }, + { id: "i9", name: "Travel cloak", type: "armor", glyph: "wool cloak", qty: 1, weight: "3 lb", value: "12 gp", desc: "Weatherproof cloak with a torn Odrun lining.", slot: "Body" }, + { id: "i10", name: "Salt charm", type: "rare", glyph: "salt charm", qty: 1, weight: "—", value: "50 gp", desc: "Protects one camp watch from dream intrusion.", properties: ["1 use"] }, + { id: "i11", name: "Ink map", type: "quest", glyph: "folded map", qty: 1, weight: "—", value: "story", desc: "Map of the Lanternrest cellars. The east room is drawn twice." }, + { id: "i12", name: "Focus shard", type: "spell", glyph: "crystal shard", qty: 1, weight: "0.2 lb", value: "38 gp", desc: "Improves one detect magic roll if crushed.", properties: ["1 use"] }, + { id: "i13", name: "Longsword", type: "weapon", glyph: "longsword", qty: 1, weight: "4 lb", value: "15 gp", desc: "A reliable infantry blade. Slightly chipped near the cross-guard.", slot: "Main" }, + { id: "i14", name: "Hunting bow", type: "weapon", glyph: "shortbow", qty: 1, weight: "2 lb", value: "30 gp", desc: "Mira's, originally. Vell has it now and Mira pretends not to mind.", slot: "Main" }, + { id: "i15", name: "Iron helm", type: "armor", glyph: "iron helm", qty: 1, weight: "5 lb", value: "20 gp", desc: "Standard issue infantry helm with dents that are not its own.", slot: "Head" }, + { id: "i16", name: "Mage hood", type: "armor", glyph: "wool hood", qty: 1, weight: "1 lb", value: "10 gp", desc: "Embroidered along the inside with a script you cannot read.", slot: "Head" }, + { id: "i17", name: "Healing draught", type: "spell", glyph: "red potion", qty: 4, weight: "0.5 lb", value: "50 gp", desc: "Restores 2d4+2 HP when consumed.", properties: ["1 use"] }, + { id: "i18", name: "Antitoxin", type: "spell", glyph: "green vial", qty: 1, weight: "0.5 lb", value: "50 gp", desc: "+5 alchemical to saves vs. poison for 1 hour.", properties: ["1 use"] }, + { id: "i19", name: "Lockpicks", type: "common", glyph: "thieves' tools", qty: 1, weight: "1 lb", value: "30 gp", desc: "Mira's set. The thinnest pick has been replaced twice." }, + { id: "i20", name: "Bedroll", type: "common", glyph: "bedroll", qty: 4, weight: "5 lb", value: "1 sp", desc: "Required for a long rest outside an inn." }, + { id: "i21", name: "Tinderbox", type: "common", glyph: "tinderbox", qty: 1, weight: "1 lb", value: "5 sp", desc: "Iron, flint, and a strip of dried fungus that smells faintly of myrrh." }, + { id: "i22", name: "Hill cheese", type: "common", glyph: "wheel of cheese", qty: 1, weight: "2 lb", value: "8 sp", desc: "Heavy, hard, lasts forever. Vell prefers it to most company." }, + ], + + quests: [ + { + id: "q1", + title: "The Lanternrest", + label: "Active", + tone: "crimson", + status: "active", + region: "Outskirts of Odrun", + dateOpened: "9 Gozran", + objective: "Investigate the still inn before the party reaches Odrun Fell.", + entry: "Eleven days east of Restov the Lanternrest sits a hundred yards off the post-road, exactly where the post-roads pretend it isn't. The lantern over the door has not been lit in seven years. The roof is sound. The door is closed but the door is not locked. Cassian thinks this is the part to be afraid of.", + entries: [ + { date: "12 Gozran, dusk", text: "Mira swears she heard a floorboard inside. Vell agrees it is the kind of building floorboards happen in. We will sleep in the courtyard. Whatever is inside has the night to make itself known." }, + { date: "12 Gozran, third watch", text: "The crow on the gable did not move. Not once. Not when Linzi threw a pebble that should have flushed it." }, + ], + sketch: "the inn at dusk, crow on the gable", + objectives: [ + { text: "Reach the Lanternrest courtyard.", done: true, note: "Arrived at dusk on the 12th." }, + { text: "Speak with whomever the inn keeps.", done: false }, + { text: "Discover what is in the east cellar.", done: false, note: "The map is drawn twice. Linzi is bothered by this." }, + { text: "Survive the night.", done: false }, + ], + npcs: [ + { name: "The Crow", role: "watcher · unmoving", short: "raven" }, + { name: "Linzi", role: "scribe · party", short: "portrait" }, + ], + reward: "1200 XP · 1 relic claim", + }, + { + id: "q2", + title: "Gate of Tines", + label: "Active", + tone: "", + status: "active", + region: "Odrun Fell", + dateOpened: "5 Gozran", + objective: "Find why the city gate is sealed and who controls the toll road.", + entry: "The gate of Tines has not opened to a caravan in two months. The toll-keeper of the south road took our writ, looked at it for longer than was polite, then handed it back without a stamp. He said the seal was correct. He did not say what the problem was.", + entries: [], + sketch: "the gate of tines, sealed", + objectives: [ + { text: "Speak with the toll-keeper a second time.", done: false }, + { text: "Find an alternate path through the fell.", done: false }, + ], + npcs: [ + { name: "Toll-keeper Olwen", role: "south gate · uneasy", short: "portrait" }, + ], + reward: "850 XP", + }, + { + id: "q3", + title: "Debt of the Shrine", + label: "Active", + tone: "", + status: "active", + region: "Thorn River", + dateOpened: "2 Gozran", + objective: "The road shrine wants a vow, a coin, or a remembered name.", + entry: "The shrine on the Thorn Road has stood since before the maps were drawn. It asks for one of three. Vell offered a coin. Vell would. The shrine did not refuse but did not seem satisfied. Mira wants to come back with a name.", + objectives: [ + { text: "Decide what the shrine is owed.", done: false }, + { text: "Return to the shrine before the next new moon.", done: false }, + ], + npcs: [{ name: "The Shrine", role: "older than the road", short: "stone" }], + reward: "1 boon · DM's discretion", + }, + { + id: "q4", + title: "The Ferryman's Tab", + label: "Resolved", + tone: "emerald", + status: "complete", + region: "Restov Crossing", + dateOpened: "27 Pharast", + objective: "Settle the ferryman's tab before leaving Restov.", + entry: "Three silver. Vell tried to pay in copper, which the ferryman declined as a matter of principle. We are square. We will not be ferried again, but we are square.", + objectives: [{ text: "Pay the ferryman.", done: true }], + npcs: [], + reward: "200 XP", + }, + { + id: "q5", + title: "Whispers at the Saltwell", + label: "Rumor", + tone: "", + status: "rumor", + region: "Whitebridge", + dateOpened: "—", + objective: "Travellers from Whitebridge mention something singing in the saltwell at dusk.", + entry: "Heard third-hand at three different inns. Songs sung in a language nobody in Whitebridge speaks. The miller's daughter is missing. Or the miller never had a daughter. The accounts vary.", + objectives: [{ text: "Visit Whitebridge.", done: false }], + npcs: [], + reward: "—", + }, + ], + + locations: [ + { id: "lanternrest", name: "Lanternrest", x: 32, y: 240, region: "Outskirts of Odrun", distance: "0 leagues", travel: "you are here", current: true, discovered: true, visited: true, + description: "An inn that should not still be standing. The lantern above the door has not been lit in seven years. The crow on the gable has not moved in three.", + tags: ["mystery", "rest", "danger"], short: "inn at dusk", connections: ["thornford", "olegs"] }, + { id: "thornford", name: "Thorn Ford", x: 50, y: 280, region: "Thorn River", distance: "1 league", travel: "half a day on foot", discovered: true, visited: true, + description: "A river crossing too shallow for a ferry, too deep for cartwheels in flood. Worn boots and patient feet.", tags: ["road"], short: "ford at dawn", connections: ["lanternrest", "templeoftheelk"] }, + { id: "olegs", name: "Oleg's Trading Post", x: 75, y: 120, region: "North Outskirts", distance: "3 leagues", travel: "two days, by road", discovered: true, visited: true, + description: "A trading post hard against the cliff. Oleg's, by name and by temperament. The eastern wall has more spear-marks than the others.", tags: ["rest", "trade"], short: "trading post", connections: ["lanternrest", "oaks"] }, + { id: "templeoftheelk", name: "Temple of the Elk", x: 35, y: 420, region: "Stagwood", distance: "4 leagues", travel: "three days", discovered: true, visited: false, + description: "A shrine to the Elk-That-Was. The antlers are tied to the roof with red wool. The wool is new. The antlers are not.", tags: ["holy"], short: "antlered shrine", connections: ["thornford", "abandonedhut"] }, + { id: "oaks", name: "Oak-That-Strayed", x: 50, y: 80, region: "Borderwood", distance: "5 leagues", travel: "three days", discovered: true, visited: false, + description: "A single oak in a field that should be forest. The locals say it walked there. The locals also say not to ask whose oak it was.", tags: ["mystery"], short: "lone oak", connections: ["olegs", "abandonedhut"] }, + { id: "abandonedhut", name: "Abandoned Hut", x: 20, y: 200, region: "Thorn River", distance: "1.5 leagues", travel: "a day", discovered: true, visited: true, + description: "A trapper's hut, abandoned. The fire-pit is still warm. It has been warm every time we have visited.", tags: ["mystery"], short: "hut in clearing", connections: ["templeoftheelk", "oaks"] }, + { id: "anciemtomb", name: "Ancient Tomb", x: 88, y: 380, region: "Old Hills", distance: "6 leagues", travel: "four days", discovered: true, visited: false, + description: "A barrow with the door long broken. The wind that comes out of it does not match the wind outside.", tags: ["danger"], short: "open barrow", connections: ["thornford"] }, + { id: "techleague", name: "Technic League Camp", x: 45, y: 510, region: "Stagwood", distance: "7 leagues", travel: "five days", discovered: false, visited: false, + description: "Reported, not yet seen. The smoke is the wrong colour.", tags: ["danger"], short: "smoke, wrong color", connections: ["templeoftheelk"] }, + ], + + tableLog: [ + { kind: "narration", text: "Day twelve. The road to Odrun has been quiet since the ford, and quiet is the sort of thing Vell stops trusting after the second hour. You make the Lanternrest at the failing of the light. The lantern above the door is, as promised, unlit. The crow on the gable is, as promised, present." }, + { kind: "action", who: "Mira", text: "approaches the door, palm flat against the wood, listening." }, + { kind: "roll", who: "Mira", text: "rolls d20: 17 + 6 Perception = 23" }, + { kind: "narration", text: "She hears, very faintly, the shifting of someone's weight on a wooden floor. Not approaching. Not retreating. Holding still, the way you hold still when you do not want to be heard. She steps back from the door without taking her eyes from it." }, + { kind: "dialog", who: "Mira", text: "There's someone in there. They know we know." }, + { kind: "action", who: "Cassian", text: "lights the brazier in the courtyard. Slowly." }, + ], +}; + +window.INITIAL_STATE = INITIAL_STATE; diff --git a/viewer/openworlds/index.html b/viewer/openworlds/index.html new file mode 100644 index 00000000..ff95466e --- /dev/null +++ b/viewer/openworlds/index.html @@ -0,0 +1,42 @@ + + + + + + Open Worlds — Codex + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/viewer/openworlds/screen-acts.jsx b/viewer/openworlds/screen-acts.jsx new file mode 100644 index 00000000..c17bb3de --- /dev/null +++ b/viewer/openworlds/screen-acts.jsx @@ -0,0 +1,305 @@ +/* Screen: Acts — chronicle progression, current act, memorable moments */ + +function ScreenActs({ onNavigate, state, setState }) { + const [selectedAct, setSelectedAct] = React.useState(ACTS.find((a) => a.current) || ACTS[0]); + + return ( +
+ + {/* LEFT — Timeline of acts */} + +
The Chronicle, in
+

Acts

+ + +
+ {/* Spine */} +
+ + {ACTS.map((a, i) => ( + setSelectedAct(a)} + /> + ))} +
+ + + {/* RIGHT — Act detail */} + + + +
+ ); +} + +function ActSpineRow({ act, isLast, selected, onSelect }) { + const tone = act.status === "complete" ? "var(--emerald)" : + act.status === "current" ? "var(--gold-glow)" : + act.status === "future" ? "var(--ink-500)" : + "var(--b-400)"; + return ( +
+ {/* Wax-seal node */} + + + +
+ ); +} + +function ActDetail({ act }) { + if (act.status === "future") { + return ( +
+
+
?
+

Not yet written

+

+ The chronicle holds the page in reserve. What it will say depends on the road taken. +

+
+
+ ); + } + return ( +
+
Act {act.numeral}{act.status === "current" ? " · in progress" : ""}
+

{act.name}

+
{act.subtitle}
+ + + + {act.illustration && ( + + )} + +

{act.synopsis}

+ +
+ + + +
+ + + + Key choices made + {act.choices.length === 0 ? ( +
No turning points yet. The road still has shape to give.
+ ) : ( +
+ {act.choices.map((c, i) => ( +
+
+ + {c.title} + + {c.when} +
+
{c.body}
+ {c.consequence && ( +
+ → {c.consequence} +
+ )} +
+ ))} +
+ )} + + + + Moments the chronicle remembers +
+ {act.memories.map((m, i) => ( +
+ +
+ "{m.text}" +
+
+ {m.when} +
+
+ ))} +
+ + {act.partyAtStart && ( + <> + + Who walked this act +
+ {act.partyAtStart.map((p, i) => ( +
+ +
{p.name}
+
+ ))} +
+ + )} +
+ ); +} + +const ACTS = [ + { + id: "1", + numeral: "I", + name: "Restov, in Pharast", + subtitle: "Of being hired, and of leaving.", + status: "complete", + outcome: "South", + duration: "Pharast · 9 days", + beginDate: "27 Pharast", + endDate: "5 Gozran", + heroLevel: "1 → 2", + illustration: "Restov gates at sunrise", + synopsis: "The party assembled in Restov by separate roads and the same purpose, which none of them named the same way. Cassian was sworn to the Wardens. Mira was paid in advance. Vell came because Vell had run out of reasons to stay. Linzi came because Linzi was always going to come. They left through the south gate on the third morning, and the gate-keeper did not look at them twice.", + choices: [ + { title: "Took the Warden writ", when: "29 Pharast", body: "Cassian accepted the contract to investigate the Lanternrest before reaching Odrun. The party agreed to honour it even if it meant the longer road.", tone: "good", consequence: "Warden reputation +20. The Stag Lord's company watches you now." }, + { title: "Paid the ferryman", when: "1 Gozran", body: "Vell tried to pay the ferry in copper. The ferryman declined. The party paid in silver.", tone: "neutral", consequence: "No bridges burnt at Restov Crossing." }, + ], + memories: [ + { sketch: "the Warden hall at dawn", text: "Cassian set his sword on the table without drawing it. The Warden Olwen nodded once.", when: "27 Pharast" }, + { sketch: "south gate at sunrise", text: "The gate-keeper did not look up. Mira found this an excellent omen, and Linzi disagreed.", when: "29 Pharast" }, + ], + partyAtStart: [ + { name: "Cassian", short: "C·portrait" }, + { name: "Mira", short: "M·portrait" }, + { name: "Vell", short: "V·portrait" }, + { name: "Linzi", short: "L·portrait" }, + ], + }, + { + id: "2", + numeral: "II", + name: "Of Toll Roads and Quiet Inns", + subtitle: "The long road to Odrun.", + status: "current", + duration: "Gozran · 12 days so far", + beginDate: "5 Gozran", + endDate: null, + heroLevel: "3", + illustration: "the Lanternrest at dusk, crow on the gable", + synopsis: "Twelve days on the south road. The Thorn Ford crossed; the gate of Tines refused; the Lanternrest reached at dusk on the twelfth evening, and the lantern over the door not lit, and the crow on the gable not moved. The party makes camp in the courtyard tonight. Tomorrow the chronicle decides whether the door opens to a knock or to a shoulder.", + choices: [ + { title: "Spared the bandit Falgrim's ear", when: "9 Gozran", body: "Vell took the ear and let the man ride south. The party agreed Vell knew what he was doing. Vell did not say.", tone: "ill", consequence: "Falgrim is rumoured to be raising men against you. Reputation with the Stag Lord's company drops further. Stag rep -8." }, + { title: "Did not pay the gate-toll at Tines", when: "5 Gozran", body: "Toll-keeper Olwen took our writ, looked at it, and handed it back unstamped. The party found another road.", tone: "good", consequence: "Gate of Tines remains closed to us. Warden reputation neutral; Olwen's standing unclear." }, + ], + memories: [ + { sketch: "Mira at the inn door", text: "She heard the floorboard. The floorboard heard her hearing it. Nothing moved.", when: "12 Gozran, dusk" }, + { sketch: "the brass key in firelight", text: "Warm against the palm. Should not have been warm.", when: "12 Gozran, third watch" }, + ], + partyAtStart: [ + { name: "Cassian", short: "C·portrait" }, + { name: "Mira", short: "M·portrait" }, + { name: "Vell", short: "V·portrait" }, + { name: "Linzi", short: "L·portrait" }, + ], + }, + { + id: "3", + numeral: "III", + name: "Beneath the Lanternrest", + subtitle: "What waits in the eastern hallway.", + status: "future", + duration: null, + beginDate: null, + heroLevel: null, + synopsis: null, + choices: [], + memories: [], + }, + { + id: "4", + numeral: "IV", + name: "Of Salt and the Stag", + subtitle: "An accounting of debts.", + status: "future", + duration: null, + beginDate: null, + heroLevel: null, + synopsis: null, + choices: [], + memories: [], + }, + { + id: "5", + numeral: "V", + name: "The Road's End", + subtitle: "Or its turning.", + status: "future", + duration: null, + beginDate: null, + heroLevel: null, + synopsis: null, + choices: [], + memories: [], + }, +]; + +Object.assign(window, { ScreenActs, ActSpineRow, ActDetail, ACTS }); diff --git a/viewer/openworlds/screen-bestiary.jsx b/viewer/openworlds/screen-bestiary.jsx new file mode 100644 index 00000000..3dcfea4d --- /dev/null +++ b/viewer/openworlds/screen-bestiary.jsx @@ -0,0 +1,398 @@ +/* Screen: Bestiary / Codex — encounters, lore, NPCs */ + +function ScreenBestiary({ onNavigate, state, setState }) { + const [tab, setTab] = React.useState("creatures"); + const [selected, setSelected] = React.useState(BESTIARY[0]); + const [filter, setFilter] = React.useState(""); + + const entries = tab === "creatures" ? BESTIARY : tab === "people" ? PEOPLE : LORE; + const filtered = entries.filter((e) => !filter || e.name.toLowerCase().includes(filter.toLowerCase())); + + React.useEffect(() => { + if (filtered.length > 0 && !filtered.find((e) => e.id === selected?.id)) { + setSelected(filtered[0]); + } + }, [tab, filter]); + + return ( +
+ + {/* LEFT — index */} + +
Encyclopaedia of
+

The Marches

+ + +
+ {[ + { id: "creatures", label: "Creatures" }, + { id: "people", label: "Persons" }, + { id: "lore", label: "Lore" }, + ].map((t) => ( + + ))} +
+ + setFilter(e.target.value)} + placeholder="Search the index…" + style={{ ...window.inkInput, fontSize: 14, padding: "8px 12px" }} + /> + +
+ {filtered.map((e) => ( + + ))} +
+ +
+ {filtered.length} known · {BESTIARY.filter((e) => e.unknown).length + PEOPLE.filter((e) => e.unknown).length} rumoured +
+
+ + {/* RIGHT — entry */} + {selected ? :
Nothing selected.
} +
+ ); +} + +function BestiaryEntry({ entry, tab }) { + if (entry.unknown) { + return ( + +
+
?
+

Not yet known

+

+ The chronicle has heard rumour of this, but has not yet seen it with its own eyes. Investigate, encounter, or be told to fill this page. +

+
+
+ ); + } + return ( + +
+
+ + {entry.cr && ( +
+
Challenge
+
{entry.cr}
+
+ )} +
+ +
+
+ {tab === "creatures" ? entry.alignment + " · " + entry.size + " " + entry.kind : + tab === "people" ? entry.role : + "Lore entry"} +
+

{entry.name}

+
{entry.subtitle}
+ + + + {tab === "creatures" && entry.stats && ( +
+ {Object.entries(entry.stats).map(([k, v]) => ( +
+
{k.toUpperCase()}
+
{v}
+
+ ))} +
+ )} + + {tab === "creatures" && ( +
+ + + + + + +
+ )} + +

+ {entry.body} +

+ + {entry.tactics && ( + <> + + {tab === "creatures" ? "Tactics" : "What is known"} +

{entry.tactics}

+ + )} + + {entry.loot && ( + <> + + Spoils +
+ {entry.loot.map((l) => {l})} +
+ + )} + + {entry.marginalia && ( + <> + +
Marginalia
+
+ "{entry.marginalia}" +
+ — {entry.marginaliaBy || "Linzi, scribe"} +
+
+ + )} +
+
+
+ ); +} + +const BESTIARY = [ + { + id: "kobold-skirmisher", + name: "Kobold Skirmisher", + short: "kobold", + short_descriptor: "humanoid · scout", + subtitle: "Trouble of the small and patient kind.", + alignment: "Lawful Evil", + size: "Small", + kind: "humanoid (reptilian)", + cr: "1/2", + hd: "1d10+1", + ac: "16", + speed: "30 ft", + senses: "Darkvision 60", + save: "Fort +1 · Ref +3 · Will -1", + stats: { str: 9, dex: 13, con: 10, int: 10, wis: 9, cha: 8 }, + encounteredAt: "Thorn Ford", + body: "Kobolds of the southern fens are smaller than their cousins to the east but twice as patient. They will not engage a target they have not measured against three contingencies and a snare. Cassian remarked, after their first ambush, that they were not so much fighting as auditing.", + tactics: "Open with shortspear and crossbow from elevation. If the player engages a flanker, the principals retreat to the second prepared snare, never the first. Will not pursue past the treeline. Will absolutely use the river.", + loot: ["shortspear", "crossbow", "rags", "1d6 sp", "trap diagram"], + marginalia: "I do not think they hate us. I think they are simply doing their jobs." + }, + { + id: "bog-strider", + name: "Bog Strider", + short: "strider", + short_descriptor: "fey · ambush", + subtitle: "Long-legged thing of the second-shallowest water.", + alignment: "Neutral", + size: "Large", + kind: "fey (aquatic)", + cr: "4", + hd: "5d8+15", + ac: "18", + speed: "20 ft, swim 40 ft", + senses: "Low-light, tremorsense 60", + save: "Fort +5 · Ref +4 · Will +6", + stats: { str: 16, dex: 13, con: 16, int: 8, wis: 14, cha: 6 }, + encounteredAt: "Thorn Ford", + body: "A creature of stilt-legs and patience, the bog strider hunts where the water is exactly knee-deep. You will not see it until it is at hand, and you will not hear it because it is always already there.", + tactics: "Initiates with grapple, drags to mid-river, releases the lighter party member at the deepest stride. Will not attack on dry land. Disengages if any party member casts light on its head.", + loot: ["bog-pearls (3d4)", "fey ichor"], + marginalia: "It blinked at me. Once. I do not know what was meant by it." + }, + { + id: "stag-lord-bandit", + name: "Stag Lord Bandit", + short: "bandit", + short_descriptor: "humanoid · brigand", + subtitle: "Tax-collector by his own definition.", + alignment: "Chaotic Evil", + size: "Medium", + kind: "human", + cr: "1", + hd: "2d10+4", + ac: "15", + speed: "30 ft", + senses: "—", + save: "Fort +4 · Ref +3 · Will +0", + stats: { str: 14, dex: 12, con: 14, int: 8, wis: 10, cha: 11 }, + encounteredAt: "Lanternrest courtyard", + body: "The Stag Lord's bandits wear what they can steal and call it livery. They count themselves an army and a kingdom; the woods of the Stolen Marches indulge them. The leadership is, by Svetlana's account, considerably worse.", + tactics: "Bandits open with crossbow from across the courtyard, advance to longsword if the player closes. They will not break and run on their own; the sergeant breaks first and the company follows.", + loot: ["longsword", "leather", "shortbow", "3d6 sp", "stamped writ (forged)"], + marginalia: "The Stag Lord pays them in salt. Not gold. Salt. Mira says this matters." + }, + { + id: "owlbear", + name: "Owlbear", + short: "owlbear", + short_descriptor: "magical beast", + subtitle: "The most-feared mistake in the Marches.", + alignment: "Neutral", + size: "Large", + kind: "magical beast", + cr: "4", + hd: "5d10+20", + ac: "15", + speed: "30 ft", + senses: "Low-light, scent", + save: "Fort +8 · Ref +4 · Will +2", + stats: { str: 21, dex: 12, con: 19, int: 2, wis: 12, cha: 10 }, + encounteredAt: "Rumoured · Old Hills", + body: "An owlbear does not stop. There is no documented case of an owlbear breaking from a fight to do anything other than wedge itself between something and its eggs. It will not be reasoned with. It cannot be reasoned with. The only good owlbear is a den entrance that has not seen footprints in a week.", + tactics: "Closes. Grapples. Does not let go. Pursue is the only verb it knows.", + loot: ["owlbear pelt", "owlbear talons", "trauma"], + marginalia: "Sketch from a distance. Always from a distance." + }, + { + id: "spectral-watcher", + name: "Spectral Watcher", + short: "spectre", + short_descriptor: "undead · incorporeal", + subtitle: "What waits in the Lanternrest, possibly.", + alignment: "?", + size: "Medium", + kind: "undead (incorporeal)", + cr: "?", + hd: "?", + ac: "?", + speed: "fly", + senses: "?", + save: "?", + stats: { str: "—", dex: "?", con: "—", int: "?", wis: "?", cha: "?" }, + encounteredAt: "Unconfirmed", + body: "Reported by Mira, half-confirmed by the temperature of the hallway. The Lanternrest stands too long for a building maintained by no one. Whatever maintains it does so on terms that exclude lit lanterns and unbroken sleep.", + tactics: "Unknown. Speculation: drains warmth before drains anything more vital. Does not appear to enter the courtyard.", + marginalia: "I will not call it a ghost until it gives me reason. I will not call it nothing, either." + }, + { id: "unk1", name: "??????", short: "?", short_descriptor: "rumoured · old hills", unknown: true }, + { id: "unk2", name: "??????", short: "?", short_descriptor: "rumoured · saltwell", unknown: true }, +]; + +const PEOPLE = [ + { + id: "svetlana", + name: "Svetlana Leveton", + short: "S·portrait", + short_descriptor: "trader's wife · friend", + subtitle: "Of Oleg's Trading Post.", + role: "Ally · Quest-giver", + encounteredAt: "Oleg's Trading Post", + body: "Married to Oleg, who would not say his own name kindly. She runs the trading post and the silence of the trading post in equal measure. She wanted a partner against the bandits and met you instead. She has chosen, for the moment, to consider this an improvement.", + tactics: "Trusts no one quickly. Trusts permanently once she has. Pays in stew and beds, never in coin.", + loot: ["intelligence on bandit raids", "free first watch"], + marginalia: "Her hands do not stop moving. I have not yet seen them rest." + }, + { + id: "linzi", + name: "Linzi", + short: "L·portrait", + short_descriptor: "halfling · scribe · party", + subtitle: "Chronicler of the Long Road.", + role: "Party · NPC", + encounteredAt: "Restov, prologue", + body: "Halfling, chronicler, refuses the word 'bard.' Writes the chronicle by candle when there is candle and by memory when there is not. Is in some real sense the reason this engine works.", + tactics: "Will not be left out of dialogue. Will write down your line whether you said it well or not.", + marginalia: "I am not a character. I am a chronicler. — Linzi" + }, + { + id: "stag-lord", + name: "The Stag Lord", + short: "lord·portrait", + short_descriptor: "antagonist · uncrowned", + subtitle: "King of nothing, by his own coronation.", + role: "Antagonist", + encounteredAt: "Not yet", + body: "Nobody who has come back has said. The bandits speak his name like a prayer they don't believe in. The toll-keepers of Odrun pretend the name does not affect them.", + tactics: "Unknown. Pays in salt. Does not pay in promises. Does not lose deserters.", + marginalia: "I do not think this is a man. I think it is a man with company." + }, + { + id: "oleg", + name: "Oleg Leveton", + short: "O·portrait", + short_descriptor: "trader · uneasy", + subtitle: "Of his post, by his post.", + role: "Ally", + encounteredAt: "Oleg's Trading Post", + body: "Trader by trade and by temperament. Would have been a miller if the river had favoured him. The eastern wall has more spear-marks than the others because Oleg stands at the eastern wall.", + tactics: "Will accept your help. Will not thank you for it. Will, much later, pretend it was his idea.", + marginalia: "Oleg pretends the hating is what saved him. — Svetlana" + }, + { id: "unk-p1", name: "??????", short: "?", short_descriptor: "the toll-keeper of Tines", unknown: true }, + { id: "unk-p2", name: "??????", short: "?", short_descriptor: "the singer at Saltwell", unknown: true }, +]; + +const LORE = [ + { + id: "stolen-marches", + name: "The Stolen Marches", + short: "borderland", + short_descriptor: "borderland · contested", + subtitle: "Where the maps and the road disagree.", + body: "A borderland between Brevoy and the older claims of the south. So named because every kingdom that has held it has lost it, and because every map drawn of it has been drawn by someone who has been lied to.", + tactics: "The marches do not respect a single law. Travel by post-road if you must travel by anything. Do not trust a shrine that has not been re-built in the last twenty years. Trust no inn that has not been lit in seven.", + marginalia: "The marches are not stolen from anyone. The marches steal." + }, + { + id: "fate-die", + name: "The Fate Die", + short: "die", + short_descriptor: "system · mechanic", + subtitle: "What the chronicle hands you when you have already failed.", + body: "A bone die, kept by the chronicle and given to each hero at the start of every chapter. Spend a fate die to ask the world for a complication, not a setback. The complication is binding. The setback would have been worse.", + tactics: "Spend early. Spend on the road, not in the den. Fate dice do not refresh between chapters. Linzi keeps the count.", + marginalia: "I have spent three. I had five. The other two were Mira's. — Cassian" + }, + { + id: "road-wardens", + name: "Road Wardens of Restov", + short: "order", + short_descriptor: "knightly order", + subtitle: "Sword-companies of the southern post-roads.", + body: "The Wardens keep the post-roads between Restov and Tines passable, by patrol, by stone, and by occasional necessary brutality. They are sworn not to draw a blade in a Warden hall. Cassian is sworn. Cassian has not drawn a blade in a Warden hall.", + tactics: "Will hire competent strangers if their own roster is short. Pay is fair. Loyalty is not requested. Honour is — and is verified.", + marginalia: "I have been a Warden for seven years. I have served the order for one of them. — Cassian" + }, + { + id: "lanternrest", + name: "The Lanternrest, history of", + short: "inn", + short_descriptor: "place · history", + subtitle: "Why the lantern is not lit.", + body: "The Lanternrest was, before the war of the Stolen Marches, an inn for couriers and clerics travelling the south road to Odrun. After the war the inn was kept by a man named Hessan, who is not known to have died. The lantern over the door went unlit on the seventh evening of the third Gozran following, and has not been lit since.", + tactics: "Approach in the afternoon. Make camp in the courtyard. Do not enter the eastern hallway. Do not light the lantern.", + marginalia: "Hessan is the part to be afraid of." + }, +]; + +Object.assign(window, { ScreenBestiary, BestiaryEntry, BESTIARY, PEOPLE, LORE }); diff --git a/viewer/openworlds/screen-character.jsx b/viewer/openworlds/screen-character.jsx new file mode 100644 index 00000000..5a5a974c --- /dev/null +++ b/viewer/openworlds/screen-character.jsx @@ -0,0 +1,597 @@ +/* Screen: Character Sheet — dense, codex/sourcebook style */ + +function ScreenCharacter({ onNavigate, state, setState }) { + const [active, setActive] = React.useState(state.party[0].id); + const [tab, setTab] = React.useState("abilities"); + const [restOpen, setRestOpen] = React.useState(false); + const toast = window.useToast ? window.useToast() : (() => {}); + + const hero = state.party.find((p) => p.id === active); + + return ( +
+ + {/* LEFT: party rail */} + + Roster + {state.party.map((p) => ( + + ))} + +
+ +
Tabs
+ {["abilities", "skills", "spells", "feats"].map((t) => ( + + ))} + +
+ + onNavigate("map", { openCamp: true })} style={{ width: "100%" }}> + Rest & Prepare + +
+ + {/* RIGHT: sheet */} +
+ {/* Hero header card */} + +
+ +
+
{hero.alignment}
+

{hero.name}

+
+ {hero.race} · {hero.class} · {hero.archetype} +
+ +
+ HP {hero.hp}/{hero.hpMax} + AC {hero.stats.ac} + Lv {hero.level} + XP {hero.xp.toLocaleString()} / {hero.xpMax.toLocaleString()} + Speed {hero.stats.speed} ft +
+ + {/* XP bar */} +
+
+
+
+
+
+ + {/* Ability scores column */} +
+ {["STR","DEX","CON","INT","WIS","CHA"].map((s) => ( + + ))} +
+
+ + + {/* Lower split: stats blocks + tab content */} +
+ + {/* Combat block */} + + Combat + +
Attack
+
+ + + + + + +
+ + + +
Defense
+
+ + + + + + +
+ + + +
Equipped
+
+ {hero.equipped.map((it) => ( +
+ +
+
{it.slot}
+
+ {it.name} +
+
+
+ ))} +
+
+ + {/* Center tab content */} + + {tab === "abilities" && } + {tab === "skills" && } + {tab === "spells" && } + {tab === "feats" && } + + + {/* Right column: lineage + traits */} + + Lineage +

+ {hero.lineage} +

+ + + + Traits +
+ {hero.traits.map((t) => ( +
+
+ {t.name} +
+
{t.detail}
+
+ ))} +
+ + + + Damage Reduction +
+ + +
+
+
+
+ + {restOpen && setRestOpen(false)} toast={toast} setState={setState} />} +
+ ); +} + +function RestPrepareModal({ hero, onClose, toast, setState }) { + const [step, setStep] = React.useState("rest"); + const [restType, setRestType] = React.useState("long"); + const [prepared, setPrepared] = React.useState({}); + + const availableSpells = hero.spells || []; + const slots = { 0: 4, 1: 3, 2: 1 }; + + const toggleSpell = (lv, name) => { + const cur = prepared[lv] || []; + const max = slots[lv] || 0; + if (cur.includes(name)) { + setPrepared({ ...prepared, [lv]: cur.filter((n) => n !== name) }); + } else if (cur.length < max) { + setPrepared({ ...prepared, [lv]: [...cur, name] }); + } + }; + + const completeRest = () => { + toast({ + kind: "rest", + eyebrow: restType === "long" ? "Long rest" : "Short rest", + title: hero.name + " is restored", + body: restType === "long" ? "HP and spell slots refreshed. The watch goes to Mira." : "Some wounds knit. Spell slots untouched.", + }); + setStep("prep"); + }; + + const completePrep = () => { + const count = Object.values(prepared).reduce((s, l) => s + l.length, 0); + toast({ + kind: "item", + eyebrow: "Spellbook", + title: hero.name + " prepares " + count + " spell" + (count !== 1 ? "s" : ""), + body: "The chronicle records the binding. They may be unbound at next rest.", + }); + onClose(); + }; + + return ( +
+
e.stopPropagation()} style={{ width: 720, maxWidth: "92vw", maxHeight: "88vh", overflow: "auto" }}> + + {step === "rest" ? ( + <> +
The Camp
+

Rest & Recover

+ + +

+ {hero.name} finds a quiet hour. The chronicle pauses with them. Choose what kind of rest to claim from the road. +

+ +
+ setRestType("short")} + title="Short Rest" + hand="One hour. A second wind." + body="Restores hit points spent from class features. Spell slots and abilities remain spent. Watch is not required." + cost="1 hour · no rations" + /> + setRestType("long")} + title="Long Rest" + hand="Eight hours. The whole road forgiven." + body="Full HP. All spell slots restored. Abilities refresh. Watch order required; one in four sleeps light." + cost="8 hours · 1 ration each" + /> +
+ + + + Watch order +
+ {["Mira", "Cassian", "Vell"].map((p, i) => ( +
+
Watch {i + 1}
+
{p}
+
+ ))} +
+ +
+ Not yet + Make camp +
+ + ) : ( + <> +
The Spellbook
+

Prepare Today's Spells

+ + +

+ {hero.name} reads by the dying fire. Choose what will be at hand when the day breaks. Unchosen spells remain bound to the page. +

+ + {availableSpells.map((group) => { + const cur = prepared[group.level] || []; + const max = slots[group.level] || 0; + if (max === 0) return null; + return ( +
+ {cur.length} / {max} prepared + }> + Level {group.level} + +
+ {group.list.map((sp) => { + const isPrepared = cur.includes(sp.name); + const canPrep = cur.length < max; + return ( + + ); + })} +
+
+ ); + })} + + + +
+ + {Object.values(prepared).reduce((s, l) => s + l.length, 0)} bound to the day. + +
+ Close book + Seal the choices +
+
+ + )} +
+
+
+ ); +} + +function RestCard({ selected, onClick, title, hand, body, cost }) { + return ( + + ); +} + +function AbilityScore({ label, value, mod }) { + return ( +
+
{label}
+
+ {value} +
+
= 0 ? "var(--emerald)" : "var(--crimson)", marginTop: 2 }}> + {mod >= 0 ? "+" : ""}{mod} +
+
+ ); +} + +function StatLine({ k, v }) { + return ( +
+ {k} + {v} +
+ ); +} + +function AbilitiesTab({ hero }) { + return ( +
+ Special Abilities +
+ {hero.abilities.map((a) => ( + + ))} +
+ + + + Feats +
+ {hero.feats.map((f) => ( + + ))} +
+
+ ); +} + +function AbilityCard({ a }) { + return ( + } side="top"> +
+ +
+
+ {a.name} +
+
{a.detail}
+
+
+
+ ); +} + +function FeatRow({ f }) { + return ( + } side="top"> +
+ +
+ + {f.name} + + — {f.detail} +
+
+
+ ); +} + +function SkillsTab({ hero }) { + return ( +
+ Skills +
+ {hero.skills.map((s) => ( +
0 ? "rgba(176,141,87,0.1)" : "transparent", + boxShadow: "inset 0 -1px 0 rgba(140,100,60,0.15)", + }}> + {s.name} + = 0 ? "var(--ink-900)" : "var(--crimson)" }}> + {s.mod >= 0 ? "+" : ""}{s.mod} + +
+ ))} +
+
+ ); +} + +function SpellsTab({ hero }) { + return ( +
+ Spellbook +
Slots per level — Magus
+
+ {[0,1,2,3,4,5,6,7,8,9].map((lv) => ( +
+
Lv {lv}
+
+ {lv === 0 ? "∞" : lv <= 2 ? Math.max(0, 4 - lv) : "—"} +
+
+ ))} +
+ + {hero.spells.map((group) => ( +
+ Level {group.level} +
+ {group.list.map((sp) => ( +
+ +
+
+ {sp.name} +
+
{sp.school} · {sp.time}
+
+
+ ))} +
+
+ ))} +
+ ); +} + +function FeatsTab({ hero }) { + return ( +
+ Weapon Proficiency +
    + {hero.proficiencies.map((p) => (
  • {p}
  • ))} +
+ + Class Features +
+ {hero.classFeatures.map((c) => ( +
+
{c.name}
+
{c.detail}
+
+ ))} +
+
+ ); +} + +Object.assign(window, { ScreenCharacter, AbilityScore, StatLine, AbilitiesTab, SkillsTab, SpellsTab, FeatsTab, AbilityCard, FeatRow, RestPrepareModal, RestCard }); diff --git a/viewer/openworlds/screen-combat.jsx b/viewer/openworlds/screen-combat.jsx new file mode 100644 index 00000000..a01f834d --- /dev/null +++ b/viewer/openworlds/screen-combat.jsx @@ -0,0 +1,402 @@ +/* Screen: Combat Encounter — tactical grid, initiative, action bar */ + +function ScreenCombat({ onNavigate, state, setState }) { + const [selectedToken, setSelectedToken] = React.useState("cassian"); + const [activeAction, setActiveAction] = React.useState(null); + const [round, setRound] = React.useState(2); + const [ap, setAp] = React.useState({ standardUsed: false, moveUsed: false, swiftUsed: false }); + const toast = window.useToast ? window.useToast() : (() => {}); + const [log, setLog] = React.useState([ + { t: "round", text: "Round 1 — initiative." }, + { t: "act", who: "Mira", text: "moves to G4 and looses a crossbow bolt at the south bandit." }, + { t: "roll", text: "Attack roll: d20+5 = 19. Hit." }, + { t: "dmg", text: "1d8+2 piercing = 6. Bandit south staggered." }, + { t: "act", who: "Cassian", text: "casts shocking grasp, charges the courtyard." }, + { t: "roll", text: "Concentration check: 18. Spell held." }, + { t: "round", text: "Round 2 — initiative continues." }, + { t: "act", who: "Bandit North", text: "advances. Shortbow at Vell. Misses (13 vs AC 18)." }, + ]); + + const tokens = TOKENS; + const selected = tokens.find((t) => t.id === selectedToken); + + const onMove = (gx, gy) => { + if (!activeAction || activeAction !== "move") return; + selected.x = gx; + selected.y = gy; + setLog((l) => [...l, { t: "act", who: selected.name, text: `moves to ${String.fromCharCode(64 + gx)}${gy}.` }]); + setAp({ ...ap, moveUsed: true }); + setActiveAction(null); + toast({ kind: "rest", eyebrow: "Action", title: selected.name + " moves", body: `to ${String.fromCharCode(64 + gx)}${gy}` }); + }; + + const endTurn = () => { + setRound(round + 1); + setAp({ standardUsed: false, moveUsed: false, swiftUsed: false }); + toast({ eyebrow: "Round", title: "Round " + (round + 1) + " begins" }); + }; + + return ( +
+ +
+ {/* Battle map */} + +
+
+
Encounter · Round {round}
+

Lanternrest Courtyard

+
+
+ Surprise broken + Cover: light + ↩ Undo + onNavigate("table")}>End encounter +
+
+ + +
+ + {/* Action bar */} + +
+
+ +
+
+ {selected.name} +
+
+ HP {selected.hp}/{selected.hpMax} · AC {selected.ac} +
+
+ + + +
+
+
+ +
+ !ap.moveUsed && setActiveAction("move")} active={activeAction === "move"} spent={ap.moveUsed} /> + !ap.standardUsed && (setActiveAction("attack"), setAp({ ...ap, standardUsed: true }), toast({ eyebrow: "Attack", title: selected.name + " attacks", body: "d20+5 vs AC 15: 18 — hit. 7 piercing." }))} active={activeAction === "attack"} spent={ap.standardUsed} /> + !ap.standardUsed && (setActiveAction("cast"), setAp({ ...ap, standardUsed: true }), toast({ kind: "item", eyebrow: "Spell", title: selected.name + " casts Shocking Grasp", body: "Held in the off hand. Touch attack next." }))} active={activeAction === "cast"} spent={ap.standardUsed} /> + { toast({ eyebrow: "Stance", title: selected.name + " defends", body: "+4 AC until next turn." }); endTurn(); }} /> + + !ap.swiftUsed && setAp({ ...ap, swiftUsed: true })} spent={ap.swiftUsed} /> + +
+
+ + {activeAction === "move" && ( +
+ Pick a tile within 6 squares. Yellow = within reach. Esc to cancel. +
+ )} +
+
+ + {/* RIGHT — initiative + log */} +
+ + Initiative +
+ {INITIATIVE.map((t) => { + const tok = tokens.find((x) => x.id === t.id) || t; + const isFoe = tok.team === "foe"; + const isActive = t.active; + const hpRatio = tok.hp ? tok.hp / tok.hpMax : 1; + return ( + + ); + })} +
+
+ + + Battle Log +
+ {log.map((l, i) => )} +
+
+
+
+ ); +} + +function CombatMap({ tokens, selected, onSelect, activeAction, onMove }) { + const COLS = 16, ROWS = 10; + const selectedToken = tokens.find((t) => t.id === selected); + + // Determine which tiles are within reach (movement = 6 squares) + const inRange = (gx, gy) => { + if (!selectedToken || activeAction !== "move") return false; + const dx = Math.abs(gx - selectedToken.x); + const dy = Math.abs(gy - selectedToken.y); + return Math.max(dx, dy) <= 6 && Math.max(dx, dy) > 0; + }; + + return ( +
+ {/* Painted scene backdrop */} +
+ +
+ + {/* Grid overlay */} + + + + + + + + + {/* Column labels */} + {Array.from({ length: COLS }).map((_, i) => ( + + {String.fromCharCode(65 + i)} + + ))} + {/* Row labels */} + {Array.from({ length: ROWS }).map((_, i) => ( + {i + 1} + ))} + + + {/* Tile click overlay */} +
+ {Array.from({ length: COLS * ROWS }).map((_, idx) => { + const gx = (idx % COLS) + 1; + const gy = Math.floor(idx / COLS) + 1; + const reach = inRange(gx, gy); + return ( +
reach && onMove(gx, gy)} + style={{ + background: reach ? "rgba(244, 210, 123, 0.16)" : "transparent", + boxShadow: reach ? "inset 0 0 0 1px rgba(244, 210, 123, 0.4)" : "none", + cursor: reach ? "pointer" : "default", + transition: "background 100ms", + }} + /> + ); + })} +
+ + {/* Tokens */} + {tokens.map((t) => ( + onSelect(t.id)} + /> + ))} +
+ ); +} + +function CombatToken({ t, cols, rows, selected, onClick }) { + const xPct = ((t.x - 0.5) / cols) * 100; + const yPct = ((t.y - 0.5) / rows) * 100; + const isFoe = t.team === "foe"; + return ( + + ); +} + +function ActionTile({ icon, label, hint, onClick, active, spent }) { + return ( + + ); +} + +function ApBadge({ used, label }) { + return ( + {label} + ); +} + +function BattleLogLine({ l }) { + if (l.t === "round") return ( +
+ {l.text} +
+ ); + if (l.t === "act") return ( +
+ + {l.who} + + {l.text} +
+ ); + if (l.t === "roll") return ( +
+ ▷ {l.text} +
+ ); + if (l.t === "dmg") return ( +
+ ▷ {l.text} +
+ ); + return null; +} + +const TOKENS = [ + { id: "cassian", name: "Cassian", initial: "C", short: "C·portrait", team: "ally", x: 5, y: 7, hp: 22, hpMax: 24, ac: 17, standardUsed: false, moveUsed: false, swiftUsed: false }, + { id: "mira", name: "Mira", initial: "M", short: "M·portrait", team: "ally", x: 7, y: 8, hp: 18, hpMax: 22, ac: 15 }, + { id: "vell", name: "Vell", initial: "V", short: "V·portrait", team: "ally", x: 4, y: 8, hp: 28, hpMax: 30, ac: 18 }, + { id: "bandit-n", name: "Bandit N", initial: "♠", team: "foe", x: 11, y: 3, hp: 14, hpMax: 16, ac: 15 }, + { id: "bandit-s", name: "Bandit S", initial: "♠", team: "foe", x: 13, y: 6, hp: 8, hpMax: 16, ac: 15 }, + { id: "bandit-e", name: "Sgt.", initial: "♣", team: "foe", x: 13, y: 4, hp: 22, hpMax: 22, ac: 16 }, +]; + +const INITIATIVE = [ + { id: "mira", init: 19 }, + { id: "cassian", init: 14, active: true }, + { id: "bandit-e", init: 12 }, + { id: "bandit-n", init: 11 }, + { id: "vell", init: 9 }, + { id: "bandit-s", init: 4 }, +]; + +Object.assign(window, { ScreenCombat, CombatMap, CombatToken, ActionTile, ApBadge, BattleLogLine, TOKENS, INITIATIVE }); diff --git a/viewer/openworlds/screen-create.jsx b/viewer/openworlds/screen-create.jsx new file mode 100644 index 00000000..ef44680e --- /dev/null +++ b/viewer/openworlds/screen-create.jsx @@ -0,0 +1,671 @@ +/* Screen: Character Creation — wizard flow */ + +function ScreenCreate({ onNavigate, state, setState }) { + const [step, setStep] = React.useState(0); + const [hero, setHero] = React.useState({ + name: "", + race: "human", + class: "magus", + background: "wanderer", + portrait: 0, + alignment: "neutral-good", + abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, + points: 20, + }); + + const steps = [ + { id: "race", label: "Lineage" }, + { id: "class", label: "Calling" }, + { id: "background", label: "Past" }, + { id: "abilities", label: "Aptitudes" }, + { id: "portrait", label: "Face" }, + { id: "name", label: "Name" }, + { id: "review", label: "Bind" }, + ]; + + const next = () => setStep(Math.min(steps.length - 1, step + 1)); + const prev = () => setStep(Math.max(0, step - 1)); + + return ( +
+ + {/* LEFT — wizard steps */} + +
Binding of a
+

New Hero

+ +
+ {steps.map((s, i) => ( + + ))} +
+ +
+ + +
+ Every step changes the next. The chronicle remembers them all. +
+ + + {/* CENTER — step content */} + +
+ {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + {step === 5 && } + {step === 6 && } +
+ + {/* Footer nav */} +
+ ← Back + Step {step + 1} of {steps.length} + {step < steps.length - 1 ? ( + Continue → + ) : ( + onNavigate("table")} tone="crimson">Bind the hero + )} +
+
+ + {/* RIGHT — live hero summary */} + +
What you have made
+

+ {hero.name || Unnamed} +

+
+ {RACES[hero.race]?.name} · {CLASSES[hero.class]?.name} +
+ + + + + +
Aptitudes
+
+ {["str","dex","con","int","wis","cha"].map((a) => { + const total = hero.abilities[a] + (RACES[hero.race]?.bonus?.[a] || 0); + return ( +
+
{a.toUpperCase()}
+
{total}
+
+ ); + })} +
+ + + +
Background
+
{BACKGROUNDS[hero.background]?.name}
+
{BACKGROUNDS[hero.background]?.brief}
+ + + +
Alignment
+
{hero.alignment.replace("-", " ")}
+ + + +
Points remaining
+
+ {hero.points} +
+
+
+ ); +} + +/* ===== STEPS ===== */ + +function StepRace({ hero, setHero }) { + return ( +
+
I. Of Lineage
+

What blood do you carry?

+

+ Lineage decides not your worth but your starting reach. Most heroes find themselves regardless. +

+ + +
+ {Object.entries(RACES).map(([id, r]) => ( + setHero({ ...hero, race: id })} + label={r.name} + sublabel={r.size + " · " + r.life} + portrait={r.glyph} + body={r.body} + tags={Object.entries(r.bonus || {}).map(([k, v]) => `${v > 0 ? "+" : ""}${v} ${k.toUpperCase()}`)} + /> + ))} +
+
+ ); +} + +function StepClass({ hero, setHero }) { + return ( +
+
II. Of Calling
+

What discipline keeps you?

+

+ Your calling chooses what you wake up doing every morning. It does not choose what wakes you. +

+ + +
+ {Object.entries(CLASSES).map(([id, c]) => ( + setHero({ ...hero, class: id })} + label={c.name} + sublabel={c.role} + portrait={c.glyph} + body={c.body} + tags={c.tags} + /> + ))} +
+
+ ); +} + +function StepBackground({ hero, setHero }) { + const ALIGNMENTS = [ + "lawful-good", "neutral-good", "chaotic-good", + "lawful-neutral", "true-neutral", "chaotic-neutral", + "lawful-evil", "neutral-evil", "chaotic-evil", + ]; + return ( +
+
III. Of the Past
+

What did you do before?

+ + +
+ {Object.entries(BACKGROUNDS).map(([id, b]) => ( + + ))} +
+ + + + Alignment +
+ {ALIGNMENTS.map((a) => ( + + ))} +
+
+ ); +} + +function StepAbilities({ hero, setHero }) { + const adjust = (key, delta) => { + const cur = hero.abilities[key]; + const target = cur + delta; + if (target < 8 || target > 18) return; + const cost = abilityCost(target) - abilityCost(cur); + if (hero.points - cost < 0) return; + setHero({ + ...hero, + abilities: { ...hero.abilities, [key]: target }, + points: hero.points - cost, + }); + }; + return ( +
+
IV. Of Aptitudes
+

Distribute your gifts.

+

+ Twenty points, six gifts. Higher scores cost more. Pathfinder rules. +

+ + +
+ {["str", "dex", "con", "int", "wis", "cha"].map((k) => { + const racial = RACES[hero.race]?.bonus?.[k] || 0; + const total = hero.abilities[k] + racial; + const mod = Math.floor((total - 10) / 2); + return ( +
+
+
{k.toUpperCase()}
+
{ABILITY_LABEL[k]}
+
+
+ {hero.abilities[k]} {racial !== 0 && ( 0 ? "var(--emerald)" : "var(--crimson)" }}>{racial > 0 ? "+" : ""}{racial})} +
+ +
+
{total}
+
= 0 ? "var(--emerald)" : "var(--crimson)" }}> + {mod >= 0 ? "+" : ""}{mod} +
+
+ +
+ ); + })} +
+ + + +
+
+
Points remaining
+
+ {hero.points}/20 +
+
+ setHero({ + ...hero, + abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, + points: 20, + })}>Reset +
+
+ ); +} + +function StepPortrait({ hero, setHero }) { + return ( +
+
V. Of Face
+

What will the chronicle remember of you?

+ + +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+ + +
+ Bring your own — drop a PNG onto any frame to replace it. +
+
+ ); +} + +function StepName({ hero, setHero }) { + const [biography, setBiography] = React.useState(""); + return ( +
+
VI. Of Name
+

What shall the chronicle call you?

+ + +
Name
+ setHero({ ...hero, name: e.target.value })} + placeholder="e.g. Eira of the Hollow Reach" + style={{ ...window.inkInput, fontSize: 22, fontFamily: "var(--f-display)", letterSpacing: "0.06em" }} + autoFocus + /> + +
Family / House (optional)
+ + +
Biography
+