From 72d42e1d5592107c6add67b22d38e18c0a65358d Mon Sep 17 00:00:00 2001 From: Fadi Shawki Date: Fri, 19 Jun 2026 23:45:09 +0200 Subject: [PATCH 1/6] Rough first setup for the book + editor --- orbitmines.com/app/[...path]/page.tsx | 19 + orbitmines.com/content/lore/SCHEMA.md | 105 +++ orbitmines.com/content/lore/books/B.md | 10 + orbitmines.com/content/lore/books/I.md | 14 + orbitmines.com/content/lore/books/II.md | 12 + orbitmines.com/content/lore/books/III.md | 10 + orbitmines.com/content/lore/books/S.md | 10 + orbitmines.com/content/lore/books/main.md | 15 + .../content/lore/chapters/i-first-day.md | 26 + .../content/lore/chapters/i-online.md | 18 + .../content/lore/chapters/ii-lecture.md | 22 + .../content/lore/chapters/prologue.md | 22 + orbitmines.com/content/lore/characters/B.md | 8 + orbitmines.com/content/lore/characters/I.md | 14 + orbitmines.com/content/lore/characters/IA.md | 10 + orbitmines.com/content/lore/characters/IB.md | 10 + orbitmines.com/content/lore/characters/IC.md | 10 + orbitmines.com/content/lore/characters/II.md | 15 + orbitmines.com/content/lore/characters/IIA.md | 10 + orbitmines.com/content/lore/characters/IIB.md | 10 + orbitmines.com/content/lore/characters/IIC.md | 10 + orbitmines.com/content/lore/characters/IID.md | 10 + orbitmines.com/content/lore/characters/III.md | 8 + orbitmines.com/content/lore/characters/S.md | 9 + .../lore/codex/locations/university.md | 7 + .../content/lore/codex/organizations/ether.md | 7 + orbitmines.com/content/lore/site/landing.md | 8 + orbitmines.com/package-lock.json | 122 +++ orbitmines.com/package.json | 7 + .../public/lore-assets/characters/B.svg | 10 + .../public/lore-assets/characters/I.svg | 10 + .../public/lore-assets/characters/IA.svg | 10 + .../public/lore-assets/characters/IB.svg | 10 + .../public/lore-assets/characters/IC.svg | 10 + .../public/lore-assets/characters/II.svg | 10 + .../public/lore-assets/characters/IIA.svg | 10 + .../public/lore-assets/characters/IIB.svg | 10 + .../public/lore-assets/characters/IIC.svg | 10 + .../public/lore-assets/characters/IID.svg | 10 + .../public/lore-assets/characters/III.svg | 10 + .../public/lore-assets/characters/IIIA.svg | 10 + .../public/lore-assets/characters/IIIB.svg | 10 + .../public/lore-assets/characters/IIIC.svg | 10 + .../public/lore-assets/characters/S.svg | 10 + .../public/lore-assets/covers/B.svg | 9 + .../public/lore-assets/covers/I.svg | 9 + .../public/lore-assets/covers/II.svg | 9 + .../public/lore-assets/covers/III.svg | 9 + .../public/lore-assets/covers/S.svg | 9 + .../public/lore-assets/covers/ether.svg | 9 + .../public/lore-assets/covers/main.svg | 9 + .../public/lore-assets/covers/university.svg | 9 + orbitmines.com/scripts/lore/build-lore.mjs | 16 + orbitmines.com/scripts/lore/editor-server.mjs | 207 +++++ orbitmines.com/scripts/lore/gen-images.mjs | 75 ++ orbitmines.com/scripts/lore/lore-core.mjs | 212 ++++++ .../src/@ether/UI/router/EtherOrMinimap.tsx | 7 +- orbitmines.com/src/lore/Lore.tsx | 126 ++++ orbitmines.com/src/lore/LoreNav.tsx | 23 + .../src/lore/components/BookHome.tsx | 107 +++ orbitmines.com/src/lore/components/Codex.tsx | 121 +++ .../src/lore/components/EntityDrawer.tsx | 125 +++ orbitmines.com/src/lore/components/Graph.tsx | 161 ++++ .../src/lore/components/LoreHtml.tsx | 30 + .../src/lore/components/LoreLanding.tsx | 162 ++++ orbitmines.com/src/lore/components/Reader.tsx | 163 ++++ orbitmines.com/src/lore/data.ts | 120 +++ orbitmines.com/src/lore/editor/Editor.tsx | 383 ++++++++++ .../src/lore/editor/FrontmatterForm.tsx | 171 +++++ orbitmines.com/src/lore/editor/api.ts | 57 ++ orbitmines.com/src/lore/editor/editor.scss | 143 ++++ orbitmines.com/src/lore/editor/frontmatter.ts | 97 +++ orbitmines.com/src/lore/generated/lore.json | 712 ++++++++++++++++++ orbitmines.com/src/lore/lore.scss | 299 ++++++++ orbitmines.com/src/lore/types.ts | 90 +++ orbitmines.com/src/lore/useProgress.ts | 62 ++ 76 files changed, 4448 insertions(+), 1 deletion(-) create mode 100644 orbitmines.com/content/lore/SCHEMA.md create mode 100644 orbitmines.com/content/lore/books/B.md create mode 100644 orbitmines.com/content/lore/books/I.md create mode 100644 orbitmines.com/content/lore/books/II.md create mode 100644 orbitmines.com/content/lore/books/III.md create mode 100644 orbitmines.com/content/lore/books/S.md create mode 100644 orbitmines.com/content/lore/books/main.md create mode 100644 orbitmines.com/content/lore/chapters/i-first-day.md create mode 100644 orbitmines.com/content/lore/chapters/i-online.md create mode 100644 orbitmines.com/content/lore/chapters/ii-lecture.md create mode 100644 orbitmines.com/content/lore/chapters/prologue.md create mode 100644 orbitmines.com/content/lore/characters/B.md create mode 100644 orbitmines.com/content/lore/characters/I.md create mode 100644 orbitmines.com/content/lore/characters/IA.md create mode 100644 orbitmines.com/content/lore/characters/IB.md create mode 100644 orbitmines.com/content/lore/characters/IC.md create mode 100644 orbitmines.com/content/lore/characters/II.md create mode 100644 orbitmines.com/content/lore/characters/IIA.md create mode 100644 orbitmines.com/content/lore/characters/IIB.md create mode 100644 orbitmines.com/content/lore/characters/IIC.md create mode 100644 orbitmines.com/content/lore/characters/IID.md create mode 100644 orbitmines.com/content/lore/characters/III.md create mode 100644 orbitmines.com/content/lore/characters/S.md create mode 100644 orbitmines.com/content/lore/codex/locations/university.md create mode 100644 orbitmines.com/content/lore/codex/organizations/ether.md create mode 100644 orbitmines.com/content/lore/site/landing.md create mode 100644 orbitmines.com/public/lore-assets/characters/B.svg create mode 100644 orbitmines.com/public/lore-assets/characters/I.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IA.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IB.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IC.svg create mode 100644 orbitmines.com/public/lore-assets/characters/II.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IIA.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IIB.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IIC.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IID.svg create mode 100644 orbitmines.com/public/lore-assets/characters/III.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IIIA.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IIIB.svg create mode 100644 orbitmines.com/public/lore-assets/characters/IIIC.svg create mode 100644 orbitmines.com/public/lore-assets/characters/S.svg create mode 100644 orbitmines.com/public/lore-assets/covers/B.svg create mode 100644 orbitmines.com/public/lore-assets/covers/I.svg create mode 100644 orbitmines.com/public/lore-assets/covers/II.svg create mode 100644 orbitmines.com/public/lore-assets/covers/III.svg create mode 100644 orbitmines.com/public/lore-assets/covers/S.svg create mode 100644 orbitmines.com/public/lore-assets/covers/ether.svg create mode 100644 orbitmines.com/public/lore-assets/covers/main.svg create mode 100644 orbitmines.com/public/lore-assets/covers/university.svg create mode 100644 orbitmines.com/scripts/lore/build-lore.mjs create mode 100644 orbitmines.com/scripts/lore/editor-server.mjs create mode 100644 orbitmines.com/scripts/lore/gen-images.mjs create mode 100644 orbitmines.com/scripts/lore/lore-core.mjs create mode 100644 orbitmines.com/src/lore/Lore.tsx create mode 100644 orbitmines.com/src/lore/LoreNav.tsx create mode 100644 orbitmines.com/src/lore/components/BookHome.tsx create mode 100644 orbitmines.com/src/lore/components/Codex.tsx create mode 100644 orbitmines.com/src/lore/components/EntityDrawer.tsx create mode 100644 orbitmines.com/src/lore/components/Graph.tsx create mode 100644 orbitmines.com/src/lore/components/LoreHtml.tsx create mode 100644 orbitmines.com/src/lore/components/LoreLanding.tsx create mode 100644 orbitmines.com/src/lore/components/Reader.tsx create mode 100644 orbitmines.com/src/lore/data.ts create mode 100644 orbitmines.com/src/lore/editor/Editor.tsx create mode 100644 orbitmines.com/src/lore/editor/FrontmatterForm.tsx create mode 100644 orbitmines.com/src/lore/editor/api.ts create mode 100644 orbitmines.com/src/lore/editor/editor.scss create mode 100644 orbitmines.com/src/lore/editor/frontmatter.ts create mode 100644 orbitmines.com/src/lore/generated/lore.json create mode 100644 orbitmines.com/src/lore/lore.scss create mode 100644 orbitmines.com/src/lore/types.ts create mode 100644 orbitmines.com/src/lore/useProgress.ts diff --git a/orbitmines.com/app/[...path]/page.tsx b/orbitmines.com/app/[...path]/page.tsx index 15575dc1..e1a77dd9 100644 --- a/orbitmines.com/app/[...path]/page.tsx +++ b/orbitmines.com/app/[...path]/page.tsx @@ -1,9 +1,28 @@ import CatchAllClient from './CatchAllClient'; import { PROFILE_NAMES } from '../profiles/[profile]/page'; +import loreData from '../../src/lore/generated/lore.json'; + +// Enumerate the lore reader URLs so /lore/... is directly loadable (dev) and +// prerendered (prod) — otherwise dynamicParams=false would 404 these. All +// render the same client SPA; deeper state (?p, ?entity) is client-side only. +function loreParams() { + const params: { path: string[] }[] = [ + { path: ['lore'] }, + { path: ['lore', 'edit'] }, + ]; + for (const id of Object.keys(loreData.books)) { + params.push({ path: ['lore', id] }); + params.push({ path: ['lore', id, 'read'] }); + params.push({ path: ['lore', id, 'codex'] }); + } + return params; +} + export function generateStaticParams() { return [ { path: ['_catchall'] }, ...Object.keys(PROFILE_NAMES).map((handle) => ({ path: [`@${handle}`] })), + ...loreParams(), ]; } diff --git a/orbitmines.com/content/lore/SCHEMA.md b/orbitmines.com/content/lore/SCHEMA.md new file mode 100644 index 00000000..60ffbbf3 --- /dev/null +++ b/orbitmines.com/content/lore/SCHEMA.md @@ -0,0 +1,105 @@ +# Lore content format + +All lore is plain markdown under `content/lore/`. A build step +(`scripts/lore/build-lore.mjs`) parses it into `src/lore/generated/lore.json`, +which the reader and codex consume. You can author these files by hand or with +any Obsidian-style editor — the conventions below are deliberately +Obsidian-compatible. + +``` +content/lore/ + books/ one file per book (the main story + per-character books) + chapters/ one file per chapter (a chapter may belong to several books) + characters/ character entities (the "who is this?" cards) + codex/ other entities: events/, locations/, concepts/, organizations/ +``` + +Every entity (book, chapter, character, codex entry) has a stable `id`. Ids are +case-insensitive and referenced from prose with wikilinks: `[[I]]`, or with a +display label `[[I|my little brother]]`. Aliases declared in frontmatter also +resolve. + +--- + +## Books — `books/.md` + +```yaml +--- +id: main +title: The Story +kind: main # main | character +subtitle: A culmination of every account. +cover: /lore-assets/covers/main.svg +order: 0 # sort order on the /lore landing page +characters: [I, II, III, S, B] # (main books) POV characters offered on the homepage +chapters: # ordered chapter ids that make up THIS book + - prologue + - i-first-day + - ii-lecture + - i-online +--- +Back-cover blurb (markdown). +``` + +- `chapters` is the **ordering authority**. A chapter listed in several books + can appear in a different position in each. +- A `kind: character` book is one character's account. Its homepage links back + to the main story and its sibling character books. + +## Chapters — `chapters/.md` + +```yaml +--- +id: i-first-day +title: First Day +pov: I # POV character id +books: [main, I] # informational; book.chapters controls order +characters: [I, IC, IA] # present in the scene (auto-augmented from wikilinks) +summary: One-line summary for the chapter list. +--- +Prose. Reference entities with [[I]] / [[IC|his mother]]. + + + +A `` line on its own starts a new A5 page. Reader progress and +knowledge gating are tracked per page, so split where the physical book would. +``` + +### Reveal callouts + +Obsidian-style callouts inside a chapter record *what becomes known and when*. +They are anchored to the page they appear on, so the codex only shows them once +a reader has read that far. + +``` +> [!reveal] A world fact the reader now knows. +> [!knows|I,IA] Characters I and IA learn this; tracked per-character. +> [!event] Something that happened (shown on the codex timeline). +> [!secret|II] Known to the reader and to II, but e.g. not to I. +``` + +Format: `> [!type]` or `> [!type|id,id,...]` then the fact text. `type` is free +(`reveal`, `knows`, `event`, `secret`, `meets`, ...). The `|csv` lists entity +ids the fact is attributed to. + +## Characters — `characters/.md` + +```yaml +--- +id: I +type: character +name: "[I]" +role: Highschool student +age: 16 +image: /lore-assets/characters/I.svg +aliases: [] +relations: + - "sibling of [[II]]" +--- +Spoiler-free baseline description. Anything time-sensitive should instead be a +reveal callout in the chapter where it surfaces. +``` + +## Codex — `codex//.md` + +Same as characters but `type: event | location | concept | organization`. diff --git a/orbitmines.com/content/lore/books/B.md b/orbitmines.com/content/lore/books/B.md new file mode 100644 index 00000000..059e7344 --- /dev/null +++ b/orbitmines.com/content/lore/books/B.md @@ -0,0 +1,10 @@ +--- +id: B +title: "[B] — The Head" +kind: character +subtitle: 0.B0.4F.1 +cover: /lore-assets/covers/B.svg +order: 5 +chapters: [] +--- +[B]'s account. (To be written.) diff --git a/orbitmines.com/content/lore/books/I.md b/orbitmines.com/content/lore/books/I.md new file mode 100644 index 00000000..606d37f9 --- /dev/null +++ b/orbitmines.com/content/lore/books/I.md @@ -0,0 +1,14 @@ +--- +id: I +title: "[I] — The Younger" +kind: character +subtitle: 1.E2031.16G.1 +cover: /lore-assets/covers/I.svg +order: 1 +chapters: + - i-first-day + - i-online +--- +The week as [I] lived it: school, his sister's strange new mood, and a stranger +online who knows too much. He sees only his own slice — and fills the rest in +with confidence. diff --git a/orbitmines.com/content/lore/books/II.md b/orbitmines.com/content/lore/books/II.md new file mode 100644 index 00000000..cc2d5a5a --- /dev/null +++ b/orbitmines.com/content/lore/books/II.md @@ -0,0 +1,12 @@ +--- +id: II +title: "[II] — The Elder" +kind: character +subtitle: 1.E2024.23B.1 +cover: /lore-assets/covers/II.svg +order: 2 +chapters: + - ii-lecture +--- +[II]'s account: the university, professor [S], and the first time she suspects +the coursework is describing something real. diff --git a/orbitmines.com/content/lore/books/III.md b/orbitmines.com/content/lore/books/III.md new file mode 100644 index 00000000..ed03fffc --- /dev/null +++ b/orbitmines.com/content/lore/books/III.md @@ -0,0 +1,10 @@ +--- +id: III +title: "[III] — The Researcher" +kind: character +subtitle: 0.E2026.0A.1 +cover: /lore-assets/covers/III.svg +order: 3 +chapters: [] +--- +[III]'s account. (To be written.) diff --git a/orbitmines.com/content/lore/books/S.md b/orbitmines.com/content/lore/books/S.md new file mode 100644 index 00000000..b44c5144 --- /dev/null +++ b/orbitmines.com/content/lore/books/S.md @@ -0,0 +1,10 @@ +--- +id: S +title: "[S] — The Professor" +kind: character +subtitle: 0.S0.0A.1 +cover: /lore-assets/covers/S.svg +order: 4 +chapters: [] +--- +[S]'s account. (To be written.) diff --git a/orbitmines.com/content/lore/books/main.md b/orbitmines.com/content/lore/books/main.md new file mode 100644 index 00000000..8faddc0d --- /dev/null +++ b/orbitmines.com/content/lore/books/main.md @@ -0,0 +1,15 @@ +--- +id: main +title: The Main Story +kind: main +subtitle: 1.E2047.0A.1 +cover: /lore-assets/covers/main.svg +order: 0 +characters: [I, II, III, S, B] +chapters: + - prologue + - i-first-day + - ii-lecture + - i-online +--- +A mind incapable of being reprogrammed is a vulnerable one. But you are never more vulnerable when you are being reprogrammed! Whether this is self-inflicted or is done so by your environment is of little consequence. After you're in a more stable state, can you more closely represent what reality is? \ No newline at end of file diff --git a/orbitmines.com/content/lore/chapters/i-first-day.md b/orbitmines.com/content/lore/chapters/i-first-day.md new file mode 100644 index 00000000..122ee8a4 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/i-first-day.md @@ -0,0 +1,26 @@ +--- +id: i-first-day +title: First Day +pov: I +books: [main, I] +characters: [I, IC, IA] +summary: A new semester, and a sister in a strange mood. +--- +[[I]] was sixteen and already bored of the assembly before it started. + +His mother — [[IC|mom]] — had dropped him and his sister at the gate without a +word, which was not like her. + +> [!knows|I] [[I]] notices [[IC]] is hiding something, but not what. + + + +[[IA]] found him by the lockers. "Your sister okay? She looked like she'd seen +a ghost." + +> [!knows|I,IA] Both [[I]] and [[IA]] saw that [[II]] was shaken this morning. + +He shrugged it off. He was good at shrugging things off — better than he was at +being right about them. + +> [!secret|I] [[I]] decides not to ask [[II]] about it. He'll regret that. diff --git a/orbitmines.com/content/lore/chapters/i-online.md b/orbitmines.com/content/lore/chapters/i-online.md new file mode 100644 index 00000000..5cc46232 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/i-online.md @@ -0,0 +1,18 @@ +--- +id: i-online +title: The Message +pov: I +books: [main, I] +characters: [I, IB] +summary: A stranger online who knows too much. +--- +That night [[I]] logged on and [[IB]] was already typing. + +> [!knows|I,IB] [[IB]] knows about the slide [[II]] saw — which [[I]] never mentioned to anyone. + + + +He stared at the message for a long time. + +> [!secret|IB] [[IB]] is not who [[I]] thinks they are. +> [!event] [[I]] realises someone outside the family is tracking what happens at the [[university]]. diff --git a/orbitmines.com/content/lore/chapters/ii-lecture.md b/orbitmines.com/content/lore/chapters/ii-lecture.md new file mode 100644 index 00000000..c1ff76a5 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/ii-lecture.md @@ -0,0 +1,22 @@ +--- +id: ii-lecture +title: The Lecture +pov: II +books: [main, II] +characters: [II, IID, S] +summary: The coursework starts describing something real. +--- +[[II]] had taken [[IID|the professor]]'s course three times now in different +forms, and never seen the slide that went up that morning. + +It was not on the syllabus. It was, as far as she could tell, not on anyone's +syllabus. + +> [!knows|II] [[II]] realises the lecture material is describing a real system, not a model. + + + +The visiting lecturer introduced himself only as [[S]]. + +> [!meets|II,S] [[II]] meets [[S]] for the first time. +> [!reveal] [[S]] is somehow connected to both the university and to [[ether|Ether]]. diff --git a/orbitmines.com/content/lore/chapters/prologue.md b/orbitmines.com/content/lore/chapters/prologue.md new file mode 100644 index 00000000..18527469 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/prologue.md @@ -0,0 +1,22 @@ +--- +id: prologue +title: Prologue +pov: S +books: [main] +characters: [S] +summary: Before any of them noticed. +--- +Before any of them noticed, the thing was already running. + +[[S]] had a way of phrasing it that made it sound like a coincidence — that you +would arrive here, that you would read exactly this far, and no further, until +you chose to. + +> [!reveal] Whatever is happening at [[university|the university]] did not begin this week. + + + +He wrote the first names on the board himself. Five of them, and two letters +that were not names at all. + +> [!event] The account opens with [[S]] already expecting each of them. diff --git a/orbitmines.com/content/lore/characters/B.md b/orbitmines.com/content/lore/characters/B.md new file mode 100644 index 00000000..90137969 --- /dev/null +++ b/orbitmines.com/content/lore/characters/B.md @@ -0,0 +1,8 @@ +--- +id: B +type: character +name: "[B]" +role: Head of the University +image: /lore-assets/characters/B.svg +--- +The head of the university. diff --git a/orbitmines.com/content/lore/characters/I.md b/orbitmines.com/content/lore/characters/I.md new file mode 100644 index 00000000..c8812874 --- /dev/null +++ b/orbitmines.com/content/lore/characters/I.md @@ -0,0 +1,14 @@ +--- +id: I +type: character +name: "[I]" +role: Highschool student +age: 16 +image: /lore-assets/characters/I.svg +aliases: ["the younger"] +relations: + - "sibling of [[II]]" +--- +Tech-savvy and confident — sometimes past the point his evidence supports. The +younger of two siblings; spends as much time with people online as in the +hallways. diff --git a/orbitmines.com/content/lore/characters/IA.md b/orbitmines.com/content/lore/characters/IA.md new file mode 100644 index 00000000..4cd458fa --- /dev/null +++ b/orbitmines.com/content/lore/characters/IA.md @@ -0,0 +1,10 @@ +--- +id: IA +type: character +name: "[IA]" +role: Friend from highschool +image: /lore-assets/characters/IA.svg +relations: + - "friend of [[I]]" +--- +[I]'s friend from school. diff --git a/orbitmines.com/content/lore/characters/IB.md b/orbitmines.com/content/lore/characters/IB.md new file mode 100644 index 00000000..41be2fbb --- /dev/null +++ b/orbitmines.com/content/lore/characters/IB.md @@ -0,0 +1,10 @@ +--- +id: IB +type: character +name: "[IB]" +role: Online friend +image: /lore-assets/characters/IB.svg +relations: + - "knows [[I]] online" +--- +Someone [I] only knows through a screen. diff --git a/orbitmines.com/content/lore/characters/IC.md b/orbitmines.com/content/lore/characters/IC.md new file mode 100644 index 00000000..6df3b428 --- /dev/null +++ b/orbitmines.com/content/lore/characters/IC.md @@ -0,0 +1,10 @@ +--- +id: IC +type: character +name: "[IC]" +role: Parent +image: /lore-assets/characters/IC.svg +relations: + - "parent of [[I]] and [[II]]" +--- +Parent to [[I]] and [[II]]. diff --git a/orbitmines.com/content/lore/characters/II.md b/orbitmines.com/content/lore/characters/II.md new file mode 100644 index 00000000..3031a32d --- /dev/null +++ b/orbitmines.com/content/lore/characters/II.md @@ -0,0 +1,15 @@ +--- +id: II +type: character +name: "[II]" +role: University student — Computer Science +age: 24 +image: /lore-assets/characters/II.svg +aliases: ["the elder"] +relations: + - "sibling of [[I]]" + - "student of [[IID]]" +--- +Computer-science student, eight years older than her brother. Careful where he +is impulsive; the first to notice the coursework has started describing +something that behaves like it's real. diff --git a/orbitmines.com/content/lore/characters/IIA.md b/orbitmines.com/content/lore/characters/IIA.md new file mode 100644 index 00000000..53676546 --- /dev/null +++ b/orbitmines.com/content/lore/characters/IIA.md @@ -0,0 +1,10 @@ +--- +id: IIA +type: character +name: "[IIA]" +role: Friend (from highschool) +image: /lore-assets/characters/IIA.svg +relations: + - "friend of [[II]]" +--- +[II]'s friend since highschool. diff --git a/orbitmines.com/content/lore/characters/IIB.md b/orbitmines.com/content/lore/characters/IIB.md new file mode 100644 index 00000000..a6305772 --- /dev/null +++ b/orbitmines.com/content/lore/characters/IIB.md @@ -0,0 +1,10 @@ +--- +id: IIB +type: character +name: "[IIB]" +role: Friend (from university) +image: /lore-assets/characters/IIB.svg +relations: + - "friend of [[II]]" +--- +[II]'s friend from university. diff --git a/orbitmines.com/content/lore/characters/IIC.md b/orbitmines.com/content/lore/characters/IIC.md new file mode 100644 index 00000000..ad205d29 --- /dev/null +++ b/orbitmines.com/content/lore/characters/IIC.md @@ -0,0 +1,10 @@ +--- +id: IIC +type: character +name: "[IIC]" +role: Friend (from university) +image: /lore-assets/characters/IIC.svg +relations: + - "friend of [[II]]" +--- +[II]'s friend from university. diff --git a/orbitmines.com/content/lore/characters/IID.md b/orbitmines.com/content/lore/characters/IID.md new file mode 100644 index 00000000..52c7c55d --- /dev/null +++ b/orbitmines.com/content/lore/characters/IID.md @@ -0,0 +1,10 @@ +--- +id: IID +type: character +name: "[IID]" +role: Professor of Computer Science +image: /lore-assets/characters/IID.svg +relations: + - "teaches [[II]]" +--- +[II]'s computer-science professor. diff --git a/orbitmines.com/content/lore/characters/III.md b/orbitmines.com/content/lore/characters/III.md new file mode 100644 index 00000000..f533d17a --- /dev/null +++ b/orbitmines.com/content/lore/characters/III.md @@ -0,0 +1,8 @@ +--- +id: III +type: character +name: "[III]" +role: A researcher at Ether. +image: /lore-assets/characters/III.svg +--- +A researcher at Ether. diff --git a/orbitmines.com/content/lore/characters/S.md b/orbitmines.com/content/lore/characters/S.md new file mode 100644 index 00000000..1578b44c --- /dev/null +++ b/orbitmines.com/content/lore/characters/S.md @@ -0,0 +1,9 @@ +--- +id: S +type: character +name: "[S]" +role: Professor +image: /lore-assets/characters/S.svg +--- +A professor. Uncanny in a way nobody can quite name — and somehow everyone you +meet turns out to have been his student. diff --git a/orbitmines.com/content/lore/codex/locations/university.md b/orbitmines.com/content/lore/codex/locations/university.md new file mode 100644 index 00000000..76820481 --- /dev/null +++ b/orbitmines.com/content/lore/codex/locations/university.md @@ -0,0 +1,7 @@ +--- +id: university +type: location +name: The University +image: /lore-assets/covers/university.svg +--- +Where [[II]] studies, [[S]] teaches, and [[B]] presides. diff --git a/orbitmines.com/content/lore/codex/organizations/ether.md b/orbitmines.com/content/lore/codex/organizations/ether.md new file mode 100644 index 00000000..577e4c11 --- /dev/null +++ b/orbitmines.com/content/lore/codex/organizations/ether.md @@ -0,0 +1,7 @@ +--- +id: ether +type: organization +name: Ether +image: /lore-assets/covers/ether.svg +--- +The research outfit [[III]] works for. diff --git a/orbitmines.com/content/lore/site/landing.md b/orbitmines.com/content/lore/site/landing.md new file mode 100644 index 00000000..97d5d46c --- /dev/null +++ b/orbitmines.com/content/lore/site/landing.md @@ -0,0 +1,8 @@ +--- +id: landing +title: Ether’s Universe +subtitle: "*A mind incapable of being reprogrammed is a vulnerable one. But you are never more vulnerable when you are being reprogrammed! Whether this is self-inflicted or is done so by your environment is of little consequence. After you're in a more stable state, can you more closely represent what reality is?*" +--- + +Front matter only: this file holds the lore landing page's heading and +intro. Edit `title` and `subtitle` above (or in the lore editor). diff --git a/orbitmines.com/package-lock.json b/orbitmines.com/package-lock.json index 2f672992..9f54fdab 100644 --- a/orbitmines.com/package-lock.json +++ b/orbitmines.com/package-lock.json @@ -14,8 +14,10 @@ "@react-three/postprocessing": "^3.0.0", "@types/three": "^0.183.1", "classnames": "^2.5.1", + "gray-matter": "^4.0.3", "html-to-image": "^1.11.13", "lodash": "^4.17.23", + "marked": "^18.0.5", "next": "^16.2.6", "postprocessing": "^6.38.3", "prism-react-renderer": "^2.4.1", @@ -1689,6 +1691,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1971,6 +1982,19 @@ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -2012,6 +2036,18 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2051,6 +2087,21 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2129,6 +2180,15 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2306,6 +2366,28 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -2367,6 +2449,18 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/media-engine": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", @@ -2799,6 +2893,19 @@ "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -2909,6 +3016,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -2963,6 +3076,15 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/orbitmines.com/package.json b/orbitmines.com/package.json index c208488a..da24bd73 100755 --- a/orbitmines.com/package.json +++ b/orbitmines.com/package.json @@ -9,8 +9,10 @@ "@react-three/postprocessing": "^3.0.0", "@types/three": "^0.183.1", "classnames": "^2.5.1", + "gray-matter": "^4.0.3", "html-to-image": "^1.11.13", "lodash": "^4.17.23", + "marked": "^18.0.5", "next": "^16.2.6", "postprocessing": "^6.38.3", "prism-react-renderer": "^2.4.1", @@ -32,8 +34,13 @@ "typescript": "^5.9.3" }, "scripts": { + "lore:build": "node scripts/lore/build-lore.mjs", + "lore:images": "node scripts/lore/gen-images.mjs", + "lore:editor": "node scripts/lore/editor-server.mjs", + "predev": "node scripts/lore/build-lore.mjs", "dev": "next dev", "start": "next start", + "prebuild": "node scripts/lore/build-lore.mjs", "build": "next build", "lint": "next lint" }, diff --git a/orbitmines.com/public/lore-assets/characters/B.svg b/orbitmines.com/public/lore-assets/characters/B.svg new file mode 100644 index 00000000..bb79cb71 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/B.svg @@ -0,0 +1,10 @@ + + + + + + + + [B] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/I.svg b/orbitmines.com/public/lore-assets/characters/I.svg new file mode 100644 index 00000000..fcae7934 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/I.svg @@ -0,0 +1,10 @@ + + + + + + + + [I] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IA.svg b/orbitmines.com/public/lore-assets/characters/IA.svg new file mode 100644 index 00000000..9ca10477 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IA.svg @@ -0,0 +1,10 @@ + + + + + + + + [IA] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IB.svg b/orbitmines.com/public/lore-assets/characters/IB.svg new file mode 100644 index 00000000..d0f5951c --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IB.svg @@ -0,0 +1,10 @@ + + + + + + + + [IB] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IC.svg b/orbitmines.com/public/lore-assets/characters/IC.svg new file mode 100644 index 00000000..78905d2e --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IC.svg @@ -0,0 +1,10 @@ + + + + + + + + [IC] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/II.svg b/orbitmines.com/public/lore-assets/characters/II.svg new file mode 100644 index 00000000..c4f6d9cf --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/II.svg @@ -0,0 +1,10 @@ + + + + + + + + [II] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IIA.svg b/orbitmines.com/public/lore-assets/characters/IIA.svg new file mode 100644 index 00000000..da2f8bbe --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IIA.svg @@ -0,0 +1,10 @@ + + + + + + + + [IIA] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IIB.svg b/orbitmines.com/public/lore-assets/characters/IIB.svg new file mode 100644 index 00000000..ff0cef88 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IIB.svg @@ -0,0 +1,10 @@ + + + + + + + + [IIB] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IIC.svg b/orbitmines.com/public/lore-assets/characters/IIC.svg new file mode 100644 index 00000000..05a70507 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IIC.svg @@ -0,0 +1,10 @@ + + + + + + + + [IIC] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IID.svg b/orbitmines.com/public/lore-assets/characters/IID.svg new file mode 100644 index 00000000..0b540989 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IID.svg @@ -0,0 +1,10 @@ + + + + + + + + [IID] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/III.svg b/orbitmines.com/public/lore-assets/characters/III.svg new file mode 100644 index 00000000..639d4357 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/III.svg @@ -0,0 +1,10 @@ + + + + + + + + [III] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IIIA.svg b/orbitmines.com/public/lore-assets/characters/IIIA.svg new file mode 100644 index 00000000..6e2105fa --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IIIA.svg @@ -0,0 +1,10 @@ + + + + + + + + [IIIA] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IIIB.svg b/orbitmines.com/public/lore-assets/characters/IIIB.svg new file mode 100644 index 00000000..64cabed3 --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IIIB.svg @@ -0,0 +1,10 @@ + + + + + + + + [IIIB] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/IIIC.svg b/orbitmines.com/public/lore-assets/characters/IIIC.svg new file mode 100644 index 00000000..4718c81a --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/IIIC.svg @@ -0,0 +1,10 @@ + + + + + + + + [IIIC] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/characters/S.svg b/orbitmines.com/public/lore-assets/characters/S.svg new file mode 100644 index 00000000..839edb1f --- /dev/null +++ b/orbitmines.com/public/lore-assets/characters/S.svg @@ -0,0 +1,10 @@ + + + + + + + + [S] + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/B.svg b/orbitmines.com/public/lore-assets/covers/B.svg new file mode 100644 index 00000000..c0fdc1b9 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/B.svg @@ -0,0 +1,9 @@ + + + + + + + B + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/I.svg b/orbitmines.com/public/lore-assets/covers/I.svg new file mode 100644 index 00000000..ff210ceb --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/I.svg @@ -0,0 +1,9 @@ + + + + + + + I + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/II.svg b/orbitmines.com/public/lore-assets/covers/II.svg new file mode 100644 index 00000000..58881ee8 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/II.svg @@ -0,0 +1,9 @@ + + + + + + + II + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/III.svg b/orbitmines.com/public/lore-assets/covers/III.svg new file mode 100644 index 00000000..bea22499 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/III.svg @@ -0,0 +1,9 @@ + + + + + + + III + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/S.svg b/orbitmines.com/public/lore-assets/covers/S.svg new file mode 100644 index 00000000..cac5a708 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/S.svg @@ -0,0 +1,9 @@ + + + + + + + S + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/ether.svg b/orbitmines.com/public/lore-assets/covers/ether.svg new file mode 100644 index 00000000..57f57b37 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/ether.svg @@ -0,0 +1,9 @@ + + + + + + + Eth + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/main.svg b/orbitmines.com/public/lore-assets/covers/main.svg new file mode 100644 index 00000000..c9929c95 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/main.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/orbitmines.com/public/lore-assets/covers/university.svg b/orbitmines.com/public/lore-assets/covers/university.svg new file mode 100644 index 00000000..e0235aaf --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/university.svg @@ -0,0 +1,9 @@ + + + + + + + Uni + \ No newline at end of file diff --git a/orbitmines.com/scripts/lore/build-lore.mjs b/orbitmines.com/scripts/lore/build-lore.mjs new file mode 100644 index 00000000..f29eacad --- /dev/null +++ b/orbitmines.com/scripts/lore/build-lore.mjs @@ -0,0 +1,16 @@ +// Parses content/lore/**.md into src/lore/generated/lore.json. Run directly, +// or via the predev/prebuild npm hooks. The parsing lives in lore-core.mjs so +// the dev editor server can reuse it. See content/lore/SCHEMA.md. +import path from 'node:path'; +import { buildLore, OUT, ROOT } from './lore-core.mjs'; + +const { data, warnings } = buildLore({ write: true }); + +console.log( + `lore: ${Object.keys(data.books).length} books, ${Object.keys(data.chapters).length} chapters, ` + + `${Object.keys(data.entities).length} entities, ${data.facts.length} facts -> ${path.relative(ROOT, OUT)}`, +); +if (warnings.length) { + console.log(`lore: ${warnings.length} warning(s):`); + for (const w of warnings) console.log(' ! ' + w); +} diff --git a/orbitmines.com/scripts/lore/editor-server.mjs b/orbitmines.com/scripts/lore/editor-server.mjs new file mode 100644 index 00000000..740bf463 --- /dev/null +++ b/orbitmines.com/scripts/lore/editor-server.mjs @@ -0,0 +1,207 @@ +// Dev-only editor API for the lore vault. Run alongside `next dev`: +// +// npm run lore:editor (defaults to http://localhost:4317) +// +// It reads/writes content/lore/**.md and reuses lore-core to keep +// src/lore/generated/lore.json in sync, so the running reader hot-reloads as +// you edit. It is intentionally NOT part of the Next app, so the production +// static export stays backend-free. Never expose this server publicly. +import http from 'node:http'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { buildLore, CONTENT, ROOT } from './lore-core.mjs'; + +const PORT = Number(process.env.LORE_EDITOR_PORT) || 4317; + +const KINDS = ['books', 'chapters', 'characters', 'codex', 'site']; + +// Resolve a client-supplied path and refuse anything outside content/lore. +function safePath(rel) { + if (!rel || typeof rel !== 'string') return null; + const abs = path.resolve(ROOT, rel); + if (abs !== CONTENT && !abs.startsWith(CONTENT + path.sep)) return null; + if (!abs.endsWith('.md')) return null; + return abs; +} +const relOf = (abs) => path.relative(ROOT, abs).replace(/\\/g, '/'); +const kindOf = (rel) => KINDS.find((k) => rel.startsWith(`content/lore/${k}/`)) || 'other'; + +async function listFiles() { + const out = []; + async function walk(dir) { + let entries = []; + try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { await walk(full); continue; } + if (!e.name.endsWith('.md') || e.name === 'SCHEMA.md') continue; + const rel = relOf(full); + out.push({ path: rel, kind: kindOf(rel), name: e.name.replace(/\.md$/, '') }); + } + } + await walk(CONTENT); + out.sort((a, b) => a.path.localeCompare(b.path)); + return out; +} + +// Cheap change signature (count + newest mtime) so the reader can poll often +// and only refetch the full data when the vault actually changed. +async function signature() { + let newest = 0; + let count = 0; + async function walk(dir) { + let entries = []; + try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { await walk(full); continue; } + if (!e.name.endsWith('.md') || e.name === 'SCHEMA.md') continue; + const st = await fs.stat(full); + newest = Math.max(newest, st.mtimeMs); + count += 1; + } + } + await walk(CONTENT); + return `${count}:${Math.round(newest)}`; +} + +function send(res, status, body) { + const json = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json', + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,PUT,POST,DELETE,OPTIONS', + 'access-control-allow-headers': 'content-type', + }); + res.end(json); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (c) => { data += c; if (data.length > 5e6) req.destroy(); }); + req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } }); + req.on('error', reject); + }); +} + +// Parse-only: validate and collect warnings WITHOUT rewriting the generated +// JSON. Mutations use this so saving an open editor doesn't churn a file the +// running Next app imports (which would trigger Fast Refresh and steal focus). +function warningsOnly() { + return buildLore({ write: false }).warnings; +} + +// Explicit rebuild: rewrite src/lore/generated/lore.json so the reader bundle +// reflects edits. This intentionally causes one HMR; only used on demand. +function rebuild() { + return buildLore({ write: true }).warnings; +} + +// Pull the parsed view of one file out of a (no-write) build, for live preview. +function viewOf(data, rel) { + const kind = kindOf(rel); + const base = path.basename(rel).replace(/\.md$/, ''); + if (kind === 'chapters') { + const ch = Object.values(data.chapters).find((c) => c.file === rel) || data.chapters[base]; + return { kind, chapter: ch || null }; + } + if (kind === 'books') { + const bk = Object.values(data.books).find((b) => b.file === rel) || data.books[base]; + return { kind, book: bk || null }; + } + if (kind === 'site') { + return { kind, site: data.landing || null }; + } + const ent = Object.values(data.entities).find((e) => rel.endsWith(`/${e.id}.md`)) || data.entities[base]; + return { kind, entity: ent || null }; +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://localhost:${PORT}`); + const route = url.pathname; + + if (req.method === 'OPTIONS') return send(res, 204, {}); + if (!route.startsWith('/api/lore/')) return send(res, 404, { error: 'not found' }); + + if (route === '/api/lore/ping') return send(res, 200, { ok: true, root: ROOT }); + + if (route === '/api/lore/tree' && req.method === 'GET') { + return send(res, 200, { files: await listFiles() }); + } + + if (route === '/api/lore/version' && req.method === 'GET') { + return send(res, 200, { sig: await signature() }); + } + + if (route === '/api/lore/data' && req.method === 'GET') { + const { data, warnings } = buildLore({ write: false }); + return send(res, 200, { data, warnings }); + } + + if (route === '/api/lore/file') { + if (req.method === 'GET') { + const abs = safePath(url.searchParams.get('path')); + if (!abs) return send(res, 400, { error: 'bad path' }); + try { + const content = await fs.readFile(abs, 'utf8'); + return send(res, 200, { path: relOf(abs), content }); + } catch { + return send(res, 404, { error: 'no such file' }); + } + } + if (req.method === 'PUT' || req.method === 'POST') { + const body = await readBody(req); + const abs = safePath(body.path); + if (!abs) return send(res, 400, { error: 'bad path' }); + const exists = await fs.access(abs).then(() => true).catch(() => false); + if (req.method === 'POST' && exists) return send(res, 409, { error: 'already exists' }); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, String(body.content ?? '')); + return send(res, 200, { ok: true, path: relOf(abs), warnings: warningsOnly() }); + } + if (req.method === 'DELETE') { + const abs = safePath(url.searchParams.get('path')); + if (!abs) return send(res, 400, { error: 'bad path' }); + await fs.unlink(abs).catch(() => {}); + return send(res, 200, { ok: true, warnings: warningsOnly() }); + } + } + + if (route === '/api/lore/rename' && req.method === 'POST') { + const body = await readBody(req); + const from = safePath(body.from); + const to = safePath(body.to); + if (!from || !to) return send(res, 400, { error: 'bad path' }); + await fs.mkdir(path.dirname(to), { recursive: true }); + await fs.rename(from, to); + return send(res, 200, { ok: true, path: relOf(to), warnings: warningsOnly() }); + } + + // On-demand: regenerate the reader bundle (one intentional HMR). + if (route === '/api/lore/rebuild' && req.method === 'POST') { + return send(res, 200, { ok: true, warnings: rebuild() }); + } + + if (route === '/api/lore/preview' && req.method === 'POST') { + const body = await readBody(req); + const abs = safePath(body.path); + if (!abs) return send(res, 400, { error: 'bad path' }); + const { data, warnings } = buildLore({ + overlay: { path: relOf(abs), content: String(body.content ?? '') }, + write: false, + }); + return send(res, 200, { ...viewOf(data, relOf(abs)), warnings }); + } + + return send(res, 404, { error: 'not found' }); + } catch (err) { + return send(res, 500, { error: String(err && err.message || err) }); + } +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`lore editor API: http://127.0.0.1:${PORT}/api/lore (vault: ${path.relative(process.cwd(), CONTENT)})`); + console.log('Leave this running next to `next dev`. Do not expose it publicly.'); +}); diff --git a/orbitmines.com/scripts/lore/gen-images.mjs b/orbitmines.com/scripts/lore/gen-images.mjs new file mode 100644 index 00000000..dfa83c7d --- /dev/null +++ b/orbitmines.com/scripts/lore/gen-images.mjs @@ -0,0 +1,75 @@ +// Generates placeholder SVGs for lore characters and book covers. +// Replace the output files by hand later — this only fills in dummies for ids +// that don't already have a real asset. +// +// node scripts/lore/gen-images.mjs +// +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const ASSETS = path.join(ROOT, 'public', 'lore-assets'); + +// Deterministic pleasant colour from a string. +function hue(str) { + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) % 360; + return h; +} + +function escapeXml(s) { + return s.replace(/[<>&'"]/g, (c) => ( + { '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c] + )); +} + +function avatar(id, label) { + const h = hue(id); + const a = `hsl(${h} 55% 28%)`; + const b = `hsl(${(h + 40) % 360} 60% 16%)`; + const fg = `hsl(${h} 70% 80%)`; + return ` + + + + + + + ${escapeXml(label)} +`; +} + +function cover(id, label) { + const h = hue(id); + const a = `hsl(${h} 50% 22%)`; + const b = `hsl(${(h + 30) % 360} 55% 10%)`; + const fg = `hsl(${h} 65% 82%)`; + // A5 ratio 1:1.414 + return ` + + + + + + ${escapeXml(label)} +`; +} + +function write(rel, svg) { + const file = path.join(ASSETS, rel); + if (fs.existsSync(file)) return; // don't clobber real art + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, svg); + console.log(' +', path.relative(ROOT, file)); +} + +const CHARACTERS = ['I', 'IA', 'IB', 'IC', 'II', 'IIA', 'IIB', 'IIC', 'IID', 'III', 'IIIA', 'IIIB', 'IIIC', 'S', 'B']; +const COVERS = { main: '★', I: 'I', II: 'II', III: 'III', S: 'S', B: 'B', ether: 'Eth', university: 'Uni' }; + +console.log('Generating placeholder lore assets...'); +for (const id of CHARACTERS) write(`characters/${id}.svg`, avatar(id, `[${id}]`)); +for (const [id, label] of Object.entries(COVERS)) write(`covers/${id}.svg`, cover(id, label)); +console.log('Done.'); diff --git a/orbitmines.com/scripts/lore/lore-core.mjs b/orbitmines.com/scripts/lore/lore-core.mjs new file mode 100644 index 00000000..777b0781 --- /dev/null +++ b/orbitmines.com/scripts/lore/lore-core.mjs @@ -0,0 +1,212 @@ +// Shared lore parser. Used by build-lore.mjs (CLI) and editor-server.mjs (dev +// API). buildLore() turns content/lore/**.md into the data object the reader +// consumes, optionally overlaying one in-memory file for live preview. +// +// See content/lore/SCHEMA.md for the authoring format. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import matter from 'gray-matter'; +import { marked } from 'marked'; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const CONTENT = path.join(ROOT, 'content', 'lore'); +export const OUT = path.join(ROOT, 'src', 'lore', 'generated', 'lore.json'); + +const PAGE_BREAK = /^[ \t]*[ \t]*$/im; +const CALLOUT = /^>\s*\[!([a-zA-Z]+)(?:\|([^\]]*))?\]\s*(.*)$/; +const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +marked.setOptions({ mangle: false, headerIds: false }); + +function escapeHtml(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +} + +// `overlay` (optional): { path: , content } substitutes (or +// injects, for a not-yet-saved new file) one file's body without touching disk. +function readDir(dir, overlay) { + const out = []; + const overlayRel = overlay ? overlay.path.replace(/\\/g, '/') : null; + let overlaySeen = false; + if (fs.existsSync(dir)) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { out.push(...readDir(full, overlay)); continue; } + if (!entry.name.endsWith('.md')) continue; + if (entry.name === 'SCHEMA.md') continue; + const rel = path.relative(ROOT, full).replace(/\\/g, '/'); + let text = fs.readFileSync(full, 'utf8'); + if (overlayRel && rel === overlayRel) { text = overlay.content; overlaySeen = true; } + out.push(parseFile(rel, text)); + } + } + // Inject a brand-new overlay file that lives in (or under) this directory. + if (overlayRel && !overlaySeen) { + const dirRel = path.relative(ROOT, dir).replace(/\\/g, '/') + '/'; + if (overlayRel.startsWith(dirRel) && !overlayRel.slice(dirRel.length).includes('/')) { + out.push(parseFile(overlayRel, overlay.content)); + overlay._injected = true; + } + } + return out; +} + +function parseFile(rel, text) { + const parsed = matter(text); + const id = parsed.data.id || path.basename(rel).replace(/\.md$/, ''); + return { id: String(id), data: parsed.data, body: parsed.content, file: rel }; +} + +export function buildLore(options = {}) { + const { overlay = null, write = false } = options; + const warnings = []; + const warn = (m) => warnings.push(m); + + const bookFiles = readDir(path.join(CONTENT, 'books'), overlay); + const chapterFiles = readDir(path.join(CONTENT, 'chapters'), overlay); + const characterFiles = readDir(path.join(CONTENT, 'characters'), overlay); + const codexFiles = readDir(path.join(CONTENT, 'codex'), overlay); + const siteFiles = readDir(path.join(CONTENT, 'site'), overlay); + + // ----- entity registry (characters + codex + books are link targets) ----- + const entities = {}; + const aliasMap = {}; + const registerAlias = (key, id) => { + const k = String(key).toLowerCase(); + if (aliasMap[k] && aliasMap[k] !== id) warn(`alias clash: "${key}" -> ${aliasMap[k]} & ${id}`); + aliasMap[k] = id; + }; + const resolve = (ref) => aliasMap[String(ref).trim().toLowerCase()] || null; + + for (const f of [...characterFiles, ...codexFiles]) { + const e = { + id: f.id, + type: f.data.type || 'character', + name: f.data.name || f.id, + role: f.data.role || '', + age: f.data.age ?? null, + image: f.data.image || null, + aliases: f.data.aliases || [], + relations: f.data.relations || [], + body: f.body.trim(), + descriptionHtml: '', + refs: [], + }; + entities[e.id] = e; + registerAlias(e.id, e.id); + for (const a of e.aliases) registerAlias(a, e.id); + if (e.name) registerAlias(e.name, e.id); + } + for (const b of bookFiles) registerAlias(b.id, b.id); + + // ----- wikilink + markdown rendering ----- + const renderWikilinks = (text, sink) => text.replace(WIKILINK, (_, rawRef, label) => { + const ref = rawRef.trim(); + const id = resolve(ref); + const display = (label != null ? label : (id && entities[id] ? entities[id].name : ref)).trim(); + if (!id) { + warn(`unresolved wikilink [[${rawRef}${label ? '|' + label : ''}]]`); + return `${escapeHtml(display)}`; + } + if (sink && !sink.includes(id)) sink.push(id); + return `${escapeHtml(display)}`; + }); + const renderMarkdown = (md, sink) => marked.parse(renderWikilinks(md, sink)).trim(); + const renderInline = (md, sink) => marked.parseInline(renderWikilinks(md, sink)).trim(); + + for (const e of Object.values(entities)) e.descriptionHtml = renderMarkdown(e.body, e.refs); + + // ----- chapters -> pages + facts ----- + const chapters = {}; + const facts = []; + for (const f of chapterFiles) { + const pageSources = f.body.split(PAGE_BREAK); + const pages = []; + pageSources.forEach((src, pageIndex) => { + const proseLines = []; + for (const line of src.split('\n')) { + const m = line.match(CALLOUT); + if (m) { + const [, type, csv, txt] = m; + const refs = []; + const html = renderInline(txt, refs); + const who = (csv ? csv.split(',') : []).map((w) => resolve(w)).filter(Boolean); + for (const w of who) if (!refs.includes(w)) refs.push(w); + facts.push({ + id: `${f.id}#${pageIndex}#${facts.filter((x) => x.chapterId === f.id && x.pageIndex === pageIndex).length}`, + type: type.toLowerCase(), who, refs, html, chapterId: f.id, pageIndex, + }); + } else { + proseLines.push(line); + } + } + const refs = []; + const html = renderMarkdown(proseLines.join('\n'), refs); + const pageFacts = facts.filter((x) => x.chapterId === f.id && x.pageIndex === pageIndex); + const allRefs = [...refs]; + for (const fc of pageFacts) for (const r of fc.refs) if (!allRefs.includes(r)) allRefs.push(r); + pages.push({ html, refs: allRefs, factIds: pageFacts.map((x) => x.id) }); + }); + chapters[f.id] = { + id: f.id, + title: f.data.title || f.id, + pov: f.data.pov ? resolve(f.data.pov) || f.data.pov : null, + summary: f.data.summary || '', + characters: (f.data.characters || []).map((c) => resolve(c) || c), + books: f.data.books || [], + pages, + file: f.file, + }; + } + + // ----- books -> ordered reading flow ----- + const books = {}; + for (const f of bookFiles) { + const chapterIds = (f.data.chapters || []).filter((cid) => { + if (!chapters[cid]) { warn(`book "${f.id}" lists unknown chapter "${cid}"`); return false; } + return true; + }); + let globalIndex = 0; + const flow = []; + for (const cid of chapterIds) { + chapters[cid].pages.forEach((_, pageIndex) => { + flow.push({ chapterId: cid, pageIndex, globalIndex: globalIndex++ }); + }); + } + books[f.id] = { + id: f.id, + title: f.data.title || f.id, + kind: f.data.kind || 'character', + subtitle: f.data.subtitle || '', + subtitleHtml: renderInline(f.data.subtitle || '', []), + cover: f.data.cover || null, + order: f.data.order ?? 999, + characters: (f.data.characters || []).map((c) => resolve(c) || c), + chapterIds, + descriptionHtml: renderMarkdown(f.body, []), + flow, + pageCount: flow.length, + file: f.file, + }; + } + + // ----- site: singleton landing config (heading + intro for /lore) ----- + const landingFile = siteFiles.find((f) => f.id === 'landing') || siteFiles[0]; + const landingTitle = landingFile?.data.title || 'The Library'; + const landingSubtitle = landingFile?.data.subtitle || ''; + const landing = { + title: landingTitle, + subtitle: landingSubtitle, + titleHtml: renderInline(landingTitle, []), + subtitleHtml: renderInline(landingSubtitle, []), + }; + + const data = { generatedAt: new Date().toISOString(), books, chapters, entities, facts, landing }; + + if (write) { + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify(data, null, 2)); + } + return { data, warnings }; +} diff --git a/orbitmines.com/src/@ether/UI/router/EtherOrMinimap.tsx b/orbitmines.com/src/@ether/UI/router/EtherOrMinimap.tsx index 5bf94d38..f834895d 100644 --- a/orbitmines.com/src/@ether/UI/router/EtherOrMinimap.tsx +++ b/orbitmines.com/src/@ether/UI/router/EtherOrMinimap.tsx @@ -2,9 +2,11 @@ import React from 'react'; import {useLocation} from 'react-router-dom'; import EtherRoutes from './EtherRoutes'; import Minimap from '../../../routes/Minimap'; +import Lore from '../../../lore/Lore'; // Catch-all that decides whether a path belongs to the ether surface -// (`/@user/...` or `/$...`) or falls back to the orbitmines.com Minimap. +// (`/@user/...` or `/$...`), the lore reader (`/lore/...`), or falls back to +// the orbitmines.com Minimap. // // Without this split, react-router would route every unmatched path to // Minimap, swallowing the ether URL space. Adding ether's URL shapes as @@ -15,6 +17,9 @@ const EtherOrMinimap: React.FC = () => { if (pathname.startsWith('/@') || pathname.startsWith('/$')) { return ; } + if (pathname === '/lore' || pathname.startsWith('/lore/')) { + return ; + } return ; }; diff --git a/orbitmines.com/src/lore/Lore.tsx b/orbitmines.com/src/lore/Lore.tsx new file mode 100644 index 00000000..700b241b --- /dev/null +++ b/orbitmines.com/src/lore/Lore.tsx @@ -0,0 +1,126 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import './lore.scss'; +import { applyLiveLore, getBook, knowledgeUpTo, loreVersion, subscribeLore } from './data'; +import { editorApi } from './editor/api'; +import { useProgress } from './useProgress'; +import { LoreNavProvider } from './LoreNav'; +import LoreLanding from './components/LoreLanding'; +import BookHome from './components/BookHome'; +import Reader from './components/Reader'; +import Codex from './components/Codex'; +import EntityDrawer from './components/EntityDrawer'; +import Editor from './editor/Editor'; + +// Client router for the whole /lore surface. Parses the path itself (the app +// is a static-export SPA served via Cloudflare's /* -> index.html fallback) and +// dispatches to landing / book home / reader / codex, with a global entity +// drawer overlaid on top. +const Lore: React.FC = () => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Re-render the whole lore surface when live edits arrive (dev only). + useSyncExternalStore(subscribeLore, loreVersion, () => 0); + + // Dev live-sync: poll the editor server for vault changes and apply them in + // place. This updates the reader/codex without rewriting the bundled + // lore.json (which would Fast-Refresh and steal editor focus). No-ops in + // production (no editor server; the static bundle is authoritative). + useEffect(() => { + if (process.env.NODE_ENV !== 'development') return; + let stopped = false; + let lastSig = ''; + let timer: ReturnType; + const poll = async () => { + if (!stopped && typeof document !== 'undefined' && !document.hidden) { + try { + const { sig } = await editorApi.version(); + if (sig !== lastSig) { + lastSig = sig; + const { data } = await editorApi.data(); + applyLiveLore(data); + } + } catch { /* editor server not running — stay on the bundled data */ } + } + if (!stopped) timer = setTimeout(poll, 1500); + }; + poll(); + return () => { stopped = true; clearTimeout(timer); }; + }, []); + + const segments = pathname.replace(/^\/lore\/?/, '').split('/').filter(Boolean); + const [bookId, view] = segments; + const book = bookId ? getBook(bookId) : undefined; + + // Progress is lifted here so the reader and the entity drawer share one + // source of truth for what the reader has uncovered. + const progress = useProgress(bookId || ''); + const knowledge = useMemo( + () => (book ? knowledgeUpTo(book, progress.furthest) : null), + [book, progress.furthest], + ); + + const openEntityId = searchParams.get('entity'); + + const goto = useCallback((path: string) => { + navigate(`/lore${path.startsWith('/') ? path : path ? `/${path}` : ''}`); + }, [navigate]); + + const openEntity = useCallback((id: string) => { + const params = new URLSearchParams(window.location.search); + params.set('entity', id); + navigate(`${window.location.pathname}?${params.toString()}`); + }, [navigate]); + + const closeEntity = useCallback(() => { + const params = new URLSearchParams(window.location.search); + params.delete('entity'); + const qs = params.toString(); + navigate(`${window.location.pathname}${qs ? `?${qs}` : ''}`); + }, [navigate]); + + const nav = useMemo( + () => ({ goto, openEntity, closeEntity, openEntityId }), + [goto, openEntity, closeEntity, openEntityId], + ); + + let content: React.ReactNode; + if (bookId === 'edit') { + content = ; + } else if (!bookId) { + content = ; + } else if (!book) { + content = ( +
+

No book named “{bookId}”.

+ +
+ ); + } else if (view === 'read') { + content = ; + } else if (view === 'codex') { + content = ; + } else { + content = ; + } + + return ( + +
+ {content} + {openEntityId && ( + <> +
+ + + )} +
+ + ); +}; + +export default Lore; diff --git a/orbitmines.com/src/lore/LoreNav.tsx b/orbitmines.com/src/lore/LoreNav.tsx new file mode 100644 index 00000000..b3b8faa8 --- /dev/null +++ b/orbitmines.com/src/lore/LoreNav.tsx @@ -0,0 +1,23 @@ +'use client'; + +import React, { createContext, useContext } from 'react'; + +// Shared navigation for the lore surface: page jumps and opening the entity +// drawer. Provided by Lore.tsx; consumed by every lore component so links and +// codex items behave consistently. +export interface LoreNav { + goto: (path: string) => void; // navigate within /lore (path relative to /lore) + openEntity: (id: string) => void; // open the entity drawer + closeEntity: () => void; + openEntityId: string | null; +} + +const Ctx = createContext(null); + +export const LoreNavProvider = Ctx.Provider; + +export function useLoreNav(): LoreNav { + const ctx = useContext(Ctx); + if (!ctx) throw new Error('useLoreNav must be used within Lore'); + return ctx; +} diff --git a/orbitmines.com/src/lore/components/BookHome.tsx b/orbitmines.com/src/lore/components/BookHome.tsx new file mode 100644 index 00000000..7df272bf --- /dev/null +++ b/orbitmines.com/src/lore/components/BookHome.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React from 'react'; +import { Button } from '@blueprintjs/core'; +import type { Book } from '../types'; +import { characterBooks, getBook, getChapter, getEntity, mainBooks } from '../data'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; + +// A selectable character on a book's homepage. If the character has their own +// book, selecting it opens that book; otherwise it opens their codex entry. +const CharacterPick: React.FC<{ id: string }> = ({ id }) => { + const { goto, openEntity } = useLoreNav(); + const entity = getEntity(id); + const ownBook = getBook(id); + if (!entity) return null; + return ( + + ); +}; + +const BookHome: React.FC<{ book: Book; furthest: number }> = ({ book, furthest }) => { + const { goto } = useLoreNav(); + const started = furthest >= 0; + const resumeChapter = started ? getChapter(book.flow[furthest]?.chapterId) : undefined; + + const siblings = + book.kind === 'character' + ? characterBooks().filter((b) => b.id !== book.id) + : []; + const home = mainBooks()[0]; + + return ( +
+ + +
+ {book.cover && {book.title}} +
+

{book.title}

+ {book.subtitle &&

{book.subtitle}

} + {book.descriptionHtml && } + +
+ + {started && ( + + )} + +
+
+
+ + {book.characters.length > 0 && ( +
+

…or follow a single character

+
+ {book.characters.map((id) => )} +
+
+ )} + + {book.kind === 'character' && ( +
+

Other accounts

+
+ {home && ( + + )} + {siblings.map((b) => )} +
+
+ )} +
+ ); +}; + +export default BookHome; diff --git a/orbitmines.com/src/lore/components/Codex.tsx b/orbitmines.com/src/lore/components/Codex.tsx new file mode 100644 index 00000000..075bbcff --- /dev/null +++ b/orbitmines.com/src/lore/components/Codex.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; +import { Button, Tag } from '@blueprintjs/core'; +import type { Book, Entity, EntityType } from '../types'; +import { entitiesByType, getChapter, getFact, lore, type Knowledge } from '../data'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; + +const TYPE_ORDER: { type: EntityType; label: string }[] = [ + { type: 'character', label: 'Characters' }, + { type: 'location', label: 'Places' }, + { type: 'organization', label: 'Organizations' }, + { type: 'concept', label: 'Concepts' }, + { type: 'event', label: 'Events' }, +]; + +const EntityCell: React.FC<{ entity: Entity }> = ({ entity }) => { + const { openEntity } = useLoreNav(); + return ( + + ); +}; + +const Codex: React.FC<{ book: Book; knowledge: Knowledge }> = ({ book, knowledge }) => { + const { goto } = useLoreNav(); + const [showLocked, setShowLocked] = useState(false); + + // Map a fact to its reading position in this book, for the timeline. + const factPosition = useMemo(() => { + const m = new Map(); + for (const e of book.flow) m.set(`${e.chapterId}#${e.pageIndex}`, e.globalIndex); + return m; + }, [book]); + + const timeline = useMemo(() => { + return lore.facts + .filter((f) => knowledge.factIds.has(f.id)) + .map((f) => ({ fact: f, pos: factPosition.get(`${f.chapterId}#${f.pageIndex}`) ?? Infinity })) + .filter((x) => x.pos !== Infinity) + .sort((a, b) => a.pos - b.pos); + }, [knowledge, factPosition]); + + const sortByFirstSeen = (a: Entity, b: Entity) => + (knowledge.firstSeen.get(a.id) ?? Infinity) - (knowledge.firstSeen.get(b.id) ?? Infinity); + + return ( +
+
+ + Codex + +
+ +

+ Everything you’ve discovered so far in {book.title}. Read further + to uncover more. +

+ + {TYPE_ORDER.map(({ type, label }) => { + const all = entitiesByType(type); + if (all.length === 0) return null; + const discovered = all.filter((e) => knowledge.entityIds.has(e.id)).sort(sortByFirstSeen); + const locked = all.filter((e) => !knowledge.entityIds.has(e.id)); + if (discovered.length === 0 && !showLocked) { + return ( +
+

{label} 0 / {all.length}

+

None discovered yet.

+
+ ); + } + return ( +
+

+ {label} {discovered.length} / {all.length} +

+
+ {discovered.map((e) => )} + {showLocked && locked.map((e) => ( +
+
?
+ undiscovered +
+ ))} +
+
+ ); + })} + + + + {timeline.length > 0 && ( +
+

What has happened

+
    + {timeline.map(({ fact }) => { + const f = getFact(fact.id)!; + const chapter = getChapter(f.chapterId); + return ( +
  1. + {f.type} + {chapter?.title} + +
  2. + ); + })} +
+
+ )} +
+ ); +}; + +export default Codex; diff --git a/orbitmines.com/src/lore/components/EntityDrawer.tsx b/orbitmines.com/src/lore/components/EntityDrawer.tsx new file mode 100644 index 00000000..5b86d67d --- /dev/null +++ b/orbitmines.com/src/lore/components/EntityDrawer.tsx @@ -0,0 +1,125 @@ +'use client'; + +import React from 'react'; +import { Button, Tag } from '@blueprintjs/core'; +import type { Book, Entity } from '../types'; +import { + getChapter, + getEntity, + knownFactsAbout, + knownFactsKnownBy, + type Knowledge, +} from '../data'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; + +const FactList: React.FC<{ title: string; facts: { id: string; type: string; html: string }[] }> = ({ title, facts }) => { + if (!facts.length) return null; + return ( +
+

{title}

+
    + {facts.map((f) => ( +
  • + {f.type} + +
  • + ))} +
+
+ ); +}; + +const EntityDrawer: React.FC<{ + entityId: string; + book: Book | undefined; + knowledge: Knowledge | null; +}> = ({ entityId, book, knowledge }) => { + const { closeEntity } = useLoreNav(); + const entity: Entity | undefined = getEntity(entityId); + + if (!entity) { + return ( +
+
+ Unknown entity +
+

No record for “{entityId}”.

+
+ ); + } + + const aboutFacts = knowledge ? knownFactsAbout(entity.id, knowledge) : []; + const knowsFacts = knowledge ? knownFactsKnownBy(entity.id, knowledge) : []; + + const firstSeen = knowledge?.firstSeen.get(entity.id); + const firstChapter = + book && firstSeen != null && book.flow[firstSeen] + ? getChapter(book.flow[firstSeen].chapterId) + : undefined; + const encountered = knowledge ? knowledge.entityIds.has(entity.id) : true; + + return ( +
+
+ {entity.type} +
+ +
+ {entity.image && {entity.name}} +
+

{entity.name}

+ {entity.role &&
{entity.role}
} + {entity.age != null &&
Age {entity.age}
} +
+
+ + {!encountered && knowledge && ( +

+ You haven’t encountered {entity.name} yet in this book. Showing only + their public profile. +

+ )} + + {firstChapter && ( +

+ First encountered in {firstChapter.title}. +

+ )} + + {entity.descriptionHtml && ( + + )} + + {entity.relations.length > 0 && ( +
+

Relations

+
    + {entity.relations.map((r, i) => ( + + ))} +
+
+ )} + + + +
+ ); +}; + +// Relations are short markdown strings ("sibling of [[II]]"); render inline. +const LoreHtmlRelation: React.FC<{ text: string }> = ({ text }) => { + const html = text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, ref, label) => { + const e = getEntity(String(ref).trim()); + const display = (label ?? (e ? e.name : ref)).trim(); + return e + ? `${display}` + : `${display}`; + }); + return
  • ; +}; + +export default EntityDrawer; diff --git a/orbitmines.com/src/lore/components/Graph.tsx b/orbitmines.com/src/lore/components/Graph.tsx new file mode 100644 index 00000000..25bd1968 --- /dev/null +++ b/orbitmines.com/src/lore/components/Graph.tsx @@ -0,0 +1,161 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { getChapter } from '../data'; +import { useLoreNav } from '../LoreNav'; +import type { Book, Chapter } from '../types'; + +// Subway-map of how chapters merge into books. Each book is a horizontal lane; +// each chapter a station placed along a shared global sequence. A chapter that +// several books include shows as a vertical "merge" connector across lanes — +// the convergence of the character accounts into the main story. +// +// Reused in two hosts: the editor (all stations clickable to edit) and the +// reader (gated by `revealed` — unread stations are anonymised/locked). + +export interface GraphProps { + books: Book[]; + /** chapterIds the viewer may see. Omit for "everything revealed" (editor). */ + revealed?: Set | null; + /** source file path of the currently-open doc, to highlight (editor). */ + selectedFile?: string | null; + onSelectChapter?: (chapter: Chapter) => void; + onSelectBook?: (book: Book) => void; + /** tighter rows for the editor strip. */ + embedded?: boolean; + /** live chapter lookup (editor) so the map reflects unsaved-bundle edits; + * falls back to the generated bundle when omitted (reader). */ + chapters?: Record; +} + +function hue(str: string): number { + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) % 360; + return h; +} +const laneColor = (id: string) => `hsl(${hue(id)} 70% 62%)`; + +const Graph: React.FC = ({ + books, revealed = null, selectedFile = null, onSelectChapter, onSelectBook, embedded = false, chapters, +}) => { + const { openEntity } = useLoreNav(); + const chapterOf = (id: string) => chapters?.[id] ?? getChapter(id); + const lanes = useMemo(() => books.filter((b) => b.chapterIds.length > 0), [books]); + + const MARGIN_LEFT = 150; + const MARGIN_TOP = embedded ? 34 : 48; + const COL_W = embedded ? 156 : 172; + const ROW_H = embedded ? 74 : 104; + const NODE_W = embedded ? 134 : 150; + + // Global column order: first appearance of each chapter across lanes. + const columns = useMemo(() => { + const seen: string[] = []; + for (const b of lanes) for (const c of b.chapterIds) if (!seen.includes(c)) seen.push(c); + return seen; + }, [lanes]); + const colIndex = useMemo(() => { + const m: Record = {}; + columns.forEach((c, i) => (m[c] = i)); + return m; + }, [columns]); + + const isRevealed = (cid: string) => !revealed || revealed.has(cid); + + const x = (col: number) => MARGIN_LEFT + col * COL_W + NODE_W / 2; + const y = (lane: number) => MARGIN_TOP + lane * ROW_H; + const width = MARGIN_LEFT + columns.length * COL_W + 40; + const height = MARGIN_TOP + lanes.length * ROW_H + 40; + + if (lanes.length === 0) { + return

    No books with chapters yet.

    ; + } + + return ( +
    +
    +
    + + {/* merge connectors */} + {columns.map((cid) => { + const ls: number[] = []; + lanes.forEach((b, li) => { if (b.chapterIds.includes(cid)) ls.push(li); }); + if (ls.length < 2) return null; + return ( + + ); + })} + {/* reading path within each lane */} + {lanes.map((b, li) => b.chapterIds.slice(1).map((c, i) => { + const prev = b.chapterIds[i]; + const lit = isRevealed(c) && isRevealed(prev); + return ( + + ); + }))} + + + {/* lane labels (centred on the lane line, like the stations) */} + {lanes.map((b, li) => ( + + ))} + + {/* stations — anchored by their centre on the lane line (y(li)) so + locked (fixed-height) and revealed (content-height) align. */} + {lanes.map((b, li) => b.chapterIds.map((c) => { + const ch = chapterOf(c); + const left = MARGIN_LEFT + colIndex[c] * COL_W; + const shown = isRevealed(c); + const selected = selectedFile && ch?.file === selectedFile; + if (!shown) { + return ( +
    + 🔒 +
    + ); + } + return ( + + ); + }))} + + {/* column headers */} + {columns.map((c, ci) => ( +
    + {isRevealed(c) ? c : '•'} +
    + ))} +
    +
    +
    + ); +}; + +export default Graph; diff --git a/orbitmines.com/src/lore/components/LoreHtml.tsx b/orbitmines.com/src/lore/components/LoreHtml.tsx new file mode 100644 index 00000000..38ee81bd --- /dev/null +++ b/orbitmines.com/src/lore/components/LoreHtml.tsx @@ -0,0 +1,30 @@ +'use client'; + +import React, { useCallback } from 'react'; +import { useLoreNav } from '../LoreNav'; + +// Renders generated lore HTML and turns [[wikilink]] anchors (rendered as +// ) into entity-drawer triggers via event +// delegation, so we don't have to hydrate every link individually. +const LoreHtml: React.FC<{ html: string; className?: string }> = ({ html, className }) => { + const { openEntity } = useLoreNav(); + + const onClick = useCallback((e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('a.lore-link') as HTMLElement | null; + if (!target) return; + const ref = target.getAttribute('data-ref'); + if (!ref) return; // broken links carry no data-ref + e.preventDefault(); + openEntity(ref); + }, [openEntity]); + + return ( +
    + ); +}; + +export default LoreHtml; diff --git a/orbitmines.com/src/lore/components/LoreLanding.tsx b/orbitmines.com/src/lore/components/LoreLanding.tsx new file mode 100644 index 00000000..e6fe47dc --- /dev/null +++ b/orbitmines.com/src/lore/components/LoreLanding.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React from 'react'; +import { Button } from '@blueprintjs/core'; +import { allBooks, landing } from '../data'; +import type { Book } from '../types'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; + +const Cover: React.FC<{ book: Book; className?: string; style?: React.CSSProperties }> = ({ + book, + className, + style, +}) => { + const { goto } = useLoreNav(); + // Main account: its title is shown as the section heading, so the cover only + // carries the subtitle code (e.g. "0.E2047.0A.1"), shown mono. + // Character books: drop the "— The Head" descriptor, keep just the "[I]" name. + const isMain = book.kind === 'main'; + return ( + + ); +}; + +/** True once mounted on a viewport wide enough for the arc layout. */ +const useWide = (min = 900): boolean => { + const [wide, setWide] = React.useState(false); + React.useEffect(() => { + const mq = window.matchMedia(`(min-width: ${min}px)`); + const update = () => setWide(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, [min]); + return wide; +}; + +/** + * Character books fanned along a downward "U" arc beneath the main story: + * the centre books sit lowest, the outer books rise and tilt away, so the + * row cradles the featured cover above it. + */ +const CharacterArc: React.FC<{ books: Book[] }> = ({ books }) => { + const n = books.length; + const SPREAD = 40; // half-width of the fan, in % of the container + const DROP = 130; // how far the centre books sink below the outer ones, in px + const TILT = 10; // max outward tilt of the outer books, in deg + + return ( +
    + {books.map((book, i) => { + const t = n > 1 ? (i / (n - 1)) * 2 - 1 : 0; // -1 .. 1 + const left = 50 + t * SPREAD; + const drop = DROP * (1 - t * t); // centre lowest, edges highest + const tilt = t * TILT; + return ( +
    + +
    + ); + })} +
    + ); +}; + +const LoreLanding: React.FC = () => { + const { goto } = useLoreNav(); + const books = allBooks(); + const main = books.filter((b) => b.kind === 'main'); + const character = books.filter((b) => b.kind === 'character'); + const site = landing(); + const mainTitle = main[0]?.title ?? 'The Main Story'; + const wide = useWide(); + + return ( +
    +
    +

    + {site.subtitleHtml && ( + + )} +
    + +
    +

    + + {wide ? ( +
    + {main.length > 0 && ( + <> +

    {mainTitle}

    +
    + {main.map((b) => ( + + ))} +
    + + )} + {character.length > 0 && ( + <> + +

    + Character Accounts +

    + + )} +
    + ) : ( + <> + {main.length > 0 && ( +
    +

    {mainTitle}

    +
    + {main.map((b) => )} +
    +
    + )} + + {character.length > 0 && ( +
    +

    Character Accounts

    +
    + {character.map((b) => )} +
    +
    + )} + + )} +
    + ); +}; + +export default LoreLanding; diff --git a/orbitmines.com/src/lore/components/Reader.tsx b/orbitmines.com/src/lore/components/Reader.tsx new file mode 100644 index 00000000..4c536dcc --- /dev/null +++ b/orbitmines.com/src/lore/components/Reader.tsx @@ -0,0 +1,163 @@ +'use client'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Tag } from '@blueprintjs/core'; +import { useSearchParams } from 'react-router-dom'; +import type { Book } from '../types'; +import { allBooks, getFact, pageAt } from '../data'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; +import Graph from './Graph'; + +const Reader: React.FC<{ + book: Book; + current: number; + furthest: number; + visit: (position: number) => void; +}> = ({ book, current, furthest, visit }) => { + const { goto } = useLoreNav(); + const [searchParams] = useSearchParams(); + const [pos, setPos] = useState(() => Math.min(Math.max(current, 0), Math.max(book.pageCount - 1, 0))); + const [showMap, setShowMap] = useState(false); + + // Honor ?p= once (deep links / "start over"), then mark it visited. + const honored = useRef(false); + useEffect(() => { + if (honored.current) return; + honored.current = true; + const p = searchParams.get('p'); + if (p != null && Number.isFinite(Number(p))) { + const clamped = Math.min(Math.max(Number(p), 0), Math.max(book.pageCount - 1, 0)); + setPos(clamped); + } + }, [searchParams, book.pageCount]); + + // Persist progress whenever the visible page changes. + useEffect(() => { visit(pos); }, [pos, visit]); + + const go = (delta: number) => setPos((p) => Math.min(Math.max(p + delta, 0), book.pageCount - 1)); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') go(1); + else if (e.key === 'ArrowLeft') go(-1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [book.pageCount]); + + const resolved = useMemo(() => pageAt(book, pos), [book, pos]); + const prev = pos > 0 ? pageAt(book, pos - 1) : null; + const isNewChapter = !prev || prev.entry.chapterId !== resolved?.entry.chapterId; + + if (book.pageCount === 0 || !resolved) { + return ( +
    + +

    This book has no pages yet.

    +
    + ); + } + + const { chapter, page } = resolved; + const pageFacts = page.factIds.map(getFact).filter(Boolean); + const atStart = pos === 0; + const atEnd = pos === book.pageCount - 1; + + // Map gating: a chapter is revealed once the reader has reached it. Other + // books appear as lanes only where they share an already-read chapter, so the + // merges light up as the reader uncovers them. + const revealedUpTo = Math.max(furthest, pos); + const revealed = new Set( + book.flow.filter((e) => e.globalIndex <= revealedUpTo).map((e) => e.chapterId), + ); + const mapBooks = allBooks().filter( + (b) => b.id === book.id || b.chapterIds.some((c) => revealed.has(c)), + ); + const jumpToChapter = (chapterId: string) => { + const entry = book.flow.find((e) => e.chapterId === chapterId); + if (entry) { setPos(entry.globalIndex); setShowMap(false); } + }; + + return ( +
    +
    + +
    + {chapter.pov && POV {chapter.pov}} + {chapter.title} +
    + + + + +
    + + {showMap ? ( +
    +

    + How far the accounts have merged, up to where you’ve read. Click a + chapter to jump there. +

    + jumpToChapter(ch.id)} /> +
    + ) : ( + <> +
    +
    + +
    + page {pos + 1} / {book.pageCount} +
    + + {pageFacts.length > 0 && ( + + )} + + )} +
    + ); +}; + +export default Reader; diff --git a/orbitmines.com/src/lore/data.ts b/orbitmines.com/src/lore/data.ts new file mode 100644 index 00000000..90dea5c1 --- /dev/null +++ b/orbitmines.com/src/lore/data.ts @@ -0,0 +1,120 @@ +// Typed access to the generated lore bundle, plus the knowledge-gating logic +// that powers the reader's progress-aware codex. +import raw from './generated/lore.json'; +import type { Book, Chapter, Entity, Fact, LoreData } from './types'; + +export const lore = raw as unknown as LoreData; + +let factById: Record = {}; +function indexFacts() { + factById = {}; + for (const f of lore.facts) factById[f.id] = f; +} +indexFacts(); + +// --- dev live-sync ---------------------------------------------------------- +// In `next dev`, the editor server is the source of truth (it parses the .md +// vault in-memory). The reader polls it (see LoreDevSync in Lore.tsx) and calls +// applyLiveLore() so edits appear immediately WITHOUT rewriting the bundled +// lore.json — which would trigger Fast Refresh and steal editor focus. The +// `lore` object identity is kept; its contents are replaced in place so all +// `import { lore }` consumers see the update on the next render. +const subscribers = new Set<() => void>(); +let liveVersion = 0; + +export function applyLiveLore(next: LoreData): void { + lore.books = next.books; + lore.chapters = next.chapters; + lore.entities = next.entities; + lore.facts = next.facts; + lore.generatedAt = next.generatedAt; + (lore as LoreData).landing = next.landing; + indexFacts(); + liveVersion += 1; + subscribers.forEach((fn) => fn()); +} + +export function subscribeLore(fn: () => void): () => void { + subscribers.add(fn); + return () => { subscribers.delete(fn); }; +} + +export const loreVersion = (): number => liveVersion; + +export const allBooks = (): Book[] => + Object.values(lore.books).sort((a, b) => a.order - b.order); + +export const mainBooks = (): Book[] => allBooks().filter((b) => b.kind === 'main'); +export const characterBooks = (): Book[] => allBooks().filter((b) => b.kind === 'character'); + +export const landing = (): LoreData['landing'] => lore.landing; + +export const getBook = (id: string): Book | undefined => lore.books[id]; +export const getChapter = (id: string): Chapter | undefined => lore.chapters[id]; +export const getEntity = (id: string): Entity | undefined => lore.entities[id]; +export const getFact = (id: string): Fact | undefined => factById[id]; + +export const entitiesByType = (type: Entity['type']): Entity[] => + Object.values(lore.entities).filter((e) => e.type === type); + +/** A reader's position: the furthest page reached in a book (its globalIndex). */ +export interface Progress { + bookId: string; + position: number; // inclusive globalIndex; -1 = not started +} + +export interface Knowledge { + /** Entity ids encountered up to and including the current position. */ + entityIds: Set; + /** Fact ids revealed up to the current position. */ + factIds: Set; + /** entityId -> globalIndex where it was first encountered. */ + firstSeen: Map; +} + +/** + * Everything a reader of `book` knows once they've read through `position` + * (inclusive). Gating is per page, so it reflects "part of a chapter" exactly. + */ +export function knowledgeUpTo(book: Book, position: number): Knowledge { + const entityIds = new Set(); + const factIds = new Set(); + const firstSeen = new Map(); + + for (const entry of book.flow) { + if (entry.globalIndex > position) break; + const chapter = lore.chapters[entry.chapterId]; + const page = chapter?.pages[entry.pageIndex]; + if (!page) continue; + for (const ref of page.refs) { + if (!firstSeen.has(ref)) firstSeen.set(ref, entry.globalIndex); + entityIds.add(ref); + } + for (const fid of page.factIds) factIds.add(fid); + } + return { entityIds, factIds, firstSeen }; +} + +/** Facts known so far that mention `entityId` — "what do I know about them?". */ +export function knownFactsAbout(entityId: string, knowledge: Knowledge): Fact[] { + return lore.facts.filter( + (f) => knowledge.factIds.has(f.id) && f.refs.includes(entityId), + ); +} + +/** Facts known so far attributed to `entityId` — "what do they know?". */ +export function knownFactsKnownBy(entityId: string, knowledge: Knowledge): Fact[] { + return lore.facts.filter( + (f) => knowledge.factIds.has(f.id) && f.who.includes(entityId), + ); +} + +/** Resolve a book's reading flow entry to its chapter + page objects. */ +export function pageAt(book: Book, position: number) { + const entry = book.flow[position]; + if (!entry) return null; + const chapter = lore.chapters[entry.chapterId]; + const page = chapter?.pages[entry.pageIndex]; + if (!chapter || !page) return null; + return { entry, chapter, page }; +} diff --git a/orbitmines.com/src/lore/editor/Editor.tsx b/orbitmines.com/src/lore/editor/Editor.tsx new file mode 100644 index 00000000..13e19a34 --- /dev/null +++ b/orbitmines.com/src/lore/editor/Editor.tsx @@ -0,0 +1,383 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Tag } from '@blueprintjs/core'; +import { useSearchParams } from 'react-router-dom'; +import './editor.scss'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from '../components/LoreHtml'; +import Graph from '../components/Graph'; +import { allBooks } from '../data'; +import type { LoreData } from '../types'; +import { editorApi, editorBase, setEditorBase, type PreviewResult, type VaultFile } from './api'; +import { splitFrontmatter, withFrontmatter, type FM } from './frontmatter'; +import FrontmatterForm, { type FormOptions } from './FrontmatterForm'; + +type Status = 'connecting' | 'online' | 'offline'; + +const KIND_ORDER = ['site', 'books', 'chapters', 'characters', 'codex', 'other']; +const KIND_LABEL: Record = { + site: 'Site', books: 'Books', chapters: 'Chapters', characters: 'Characters', codex: 'Codex', other: 'Other', +}; + +function template(kind: string, id: string): string { + switch (kind) { + case 'chapters': + return `---\nid: ${id}\ntitle: \npov: \nbooks: []\ncharacters: []\nsummary: \n---\n\nWrite the chapter here. Reference entities with [[id]] and split A5 pages with a line.\n`; + case 'books': + return `---\nid: ${id}\ntitle: \nkind: character\nsubtitle: \ncover: /lore-assets/covers/${id}.svg\norder: 99\nchapters: []\n---\n\nBack-cover blurb.\n`; + case 'characters': + return `---\nid: ${id}\ntype: character\nname: "${id}"\nrole: \nimage: /lore-assets/characters/${id}.svg\n---\n\nDescription.\n`; + default: + return `---\nid: ${id}\ntype: concept\nname: "${id}"\n---\n\nDescription.\n`; + } +} + +const Editor: React.FC = () => { + const { goto } = useLoreNav(); + const [searchParams] = useSearchParams(); + const [status, setStatus] = useState('connecting'); + const [files, setFiles] = useState([]); + const [selected, setSelected] = useState(null); + const [content, setContent] = useState(''); + const [baseline, setBaseline] = useState(''); + const [preview, setPreview] = useState(null); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + const [urlInput, setUrlInput] = useState(editorBase()); + const [newKind, setNewKind] = useState('chapters'); + const [newId, setNewId] = useState(''); + const [showMap, setShowMap] = useState(true); + const [loreData, setLoreData] = useState(null); + const [syncing, setSyncing] = useState(false); + const textareaRef = useRef(null); + + const dirty = content !== baseline; + const kind = useMemo(() => files.find((f) => f.path === selected)?.kind ?? 'other', [files, selected]); + + const refreshTree = useCallback(async () => { + const { files } = await editorApi.tree(); + setFiles(files); + return files; + }, []); + + // Live parsed snapshot for the merge map. Fetched from the server (in-memory, + // no file write) so editing doesn't churn the generated bundle / trigger HMR. + const fetchData = useCallback(async () => { + try { const { data } = await editorApi.data(); setLoreData(data); } catch { /* offline */ } + }, []); + + const connect = useCallback(async () => { + setStatus('connecting'); + try { + await editorApi.ping(); + await refreshTree(); + await fetchData(); + setStatus('online'); + } catch { + setStatus('offline'); + } + }, [refreshTree, fetchData]); + + useEffect(() => { connect(); }, [connect]); + + const open = useCallback(async (path: string) => { + try { + const { content } = await editorApi.read(path); + setSelected(path); + setContent(content); + setBaseline(content); + setMessage(''); + } catch (e) { + setMessage(String((e as Error).message)); + } + }, []); + + // Honor ?file= once we're online. + const honored = useRef(false); + useEffect(() => { + if (status !== 'online' || honored.current) return; + honored.current = true; + const f = searchParams.get('file'); + if (f) open(f); + }, [status, searchParams, open]); + + // Debounced live preview from the server (identical parse to production). + useEffect(() => { + if (status !== 'online' || !selected) return; + const t = setTimeout(() => { + editorApi.preview(selected, content).then(setPreview).catch(() => setPreview(null)); + }, 350); + return () => clearTimeout(t); + }, [content, selected, status]); + + const fm: FM = useMemo(() => splitFrontmatter(content).fm, [content]); + const onFormChange = useCallback((next: FM) => { + setContent((prev) => withFrontmatter(next, splitFrontmatter(prev).body)); + }, []); + + const options: FormOptions = useMemo(() => ({ + characters: files.filter((f) => f.kind === 'characters').map((f) => f.name), + books: files.filter((f) => f.kind === 'books').map((f) => f.name), + chapters: files.filter((f) => f.kind === 'chapters').map((f) => f.name), + entityTypes: ['character', 'event', 'location', 'concept', 'organization'], + }), [files]); + + const save = useCallback(async () => { + if (!selected) return; + setSaving(true); + try { + const { warnings } = await editorApi.save(selected, content); + setBaseline(content); + setMessage(warnings.length ? `Saved · ${warnings.length} warning(s): ${warnings[0]}` : ''); + fetchData(); + } catch (e) { + setMessage(`Save failed: ${(e as Error).message}`); + } finally { + setSaving(false); + } + }, [selected, content, fetchData]); + + // Regenerate the reader bundle on demand (one intentional HMR). + const syncReader = useCallback(async () => { + setSyncing(true); + try { await editorApi.rebuild(); setMessage('Reader synced.'); } + catch (e) { setMessage(`Sync failed: ${(e as Error).message}`); } + finally { setSyncing(false); } + }, []); + + const createFile = useCallback(async () => { + const id = newId.trim(); + if (!id) return; + const path = `content/lore/${newKind}/${id}.md`; + try { + await editorApi.create(path, template(newKind, id)); + setNewId(''); + await refreshTree(); + await fetchData(); + await open(path); + } catch (e) { + setMessage(`Create failed: ${(e as Error).message}`); + } + }, [newId, newKind, refreshTree, fetchData, open]); + + const remove = useCallback(async () => { + if (!selected || !window.confirm(`Delete ${selected}?`)) return; + await editorApi.remove(selected); + setSelected(null); setContent(''); setBaseline(''); setPreview(null); + await refreshTree(); + await fetchData(); + }, [selected, refreshTree, fetchData]); + + // Insert text at the textarea cursor (toolbar helpers). + const insert = useCallback((before: string, after = '', placeholder = '') => { + const ta = textareaRef.current; + if (!ta) return; + const s = ta.selectionStart, e = ta.selectionEnd; + const sel = content.slice(s, e) || placeholder; + const next = content.slice(0, s) + before + sel + after + content.slice(e); + setContent(next); + requestAnimationFrame(() => { + ta.focus(); + ta.selectionStart = s + before.length; + ta.selectionEnd = s + before.length + sel.length; + }); + }, [content]); + + // Keyboard save (Ctrl/Cmd+S) — also flushes the pending autosave immediately. + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') { ev.preventDefault(); save(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [save]); + + // Autosave: persist shortly after typing stops, so the Save button is never + // needed. The debounce coalesces keystrokes (and each save triggers a server + // rebuild). Skips when nothing changed. + useEffect(() => { + if (status !== 'online' || !selected || saving || content === baseline) return; + const t = setTimeout(() => { save(); }, 700); + return () => clearTimeout(t); + }, [content, baseline, selected, status, saving, save]); + + if (status !== 'online') { + return ( +
    +
    +

    Lore editor

    + {status === 'connecting' ? ( +

    Connecting to the editor server…

    + ) : ( + <> +

    + The editor needs its dev server. In orbitmines.com/ run: +

    +
    npm run lore:editor
    +
    + server URL + setUrlInput(e.target.value)} /> +
    +
    + + + +
    + + )} +
    +
    + ); + } + + const grouped = KIND_ORDER.map((k) => ({ k, items: files.filter((f) => f.kind === k) })).filter((g) => g.items.length); + const mapBooks = (loreData ? Object.values(loreData.books) : allBooks()) + .slice().sort((a, b) => a.order - b.order); + + return ( +
    +
    +
    + + {showMap && click a chapter to edit · click a lane for its book} + +
    + {showMap && ( + ch.file && open(ch.file)} + onSelectBook={(b) => b.file && open(b.file)} /> + )} +
    + +
    + + +
    + {!selected ? ( +
    Select or create a file to edit.
    + ) : ( + <> +
    + {selected} + + + {saving ? 'Saving…' : dirty ? 'Unsaved changes…' : 'All changes saved'} + +
    + +
    +
    +
    + + + + + + + +
    +