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..ee9e1137 --- /dev/null +++ b/orbitmines.com/content/lore/SCHEMA.md @@ -0,0 +1,107 @@ +# 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]]. Just write — the +chapter is split into A5 pages automatically (no page markers needed), for both +the web reader and the PDF. + + + +A `` line on its own is optional: it forces a page break where you +want one (auto-pagination still applies within each forced section). +``` + +### 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..07fd27a5 --- /dev/null +++ b/orbitmines.com/content/lore/books/B.md @@ -0,0 +1,12 @@ +--- +id: B +title: "Kyra Empson" +kind: character +subtitle: 0.B0.4F.1 +cover: /lore-assets/covers/B.svg +order: 5 +chapters: + - prologue +--- + +[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..2288cffe --- /dev/null +++ b/orbitmines.com/content/lore/books/I.md @@ -0,0 +1,14 @@ +--- +id: I +title: Jay Armac +kind: character +subtitle: 1.E2031.16G.1 +cover: /lore-assets/covers/I.svg +order: 1 +chapters: + - incident +characters: + - I +--- + +Future R. diff --git a/orbitmines.com/content/lore/books/II.md b/orbitmines.com/content/lore/books/II.md new file mode 100644 index 00000000..6b0d3025 --- /dev/null +++ b/orbitmines.com/content/lore/books/II.md @@ -0,0 +1,12 @@ +--- +id: II +title: "Hana Armac" +kind: character +subtitle: 1.E2024.23B.1 +cover: /lore-assets/covers/II.svg +order: 2 +chapters: + - II.1 +--- + +Sister of [[I]] \ No newline at end of file diff --git a/orbitmines.com/content/lore/books/III.md b/orbitmines.com/content/lore/books/III.md new file mode 100644 index 00000000..52b750aa --- /dev/null +++ b/orbitmines.com/content/lore/books/III.md @@ -0,0 +1,14 @@ +--- +id: III +title: Bjorne Specter +kind: character +subtitle: 0.E2026.0A.1 +cover: /lore-assets/covers/III.svg +order: 3 +chapters: + - interlude +characters: + - III +--- + +Dutch researcher \ No newline at end of file diff --git a/orbitmines.com/content/lore/books/S.md b/orbitmines.com/content/lore/books/S.md new file mode 100644 index 00000000..bc6f030d --- /dev/null +++ b/orbitmines.com/content/lore/books/S.md @@ -0,0 +1,13 @@ +--- +id: S +title: Raafar Kemmett +kind: character +subtitle: 0.S0.0A.1 +cover: /lore-assets/covers/S.svg +order: 4 +chapters: + - prologue + - solvergence +--- + +[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..d1bb6e04 --- /dev/null +++ b/orbitmines.com/content/lore/books/main.md @@ -0,0 +1,22 @@ +--- +id: main +title: The Main Story +kind: main +subtitle: 1.E2047.0A.1 +cover: /lore-assets/covers/main.jpg +order: 0 +characters: + - I + - II + - III + - S + - B +chapters: + - prologue + - II.1 + - incident + - interlude + - solvergence +--- + +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/II.1.md b/orbitmines.com/content/lore/chapters/II.1.md new file mode 100644 index 00000000..4c304ef8 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/II.1.md @@ -0,0 +1,64 @@ +--- +id: II.1 +title: II.1 +pov: II +books: + - II + - main +characters: + - II + - IIB + - IIC +summary: "" +--- +*It's difficult to imagine what life is like without certain technologies. They transform our world. They were once radical, and now they are obvious. For our parents it was the coming of computers and daresay the internet. For us it is its natural expansion as what is now known as the [[ether]].* +**2032** + +The door swung open loudly. Taking everyone's attention in the room for a brief moment. All the way in the back of the room sat two students who recognized the person who just entered. + +"Perfectly on time [[IIC]]!" shouted one of the two. + +"No need to embarass him further [[IIB]]". Said the other one. + +"There is in fact every need to embarass him further," teasingly adding in a slow voice: "[[II]]". + +[[II]] rolled her eyes in an over-the-top way. + +"A you're like this because it's a hoodie-day. I see." he fired at her. + +"You're making friends today." she shot a glance up, bringing the pen in her hand to her lips. "But you're right, it is in fact a hoodie-day." + +"Ah, here he comes waddling in" said [[IIB]] as their friend came up the stairs running towards them. + +Seeming to need air incredibly badly, out of breath, he quickly said: "You guys got-to see this." + +At the same time [[II]] and [[IIB]] started speaking: + +"I am seeing it." + +"See what?" + +They exchanged a quick glance, after which [[IIB]] shrugged. + +"You guys got-to see this." he repeated even faster. + +"See what?" repeated [[II]] too + +"We're supposed to be starting Distributed Systems 5 minutes ago." said [[IIB]] dismissively. + +"No seriously, here." as [[IIC]] quickly opened his backpack. And took out his laptop. + +"I got told by [[IIE]] about this just now. Apparently there's this new game - well he told me it was kind of a game or something - called the [[ether]]." + +"A game? The Ether as in The Nether? Minecraft?" said [[IIB]] jokingly. + +In a sarcastic tone the friend replied with "Yeah, yeah, of course, exactly, The Nether..." + +[[II]] intruigingly said: "As in the luminif- something aether? The physics thing? But then a ... game?" + +Before the friend could respond [[IIB]] shot a random: "Well [[IIE]]'s family is from The Netherlands right? Or should I say The Etherlands? That must be where they got the name from." + +[[II]] now looked at him once more glaringly, which caused a second shrug. + +"Hell if I know where they got the name from, what does it matter? Let me just show you." + diff --git a/orbitmines.com/content/lore/chapters/incident.md b/orbitmines.com/content/lore/chapters/incident.md new file mode 100644 index 00000000..838247e6 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/incident.md @@ -0,0 +1,11 @@ +--- +id: incident +title: "" +pov: I +books: + - main + - I +characters: + - I +summary: "" +--- diff --git a/orbitmines.com/content/lore/chapters/interlude.md b/orbitmines.com/content/lore/chapters/interlude.md new file mode 100644 index 00000000..6bf7d70f --- /dev/null +++ b/orbitmines.com/content/lore/chapters/interlude.md @@ -0,0 +1,14 @@ +--- +id: interlude +title: Interlude +pov: III +books: + - III + - main +characters: + - III +summary: "" +--- +***Regramance** - the state of reaching total reprogrammability. Criticality. A systems ability operate as a universal computer. In psychological terms partially achievable through means of psychosis.* +**[[2200.A]]** + diff --git a/orbitmines.com/content/lore/chapters/prologue.md b/orbitmines.com/content/lore/chapters/prologue.md new file mode 100644 index 00000000..e151d384 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/prologue.md @@ -0,0 +1,85 @@ +--- +id: prologue +title: Prologue +pov: S +books: + - main + - S + - B +characters: + - S + - B +summary: "" +--- + +*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?* +**Ether's [[2247.A]] 2247** + +The sun burned brightly even though it was an early spring day. In particular it seemed the rays of light had chosen a particular person that day, casually sitting on a bench in a small park. Perhaps it was the fact that he stood out particularly against the background. The brightest of green colors around him, against his casual black outfit which blended well with his skin. He remembered not exactly how he got there... by the time he became aware of himself, it seemed an age of the universe had gone by. + +Something gravitated him towards where he was now, though that too he couldn't recall. The only thing he could remember was the constant state of day, and the stories of others playing out in front of him. + +He could imagine the construction of the park, whilst he sat there in its center. - He had seen it being built after all - People coming and going all throughout that long day. + +Most were children, whose entire histories were being shaped within the park. Friendships born and die. The occasional fight had caused as much. Some groups larger than others. Even a pair, seemingly sisters, one younger than the other, were playing in one corner of the park. + +Nothing striked him particularly about any of the groups, of course the children didn't know who he was anyway. The occasional student wondered in the park, a few of them close to that sisterly pair. But they didn't seem to notice him either. + +Until at one point, he knew not what caused it, the elder of the sisters seemed to take the hand of one of those students. Together they explored the park. And naturally, they ended up where he was sitting. + +The child tugged at his knee. + +"Mister, mister" + +The student quickly took her hand away and said "I'm sorry professor. She's a little eager this one." + +He smiled. And slowly turned towards the girl. "What is it, little one?" + +It seemed like her original courage dissappeared as quickly as it had come to her as she quickly grabbed the leg of the student in shame. + +A quick laugh also came across the student's face. "She wants to know who you are professor." + +... + +"Professor, professor?" the students voice echoed into nothing. + +... + +The next instant he found himself, still on a bench, but in a completely different environment. Students scurrying all around him, though unlike the one from earlier, these ones barely recognized his existance. + +Once the hall cleared, still feeling dissociated. Another - this time demanding voice - called out to him. + +"Professor"... "Professor!" + +He came to his senses and looked up at the strong gravity of the voice. Curious to see what could have possibly produced it. + +"Ah, [[S]]! You're finally here with me." + +Evidently it matched the only person standing nearby, ponytailed with a distinct set of glasses, she looked almost the exact opposite of [[S]]. + +"I've been expecting you!" she said while pointing her finger at him while a big smile struck her face. + +"We've got a lot to learn from you," as she forcefully took his hand and lifted him up with a small grunt. "and not a lot of time to do it." + +"Now, 21 standard years ago." she stopped for a brief moment. And interrupted herself with: "Do you call them standard years yet?" - "No, no, perhaps not. Years then. 21 years." + +Looking at him briefly. Giving a hand gesture to start moving. + +"Like I was saying. 21 years ago I first heard about you. And recently we've been busy at getting the other professors at the university up to speed with your work. Of course it might take a long while before they're there. But in the meantime I'd like to get you started with your program for the students who've already come here - thanks to you. Now as I understand it, now that you've got something working, you're planning to massively upscale the number of students that follow your program?" + +He nodded. Though still in his dazy spell, he followed her as they walked the hallways of the university. + +"Good. I'll give you access to the resources I have at my disposal which will hopefully grant you proper time to make that work." + +She continued. "Now personally, I want to know how you got started in the first place. But we must first allow you to get this experiment, your 'system' as you call it? Up and running properly. I've got a meeting coming up with a couple of the other universities and they're all very excited about the prospects." + +They neared an auditorium, before going inside she stopped. + +"Oh, and I almost forgot, I'm called [[B]]. So now you know how to refer to me. This thing has got me quite busy, I'll let you know how that meeting goes. For now, we've got a room full of technically inclined students waiting to hear the latest from you. One word of caution though, education has stagnated across the universities and your new system might ruffle some feathers." + +And just like that she was gone. + +[[S]] stood there for a little while. Before heading towards the large door of the auditorium and placing his hand on the handle. + + + diff --git a/orbitmines.com/content/lore/chapters/solvergence.md b/orbitmines.com/content/lore/chapters/solvergence.md new file mode 100644 index 00000000..f5d56d06 --- /dev/null +++ b/orbitmines.com/content/lore/chapters/solvergence.md @@ -0,0 +1,14 @@ +--- +id: solvergence +title: ?? +pov: S +books: + - main + - S +characters: + - S +summary: "" +--- + +*Your entire reality, your entire story, - all of it - just a translation for what was actually happening.* +**[[III]] 2274** \ No newline at end of file diff --git a/orbitmines.com/content/lore/characters/B.md b/orbitmines.com/content/lore/characters/B.md new file mode 100644 index 00000000..cf606624 --- /dev/null +++ b/orbitmines.com/content/lore/characters/B.md @@ -0,0 +1,8 @@ +--- +id: B +type: character +name: "Kyra" +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..bb39eae1 --- /dev/null +++ b/orbitmines.com/content/lore/characters/I.md @@ -0,0 +1,14 @@ +--- +id: I +type: character +name: "Jay" +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..4f169041 --- /dev/null +++ b/orbitmines.com/content/lore/characters/II.md @@ -0,0 +1,15 @@ +--- +id: II +type: character +name: "Hana" +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/IIE.md b/orbitmines.com/content/lore/characters/IIE.md new file mode 100644 index 00000000..98445b55 --- /dev/null +++ b/orbitmines.com/content/lore/characters/IIE.md @@ -0,0 +1,9 @@ +--- +id: IIE +type: character +name: "IIE" +role: +image: /lore-assets/characters/IIE.svg +--- + +Description. diff --git a/orbitmines.com/content/lore/characters/III.md b/orbitmines.com/content/lore/characters/III.md new file mode 100644 index 00000000..5b6e7cc5 --- /dev/null +++ b/orbitmines.com/content/lore/characters/III.md @@ -0,0 +1,8 @@ +--- +id: III +type: character +name: "Bjorne" +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..7ed83dff --- /dev/null +++ b/orbitmines.com/content/lore/characters/S.md @@ -0,0 +1,9 @@ +--- +id: S +type: character +name: "Raafar" +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/2054.A.md b/orbitmines.com/content/lore/codex/2054.A.md new file mode 100644 index 00000000..34d8c1b6 --- /dev/null +++ b/orbitmines.com/content/lore/codex/2054.A.md @@ -0,0 +1,7 @@ +--- +id: 2054.A +type: concept +name: "The Future of Preservation" +--- + +*Then there is the possibility of the universe allowing physical access to history. Or must we always actively preserve? Exciting the world would be where preservation would be less costly, but the extend to which we would keep records would border on a disorder. We would put version control on the entire universe if we could - that is how much we value history.* \ No newline at end of file diff --git a/orbitmines.com/content/lore/codex/2200.A.md b/orbitmines.com/content/lore/codex/2200.A.md new file mode 100644 index 00000000..724b18a0 --- /dev/null +++ b/orbitmines.com/content/lore/codex/2200.A.md @@ -0,0 +1,8 @@ +--- +id: 2200.A +type: concept +name: "23rd century Ludus Dictionary" +--- + +*Regramance - the state of reaching total reprogrammability. Criticality. A systems ability operate as a universal computer. In psychological terms achievable through means of psychosis.* +**23rd century Ludus Dictionary** \ No newline at end of file diff --git a/orbitmines.com/content/lore/codex/2247.A.md b/orbitmines.com/content/lore/codex/2247.A.md new file mode 100644 index 00000000..d3c19c95 --- /dev/null +++ b/orbitmines.com/content/lore/codex/2247.A.md @@ -0,0 +1,7 @@ +--- +id: 2247.A +type: concept +name: "Bicentennial address" +--- + +*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?* 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..1fa4f9bf --- /dev/null +++ b/orbitmines.com/content/lore/site/landing.md @@ -0,0 +1,24 @@ +--- +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?*" +--- + +[@eu-west-8] Broadcasting... +07:32:03.013 INSTANCE @ether/@eu-west-8 > @public : EP, length 2cm + +07:32:03.013 INSTANCE @ether/@eu-west-8 > @public : EP, length 2cm + +07:32:03.013 INSTANCE @ether/@eu-west-8 > @public : EP, length 2cm + +07:32:03.014 INSTANCE @ether/@eu-west-8 > @public : EP, length 2cm + +e2e2e2f327f69667163502e2e2e202f3c6c61666e677f64402e2e2e202f3555555f4f49502562716024716867502e2e2e202e2e2e202e2e2e20237963797c616e61602e2e2e20237963797c616e61602e2e2e20237963797c616e61402e2e2e202d58636e61627260276e696a796c616964796e6965625b502e2e2e202e2e2e202e2e2e20212544514c4f435940244e4140245255465542502e2e2e202e2e2e202e2e2e202f32756473716d402e2e2e237e6f676162744021237569702e2e2e20237e6f67616274402e29727f647962727564702e677f6e6b6e65702e2e2e20256864702e69602e2e2e20256271602567502e2e2e202e246562796571756270247563756270227d2d2f602e2e2e20237963797c616e61402e2e2e202f3b627f677d24796d246964402e2e2e202e277f6e602d202f3b61656073702d202e61636029402e2e2e202e2e2e202f3379686470237724716867502e2e2e202e2e2e202e2e2e2cnntcmputetimeevrythngslwnw... + +07:40:13.529 INSTANCE @ether/@eu-west-8 > @me : EP, length 2cm + +07:40:13.530 INSTANCE @ether/@eu-west-8 > @me : EP, length 2cm + +07:40:13.530 INSTANCE @ether/@eu-west-8 > @me : EP, length 2cm + +[Ether 1.E2047.0A.1] Initializing... \ No newline at end of file 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..f6c6b9a0 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,15 @@ "typescript": "^5.9.3" }, "scripts": { + "lore": "npm run dev & npm run lore:editor", + "lore:build": "node scripts/lore/build-lore.mjs", + "lore:images": "node scripts/lore/gen-images.mjs", + "lore:pdf": "node scripts/lore/gen-pdf.mjs", + "lore:editor": "node --watch 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 && node scripts/lore/gen-pdf.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.jpg b/orbitmines.com/public/lore-assets/covers/main.jpg new file mode 100644 index 00000000..00652631 Binary files /dev/null and b/orbitmines.com/public/lore-assets/covers/main.jpg differ diff --git a/orbitmines.com/public/lore-assets/covers/main_placeholder.svg b/orbitmines.com/public/lore-assets/covers/main_placeholder.svg new file mode 100644 index 00000000..c9929c95 --- /dev/null +++ b/orbitmines.com/public/lore-assets/covers/main_placeholder.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/public/lore-assets/pdf/Ether\342\200\231s Universe - The Main Story.pdf" "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - The Main Story.pdf" new file mode 100644 index 00000000..8a073ede Binary files /dev/null and "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - The Main Story.pdf" differ diff --git "a/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [B] \342\200\224 The Head.pdf" "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [B] \342\200\224 The Head.pdf" new file mode 100644 index 00000000..8c931710 Binary files /dev/null and "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [B] \342\200\224 The Head.pdf" differ diff --git "a/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [III] \342\200\224 The Researcher.pdf" "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [III] \342\200\224 The Researcher.pdf" new file mode 100644 index 00000000..bef33b61 Binary files /dev/null and "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [III] \342\200\224 The Researcher.pdf" differ diff --git "a/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [II] \342\200\224 The Elder.pdf" "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [II] \342\200\224 The Elder.pdf" new file mode 100644 index 00000000..d58e2f97 Binary files /dev/null and "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [II] \342\200\224 The Elder.pdf" differ diff --git "a/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [I] \342\200\224 The Younger.pdf" "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [I] \342\200\224 The Younger.pdf" new file mode 100644 index 00000000..44ffcbfd Binary files /dev/null and "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [I] \342\200\224 The Younger.pdf" differ diff --git "a/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [S] \342\200\224 The Professor.pdf" "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [S] \342\200\224 The Professor.pdf" new file mode 100644 index 00000000..57545bd3 Binary files /dev/null and "b/orbitmines.com/public/lore-assets/pdf/Ether\342\200\231s Universe - [S] \342\200\224 The Professor.pdf" differ 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..d1ff7d33 --- /dev/null +++ b/orbitmines.com/scripts/lore/editor-server.mjs @@ -0,0 +1,231 @@ +// 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'; +import { generateBookPdfBuffer } from './lore-pdf.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() }); + } + + // Dev PDF: generate a fresh A5 PDF from the current pages, write it to + // public (so the production path is primed) and stream it as a download. + if (route === '/api/lore/pdf' && req.method === 'GET') { + const bookId = url.searchParams.get('book'); + if (!bookId) return send(res, 400, { error: 'book required' }); + const { data } = buildLore({ write: false }); + const book = data.books[bookId]; + if (!book) return send(res, 404, { error: 'unknown book' }); + const buf = await generateBookPdfBuffer(bookId, data); + const out = path.join(ROOT, 'public', 'lore-assets', 'pdf', `${book.pdfName}.pdf`); + await fs.mkdir(path.dirname(out), { recursive: true }); + await fs.writeFile(out, buf); + // Download name matches the production filename (RFC 5987 for unicode). + const name = `${book.pdfName}.pdf`; + const ascii = name.replace(/[^\x20-\x7E]/g, '_'); + res.writeHead(200, { + 'content-type': 'application/pdf', + 'content-disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`, + 'access-control-allow-origin': '*', + }); + return res.end(buf); + } + + 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/gen-pdf.mjs b/orbitmines.com/scripts/lore/gen-pdf.mjs new file mode 100644 index 00000000..5a8beebe --- /dev/null +++ b/orbitmines.com/scripts/lore/gen-pdf.mjs @@ -0,0 +1,28 @@ +// Writes ready-to-go A5 PDFs to public/lore-assets/pdf/ for every book (or one, +// if an id is passed). Files are named "{site} - {book}.pdf" (book.pdfName) so +// the production URL/download has a proper name. Wired into prebuild; also +// runnable via `npm run lore:pdf`. +// +// node scripts/lore/gen-pdf.mjs [bookId] +import fs from 'node:fs'; +import path from 'node:path'; +import { buildLore, ROOT } from './lore-core.mjs'; +import { generateBookPdfFile } from './lore-pdf.mjs'; + +const OUT_DIR = path.join(ROOT, 'public', 'lore-assets', 'pdf'); +const only = process.argv[2]; + +const { data } = buildLore({ write: false }); +const ids = (only ? [only] : Object.keys(data.books)).filter((id) => { + if (!data.books[id]) { console.warn('lore:pdf: unknown book', id); return false; } + return true; +}); + +// Start clean so renamed/removed books don't leave stale PDFs behind. +if (!only) fs.rmSync(OUT_DIR, { recursive: true, force: true }); + +for (const id of ids) { + const out = path.join(OUT_DIR, `${data.books[id].pdfName}.pdf`); + await generateBookPdfFile(id, out, data); + console.log('lore:pdf ->', path.relative(ROOT, out)); +} diff --git a/orbitmines.com/scripts/lore/lore-core.mjs b/orbitmines.com/scripts/lore/lore-core.mjs new file mode 100644 index 00000000..2df45932 --- /dev/null +++ b/orbitmines.com/scripts/lore/lore-core.mjs @@ -0,0 +1,288 @@ +// 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 PAGE_BREAK_LINE = /^[ \t]*[ \t]*$/; +const CALLOUT = /^>\s*\[!([a-zA-Z]+)(?:\|([^\]]*))?\]\s*(.*)$/; +const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +// Automatic A5 pagination: paragraphs are packed into pages sized to roughly an +// A5 text column at the reader's 9pt body — authors never place page breaks +// (an explicit is still honoured as a forced break). Tuned to the +// real A5 text area (~118mm × ~170mm at 9pt / 1.5): ~74 chars/line, ~34 lines. +const CHARS_PER_LINE = 74; +const LINES_PER_PAGE = 34; +function estimateLines(text) { + const len = text.replace(/\s+/g, ' ').trim().length; + return len ? Math.ceil(len / CHARS_PER_LINE) + 1 : 0; // +1 ≈ paragraph spacing +} + +marked.setOptions({ mangle: false, headerIds: false }); + +function escapeHtml(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +} + +// A chapter that opens with an italic paragraph treats that whole first +// paragraph (the contiguous block, up to the first blank line) as a header / +// epigraph: rendered smaller and tighter. We tag the very first

when it +// starts with emphasis. If that header contains a hard line break, whatever +// follows the LAST break (e.g. an attribution) is wrapped so it can be +// right-aligned — "— Author" on its own line under the epigraph. +function markLeadingHeader(html) { + return html.replace(/^(\s*)

(\s*<(?:em|i)>[\s\S]*?)<\/p>/, (_full, ws, inner) => { + // The epigraph's last line — split off by a hard break (
) OR a soft + // newline — becomes a right-aligned attribution; the rest flows as one line. + const parts = inner.split(/\s*(?:|\n)\s*/).filter((s) => s !== ''); + let body = inner; + if (parts.length > 1) { + const tail = parts.pop(); + body = `${parts.join(' ')}${tail}`; + } + return `${ws}

${body}

`; + }); +} + +// `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) { + // 1) Tokenise the body into paragraphs / reveal-callouts / forced breaks. + const tokens = []; + let para = []; + const flushPara = () => { if (para.join('\n').trim()) tokens.push({ type: 'para', text: para.join('\n') }); para = []; }; + for (const line of f.body.split('\n')) { + if (PAGE_BREAK_LINE.test(line)) { flushPara(); tokens.push({ type: 'break' }); continue; } + const cm = line.match(CALLOUT); + if (cm) { flushPara(); tokens.push({ type: 'callout', m: cm }); continue; } + if (line.trim() === '') { flushPara(); continue; } + para.push(line); + } + flushPara(); + + // 2) Pack paragraphs into A5-sized pages; callouts become facts on the + // current page (they take no visible space); honour forced breaks. + const pageParas = [[]]; + let pageIdx = 0; + let lineCount = 0; + const newPage = () => { pageIdx += 1; pageParas[pageIdx] = []; lineCount = 0; }; + for (const tok of tokens) { + if (tok.type === 'break') { + if (pageParas[pageIdx].length) newPage(); + continue; + } + if (tok.type === 'callout') { + const [, type, csv, txt] = tok.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}#${pageIdx}#${facts.filter((x) => x.chapterId === f.id && x.pageIndex === pageIdx).length}`, + type: type.toLowerCase(), who, refs, html, chapterId: f.id, pageIndex: pageIdx, + }); + continue; + } + // paragraph + let lines = estimateLines(tok.text); + // The opening italic epigraph carries extra spacing below it. + if (pageIdx === 0 && pageParas[0].length === 0 && /^\s*[*_]/.test(tok.text)) lines += 2; + if (lineCount > 0 && lineCount + lines > LINES_PER_PAGE) newPage(); + pageParas[pageIdx].push(tok.text); + lineCount += lines; + } + // Drop a trailing empty page (e.g. a break at the very end). + while (pageParas.length > 1 + && pageParas[pageParas.length - 1].length === 0 + && !facts.some((x) => x.chapterId === f.id && x.pageIndex === pageParas.length - 1)) { + pageParas.pop(); + } + + // 3) Render each page. + const pages = pageParas.map((paras, pageIndex) => { + const refs = []; + let html = renderMarkdown(paras.join('\n\n'), refs); + if (pageIndex === 0) html = markLeadingHeader(html); // epigraph only on page 1 + 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); + return { 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, []), + // The landing file's body — ambient "mystery" text shown dimmed in the + // background of the landing page. + contentHtml: renderMarkdown(landingFile?.body || '', []), + }; + + // The base filename a book's PDF is published under (URL + downloaded name), + // so browsers save it as "{site} - {book}.pdf" rather than the bare id. + const sanitizePdfName = (s) => String(s).replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, ' ').trim(); + for (const b of Object.values(books)) { + b.pdfName = `${sanitizePdfName(landingTitle)} - ${sanitizePdfName(b.title)}`; + } + + 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/scripts/lore/lore-pdf.mjs b/orbitmines.com/scripts/lore/lore-pdf.mjs new file mode 100644 index 00000000..a94a5612 --- /dev/null +++ b/orbitmines.com/scripts/lore/lore-pdf.mjs @@ -0,0 +1,216 @@ +// Generates an A5 PDF of a lore book using @react-pdf/renderer (Node). +// +// Used by the dev editor server (on-demand, reflecting current edits) and the +// gen-pdf.mjs build script (ready-to-go static PDFs for production). The reader +// never imports this — the heavy PDF renderer stays out of the client bundle. +// +// The cover (raster only) goes on the first page; each book page becomes one A5 +// page, with the generated page HTML converted to react-pdf primitives. +import fs from 'node:fs'; +import path from 'node:path'; +import React from 'react'; +import { Document, Page, View, Text, Image, Font, renderToFile, renderToBuffer } from '@react-pdf/renderer'; +import { buildLore, ROOT } from './lore-core.mjs'; + +const h = React.createElement; +const PUBLIC = path.join(ROOT, 'public'); + +// Use JetBrains Mono (the site's font) — shipped in public/fonts — embedded in +// the PDF so it matches the rest of OrbitMines. +const FONT = 'JetBrains Mono'; +const jbm = (file) => path.join(PUBLIC, 'fonts', file); +Font.register({ + family: FONT, + fonts: [ + { src: jbm('JetBrainsMono-Regular.ttf') }, + { src: jbm('JetBrainsMono-Bold.ttf'), fontWeight: 'bold' }, + { src: jbm('JetBrainsMono-Italic.ttf'), fontStyle: 'italic' }, + { src: jbm('JetBrainsMono-BoldItalic.ttf'), fontWeight: 'bold', fontStyle: 'italic' }, + ], +}); +// Monospace lines are long; let react-pdf break them rather than hyphenate. +Font.registerHyphenationCallback((word) => [word]); + +const INK = '#2b2620'; +const PAPER = '#f3ead8'; +const ACCENT = '#7a5a2c'; +const MUTED = '#6a6253'; + +const styles = { + page: { + backgroundColor: PAPER, color: INK, fontFamily: FONT, + fontSize: 9, lineHeight: 1.5, paddingTop: 48, paddingBottom: 54, + paddingHorizontal: 46, + }, + body: { flexGrow: 1 }, + p: { marginBottom: 2, textIndent: 28, textAlign: 'justify' }, + headerWrap: { marginBottom: 14, paddingHorizontal: 8 }, + header: { fontStyle: 'italic', fontSize: 10, textAlign: 'justify' }, + headerBy: { fontStyle: 'italic', fontSize: 10, textAlign: 'right', marginTop: 2 }, + h2: { fontWeight: 'bold', fontSize: 17, marginBottom: 12, textAlign: 'center' }, + eyebrow: { fontSize: 8, letterSpacing: 2, color: ACCENT, textAlign: 'center', marginBottom: 2 }, + blockquote: { marginLeft: 16, marginBottom: 9, fontStyle: 'italic', color: MUTED }, + li: { marginBottom: 3, flexDirection: 'row' }, + bullet: { width: 12 }, + hr: { borderBottomWidth: 1, borderBottomColor: '#d8cba8', marginVertical: 10 }, + foot: { + position: 'absolute', bottom: 26, left: 46, right: 46, + flexDirection: 'row', justifyContent: 'space-between', + fontSize: 8, color: MUTED, fontStyle: 'italic', + }, + cover: { backgroundColor: '#000' }, + coverImg: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }, + coverFallback: { + backgroundColor: PAPER, color: INK, justifyContent: 'center', alignItems: 'center', + padding: 48, fontFamily: FONT, + }, + coverTitle: { fontWeight: 'bold', fontSize: 28, textAlign: 'center', marginBottom: 12 }, + coverSub: { fontStyle: 'italic', fontSize: 13, color: MUTED, textAlign: 'center' }, +}; + +// ---- minimal HTML -> react-pdf ------------------------------------------- +function decodeEntities(s) { + return s + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'|'/g, "'").replace(/ /g, ' ') + .replace(/—/g, '—').replace(/…/g, '…') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))); +} + +function spanStyle(flags) { + // Bold/italic come from fontWeight/fontStyle against the registered family. + const s = { fontFamily: FONT }; + if (flags.b) s.fontWeight = 'bold'; + if (flags.i) s.fontStyle = 'italic'; + if (flags.link) s.color = ACCENT; + return s; +} + +// Inline HTML (within a block) -> children for a . Unformatted runs are +// emitted as bare strings (not nested ) so textAlign: 'justify' works — +// react-pdf won't justify a Text whose children are all nested Text nodes. +function inlineSpans(html) { + const out = []; + const flags = { b: false, i: false, link: false }; + let key = 0; + const re = /<(\/?)(em|i|strong|b|a|span)\b[^>]*?(class="[^"]*")?[^>]*>||([^<]+)/gi; + let m; + while ((m = re.exec(html))) { + if (m[4] != null) { + const t = decodeEntities(m[4]); + if (!t) continue; + const styled = flags.b || flags.i || flags.link; + out.push(styled ? h(Text, { key: key++, style: spanStyle(flags) }, t) : t); + } else if (/^
]*)>([\s\S]*?)<\/\1>|<(ul|ol)\b[^>]*>([\s\S]*?)<\/\4>|/gi; + let m; + while ((m = re.exec(html))) { + if (m[1]) out.push({ tag: m[1].toLowerCase(), attrs: m[2] || '', inner: m[3] }); + else if (m[4]) { + const items = [...m[5].matchAll(/]*>([\s\S]*?)<\/li>/gi)].map((x) => x[1]); + out.push({ tag: 'list', items }); + } else out.push({ tag: 'hr' }); + } + return out; +} + +function renderBlock(b, key) { + if (b.tag === 'hr') return h(View, { key, style: styles.hr }); + if (b.tag === 'h2' || b.tag === 'h3') return h(Text, { key, style: styles.h2 }, inlineSpans(b.inner)); + if (b.tag === 'blockquote') return h(Text, { key, style: styles.blockquote }, inlineSpans(stripTags(b.inner))); + if (b.tag === 'list') { + return h(View, { key, style: { marginBottom: 9 } }, + ...b.items.map((it, i) => + h(View, { key: i, style: styles.li }, + h(Text, { style: styles.bullet }, '• '), + h(Text, { style: { flex: 1 } }, inlineSpans(it))))); + } + // paragraph (maybe the epigraph header) + const isHeader = /lore-page__header/.test(b.attrs); + if (isHeader) { + // Pull the attribution ("— Author") onto its own right-aligned line. + const byMatch = b.inner.match(/([\s\S]*?)<\/span>/i); + const bodyHtml = (byMatch ? b.inner.replace(byMatch[0], '') : b.inner).replace(/(\s*)+$/i, ''); + return h(View, { key, style: styles.headerWrap }, + h(Text, { style: styles.header }, inlineSpans(bodyHtml)), + byMatch ? h(Text, { style: styles.headerBy }, inlineSpans(byMatch[1])) : null); + } + return h(Text, { key, style: styles.p }, inlineSpans(b.inner)); +} + +function stripTags(html) { + // blockquote inner may wrap a

; unwrap for inline rendering. + return html.replace(/<\/?p[^>]*>/gi, '').trim(); +} + +// ---- pages ---------------------------------------------------------------- +function coverPage(book) { + const raster = book.cover && /\.(png|jpe?g)$/i.test(book.cover); + const abs = raster ? path.join(PUBLIC, book.cover.replace(/^\//, '')) : null; + if (abs && fs.existsSync(abs)) { + return h(Page, { key: 'cover', size: 'A5', style: styles.cover }, + h(Image, { src: abs, style: styles.coverImg })); + } + return h(Page, { key: 'cover', size: 'A5', style: styles.coverFallback }, + h(Text, { style: styles.coverTitle }, book.title), + book.subtitle ? h(Text, { style: styles.coverSub }, book.subtitle) : null); +} + +// One wrapping A5 Page per chapter: react-pdf auto-paginates (and splits text) +// to fill each page, so no manual page breaks are needed and pages aren't left +// half-empty. The footer is `fixed`, repeating on every page of the chapter. +function chapterDoc(data, chapterId, key) { + const ch = data.chapters[chapterId]; + if (!ch || !ch.pages.length) return null; + const blockEls = [ + h(Text, { key: 'eyebrow', style: styles.eyebrow }, 'CHAPTER'), + h(Text, { key: 'chtitle', style: styles.h2 }, ch.title), + ]; + let bi = 0; + for (const pg of ch.pages) { + for (const b of blocks(pg.html)) { const el = renderBlock(b, bi++); if (el) blockEls.push(el); } + } + return h(Page, { key, size: 'A5', style: styles.page, wrap: true }, + h(View, { style: styles.body }, ...blockEls), + h(View, { style: styles.foot, fixed: true }, + h(Text, {}, ch.title), + h(Text, { render: ({ pageNumber }) => `${pageNumber - 1}` })), + ); +} + +export function buildBookDocument(data, bookId) { + const book = data.books[bookId]; + if (!book) throw new Error(`Unknown book: ${bookId}`); + const pages = book.chapterIds + .map((cid, i) => chapterDoc(data, cid, `c${i}`)) + .filter(Boolean); + return h(Document, { title: book.title, author: 'OrbitMines' }, coverPage(book), ...pages); +} + +// Build the lore data fresh (reflecting current files) unless one is supplied. +function dataOf(data) { return data ?? buildLore({ write: false }).data; } + +export async function generateBookPdfBuffer(bookId, data) { + return renderToBuffer(buildBookDocument(dataOf(data), bookId)); +} + +export async function generateBookPdfFile(bookId, outPath, data) { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + await renderToFile(buildBookDocument(dataOf(data), bookId), outPath); + return outPath; +} 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..72db12cd --- /dev/null +++ b/orbitmines.com/src/lore/Lore.tsx @@ -0,0 +1,124 @@ +'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 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 === 'codex') { + content = ; + } else { + // No more per-book homepage: /lore/ opens the reader directly. + 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/Codex.tsx b/orbitmines.com/src/lore/components/Codex.tsx new file mode 100644 index 00000000..02e4a089 --- /dev/null +++ b/orbitmines.com/src/lore/components/Codex.tsx @@ -0,0 +1,135 @@ +'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'; +import LoreMeta, { toText } from './LoreMeta'; + +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; embedded?: boolean }> = ({ book, knowledge, embedded }) => { + 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 ( +
+ {!embedded && ( + + )} + {embedded ? ( +

Codex

+ ) : ( +
+ + 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..75b2777a --- /dev/null +++ b/orbitmines.com/src/lore/components/EntityDrawer.tsx @@ -0,0 +1,173 @@ +'use client'; + +import React from 'react'; +import { Button, Tag } from '@blueprintjs/core'; +import type { Book, Entity, Fact } 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: Fact[]; + pageOf: (f: Fact) => number | null; +}> = ({ title, facts, pageOf }) => { + if (!facts.length) return null; + return ( +
+

{title}

+
    + {facts.map((f) => { + const page = pageOf(f); + return ( +
  • + {page != null && p. {page}} + {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}”.

+
+ ); + } + + // Page numbers come from the book currently being read, and a fact is only + // shown if it has a page in *this* book — so progress made in another book + // never leaks into this sheet. + const pageByKey = React.useMemo(() => { + const m = new Map(); + if (book) for (const e of book.flow) m.set(`${e.chapterId}#${e.pageIndex}`, e.globalIndex + 1); + return m; + }, [book]); + const pageOf = (f: Fact): number | null => pageByKey.get(`${f.chapterId}#${f.pageIndex}`) ?? null; + const inThisBook = (f: Fact) => pageByKey.has(`${f.chapterId}#${f.pageIndex}`); + + // Most recently learned first: latest page, then latest fact within a page. + const nOf = (f: Fact) => Number(f.id.split('#')[2]) || 0; + const byRecency = (a: Fact, b: Fact) => + (pageOf(b)! - pageOf(a)!) || (nOf(b) - nOf(a)); + + const aboutFacts = (knowledge ? knownFactsAbout(entity.id, knowledge) : []).filter(inThisBook).sort(byRecency); + const knowsFacts = (knowledge ? knownFactsKnownBy(entity.id, knowledge) : []).filter(inThisBook).sort(byRecency); + + 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; + + // Relations are "revealed" once you've encountered the other party — a dummy + // gating rule for now (to be replaced by explicit reveal callouts later). Each + // shows the page where it became knowable, newest first. + const refsOf = (text: string): string[] => { + const ids: string[] = []; + text.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, (m, ref) => { + const e = getEntity(String(ref).trim()); + if (e && !ids.includes(e.id)) ids.push(e.id); + return m; + }); + return ids; + }; + const relationHtml = (text: string): string => + text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, ref, label) => { + const e = getEntity(String(ref).trim()); + const display = (label ?? (e ? e.name : ref)).trim(); + return e + ? `${display}` + : `${display}`; + }); + const revealedRelations = (encountered && knowledge ? entity.relations : []) + .map((text) => { + const refs = refsOf(text); + if (refs.length === 0 || !refs.every((id) => knowledge!.entityIds.has(id))) return null; + const seen = [entity.id, ...refs] + .map((id) => knowledge!.firstSeen.get(id)) + .filter((x): x is number => x != null); + const page = seen.length ? Math.max(...seen) + 1 : null; + return { text, page, html: relationHtml(text) }; + }) + .filter((r): r is { text: string; page: number | null; html: string } => r != null) + .sort((a, b) => (b.page ?? -1) - (a.page ?? -1)); + + 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 && ( + + )} + + {revealedRelations.length > 0 && ( +
+

Relations

+
    + {revealedRelations.map((r, i) => ( +
  • + {r.page != null && p. {r.page}} + +
  • + ))} +
+
+ )} + + + +
+ ); +}; + +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..cc38057e --- /dev/null +++ b/orbitmines.com/src/lore/components/Graph.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { getChapter, getEntity } 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; + /** entity ids the reader has discovered. A POV shows its name once discovered, + * otherwise just its id. Omit (editor) to always show names. */ + discovered?: Set | null; +} + +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, + discovered = null, +}) => { + 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; + // POV: show the character's name once discovered, else just the id. + const pov = ch?.pov ?? null; + const povDiscovered = pov ? (discovered ? discovered.has(pov) : !revealed) : false; + const povLabel = pov && povDiscovered ? (getEntity(pov)?.name ?? pov) : pov; + 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..9ecdf4c1 --- /dev/null +++ b/orbitmines.com/src/lore/components/LoreLanding.tsx @@ -0,0 +1,208 @@ +'use client'; + +import React from 'react'; +import { Button } from '@blueprintjs/core'; +import { useNavigate } from 'react-router-dom'; +import { allBooks, getBook, getChapter, landing } from '../data'; +import type { Book } from '../types'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; +import LoreMeta, { toText } from './LoreMeta'; +import { lastReadBookId, readFurthest } from '../useProgress'; + +const Cover: React.FC<{ book: Book; className?: string; style?: React.CSSProperties; feature?: boolean }> = ({ + book, + className, + style, + feature, +}) => { + const { goto } = useLoreNav(); + // Character books carry a "[X] — The Younger" descriptor; strip it for the + // cover, keeping just the "[X]". Main books keep their full title. + const isMain = book.kind === 'main'; + const title = isMain ? book.title : book.title.split('—')[0].trim(); + const titleEl =
{title}
; + const subtitleEl = book.subtitleHtml ? ( +
+ ) : null; + return ( + + ); +}; + +// The CONTINUE / START READING call-to-action shown beside the featured book. +// Reads the resume point from localStorage after mount (so SSR stays stable). +const Continue: React.FC<{ book: Book }> = ({ book }) => { + const { goto } = useLoreNav(); + const [furthest, setFurthest] = React.useState(-1); + React.useEffect(() => { setFurthest(readFurthest(book.id)); }, [book.id]); + + const empty = book.pageCount === 0; + const started = furthest >= 0; + const pos = started ? Math.min(furthest, book.pageCount - 1) : 0; + const entry = book.flow[pos]; + const chapter = entry ? getChapter(entry.chapterId) : undefined; + + return ( + + ); +}; + +/** Whether the viewport is wide enough for the arc layout. Resolved + * synchronously on the client so the first painted layout is already correct. */ +const useWide = (min = 900): boolean => { + const [wide, setWide] = React.useState( + () => typeof window !== 'undefined' && window.matchMedia(`(min-width: ${min}px)`).matches, + ); + 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; +}; + +/** Order books so the main stories land in the middle of the row. */ +const centerMains = (books: Book[]): Book[] => { + const mains = books.filter((b) => b.kind === 'main'); + const others = books.filter((b) => b.kind !== 'main'); + const half = Math.ceil(others.length / 2); + return [...others.slice(0, half), ...mains, ...others.slice(half)]; +}; + +/** + * The non-featured books fanned along a downward "U" arc beneath the feature: + * centre books sit lowest, outer books rise and tilt away, cradling the + * featured cover above. Main stories are placed in the middle (see centerMains). + */ +const BookArc: 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 navigate = useNavigate(); + const books = allBooks(); + const site = landing(); + const wide = useWide(); + + // The book layout depends on client-only state (viewport width + which book + // was last read), so it can't be prerendered correctly. Render it only after + // mount — until then a neutral placeholder holds the space — so the books are + // painted directly in their final positions instead of snapping there. + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { setMounted(true); }, []); + + // Feature = most recently read book; falls back to the main story, then the + // first book. Resolved synchronously so the first painted layout is correct. + const [featureId] = React.useState(() => lastReadBookId()); + const main = books.find((b) => b.kind === 'main'); + const feature = + (featureId ? getBook(featureId) : undefined) || main || books[0]; + + if (!feature) return null; + const below = centerMains(books.filter((b) => b.id !== feature.id)); + + return ( +
+ + +
+ )} + + + {!mounted ? ( +
+ ) : wide ? ( +
+ {site.contentHtml && ( + + )} +
+ + +
+ {below.length > 0 && } +
+ ) : ( + <> + {/* Continue CTA is omitted on small screens — tapping the cover reads. */} +
+ +
+ {below.length > 0 && ( +
+ {below.map((b) => )} +
+ )} + {site.contentHtml && ( + + )} + + )} +
+ ); +}; + +export default LoreLanding; diff --git a/orbitmines.com/src/lore/components/LoreMeta.tsx b/orbitmines.com/src/lore/components/LoreMeta.tsx new file mode 100644 index 00000000..aa8b9c2b --- /dev/null +++ b/orbitmines.com/src/lore/components/LoreMeta.tsx @@ -0,0 +1,55 @@ +'use client'; + +import React from 'react'; + +// Per-view decoration (title + description + Open Graph + Twitter), the +// same way Post.tsx does it: React 19 hoists these into , so the static +// prerender of each enumerated /lore route ships proper meta for crawlers and +// link unfurlers (Discord/Twitter/etc.). + +const SITE = 'https://orbitmines.com'; +const abs = (p: string) => (/^https?:/i.test(p) ? p : `${SITE}${p}`); + +// Strip HTML/markdown to a plain, length-capped description string. +export const toText = (html: string, max = 200): string => { + const t = (html || '') + .replace(/<[^>]+>/g, '') + .replace(/&[a-z]+;|&#\d+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t; +}; + +const LoreMeta: React.FC<{ + title: string; + description: string; + pathname: string; + image?: string | null; + type?: string; +}> = ({ title, description, pathname, image, type = 'website' }) => { + const url = abs(pathname.replace(/\/+$/, '') || '/lore'); + // OG/Twitter previews need a raster image; SVG covers fall back to the logo. + const img = image && /\.(png|jpe?g)(\?|$)/i.test(image) ? abs(image) : `${SITE}/logo.png`; + return ( + <> + {title} + + + + + + + + + + + + + + + + + ); +}; + +export default LoreMeta; diff --git a/orbitmines.com/src/lore/components/Reader.tsx b/orbitmines.com/src/lore/components/Reader.tsx new file mode 100644 index 00000000..3cf8f728 --- /dev/null +++ b/orbitmines.com/src/lore/components/Reader.tsx @@ -0,0 +1,204 @@ +'use client'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@blueprintjs/core'; +import { useSearchParams } from 'react-router-dom'; +import type { Book } from '../types'; +import { allBooks, knowledgeUpTo, lore, pageAt } from '../data'; +import { useLoreNav } from '../LoreNav'; +import LoreHtml from './LoreHtml'; +import LoreMeta, { toText } from './LoreMeta'; +import Graph from './Graph'; +import Codex from './Codex'; +import { useMeasuredPages, splitTopLevel, type PageBlock } from './useMeasuredPages'; +import { editorBase } from '../editor/api'; + +// A subtle ".pdf" download. In dev it asks the editor server to generate a fresh +// A5 PDF; in production it links the pre-generated static file. +const DownloadPdf: React.FC<{ book: Book }> = ({ book }) => { + const href = process.env.NODE_ENV === 'development' + ? `${editorBase()}/api/lore/pdf?book=${encodeURIComponent(book.id)}` + : `/lore-assets/pdf/${encodeURIComponent(book.pdfName)}.pdf`; + return
.pdf; +}; + +const Chevron: React.FC<{ dir: 'left' | 'right' }> = ({ dir }) => ( + + + +); + +const Reader: React.FC<{ + book: Book; + current: number; // resume point, as a build-page globalIndex + furthest: number; // furthest build-page globalIndex reached (gates the codex) + visit: (globalIndex: number) => void; +}> = ({ book, current, furthest, visit }) => { + const { goto } = useLoreNav(); + const [, setSearchParams] = useSearchParams(); + + // Flatten the whole book into blocks in reading order, then measure-paginate. + const blocks = useMemo(() => { + const out: PageBlock[] = []; + for (const e of book.flow) { + const ch = lore.chapters[e.chapterId]; + const pg = ch?.pages[e.pageIndex]; + if (!ch || !pg) continue; + for (const html of splitTopLevel(pg.html)) { + out.push({ html, gi: e.globalIndex, chapterId: e.chapterId, chapterTitle: ch.title }); + } + } + return out; + }, [book]); + + const { pages: displayPages, probe } = useMeasuredPages(blocks, 'lore-page'); + + // ----- navigation over display pages -------------------------------------- + const pageCount = displayPages.length; + const maxPos = Math.max(pageCount - 1, 0); + const clampPos = (n: number) => Math.min(Math.max(n, 0), maxPos); + const [pos, setPos] = useState(0); + const ready = useRef(false); + const lastSync = useRef(null); + + const urlPage = (): number | null => { + const raw = new URLSearchParams(window.location.search).get('page'); + return raw != null && raw !== '' && Number.isFinite(Number(raw)) ? Number(raw) - 1 : null; + }; + + // Once measured, resume from ?page (display index) or saved progress (a build + // globalIndex → the first display page that reaches it). + useEffect(() => { + if (ready.current || pageCount === 0) return; + ready.current = true; + const fromUrl = urlPage(); + if (fromUrl != null) { const p = clampPos(fromUrl); lastSync.current = p; setPos(p); return; } + const idx = displayPages.findIndex((d) => d.gi >= current); + setPos(idx < 0 ? 0 : clampPos(idx)); + }, [pageCount]); // eslint-disable-line react-hooks/exhaustive-deps + + // pos -> URL (replace first, push after, so Back/Forward turn pages). + useEffect(() => { + if (!ready.current || lastSync.current === pos) return; + const replace = lastSync.current === null; + lastSync.current = pos; + setSearchParams((prev) => { const n = new URLSearchParams(prev); n.set('page', String(pos + 1)); return n; }, { replace }); + }, [pos, setSearchParams]); + + useEffect(() => { + const onPop = () => { const p = urlPage(); if (p != null && clampPos(p) !== lastSync.current) { lastSync.current = clampPos(p); setPos(clampPos(p)); } }; + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); + }, [maxPos]); // eslint-disable-line react-hooks/exhaustive-deps + + // Persist progress as the build globalIndex this display page reaches. + useEffect(() => { if (displayPages[pos]) visit(displayPages[pos].gi); }, [pos, displayPages, visit]); + + const go = (delta: number) => setPos((p) => clampPos(p + delta)); + + 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); + }, [maxPos]); // eslint-disable-line react-hooks/exhaustive-deps + + const touch = useRef<{ x: number; y: number } | null>(null); + const onTouchStart = (e: React.TouchEvent) => { const t = e.touches[0]; touch.current = { x: t.clientX, y: t.clientY }; }; + const onTouchEnd = (e: React.TouchEvent) => { + const s = touch.current; touch.current = null; + if (!s) return; + const t = e.changedTouches[0]; + const dx = t.clientX - s.x, dy = t.clientY - s.y; + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) go(dx < 0 ? 1 : -1); + }; + + // Fallback to the first build page until measurement completes (e.g. SSR). + const dp = displayPages[clampPos(pos)]; + const fallback = !dp ? pageAt(book, 0) : null; + const chapterTitle = dp ? dp.chapterTitle : fallback?.chapter.title ?? ''; + const chapterStart = dp ? dp.chapterStart : true; + const bodyHtml = dp ? dp.html : fallback?.page.html ?? ''; + const giNow = dp ? dp.gi : 0; + const count = pageCount || 1; + const atStart = pos <= 0; + const atEnd = pos >= maxPos; + + if (book.pageCount === 0) { + return ( +
+ +

This book has no pages yet.

+
+ ); + } + + // Gating uses the build globalIndex reached (or the furthest, if ahead). + const revealedUpTo = Math.max(furthest, giNow); + 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 knowledge = knowledgeUpTo(book, revealedUpTo); + + const jumpToChapter = (chapterId: string) => { + const idx = displayPages.findIndex((d) => d.chapterId === chapterId); + if (idx >= 0) { setPos(idx); window.scrollTo({ top: 0, behavior: 'smooth' }); } + }; + + return ( +
+ + + {/* Off-screen probe that measures the real A5 page for pagination. */} + {probe} + +
+ + +
+ +
+
+ + +
+
+ {chapterStart && ( +
+
Chapter
+

{chapterTitle}

+
+ )} + +
+ {chapterTitle} + {pos + 1} / {count} +
+
+
+ + +
+
+ +
+

Merge map

+

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

+ jumpToChapter(ch.id)} /> +
+ +
+ +
+
+ ); +}; + +export default Reader; diff --git a/orbitmines.com/src/lore/components/useMeasuredPages.tsx b/orbitmines.com/src/lore/components/useMeasuredPages.tsx new file mode 100644 index 00000000..3d3a8435 --- /dev/null +++ b/orbitmines.com/src/lore/components/useMeasuredPages.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +// Shared A5 pagination by measurement: pack content blocks into pages that fill +// a real A5 page (reserving the chapter header on chapter-start pages and the +// footer), measured against an off-screen clone of the page. Used by the reader +// and by the editor preview so both fill pages identically. + +const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +export interface PageBlock { + html: string; + gi: number; // source build-page globalIndex (reader progress/gating) + chapterId: string; + chapterTitle: string; +} + +export interface MeasuredPage { + html: string; + chapterId: string; + chapterTitle: string; + chapterStart: boolean; + gi: number; +} + +// Split a chapter page's HTML into its top-level block elements. +export function splitTopLevel(html: string): string[] { + if (typeof document === 'undefined') return html ? [html] : []; + const tmp = document.createElement('div'); + tmp.innerHTML = html; + return Array.from(tmp.children).map((el) => (el as HTMLElement).outerHTML); +} + +export function useMeasuredPages(blocks: PageBlock[], measureClassName = 'lore-page') { + const pageRef = useRef(null); + const bodyRef = useRef(null); + const headerRef = useRef(null); + const footerRef = useRef(null); + const [pages, setPages] = useState([]); + const [tick, setTick] = useState(0); + + // Re-measure on resize and once webfonts load (metrics change). + useEffect(() => { + const bump = () => setTick((t) => t + 1); + window.addEventListener('resize', bump); + (document as Document & { fonts?: { ready: Promise } }).fonts?.ready.then(bump).catch(() => {}); + return () => window.removeEventListener('resize', bump); + }, []); + + useIso(() => { + const page = pageRef.current, body = bodyRef.current; + if (!page || !body || blocks.length === 0) { setPages([]); return; } + const cs = getComputedStyle(page); + const inner = page.clientHeight - parseFloat(cs.paddingTop || '0') - parseFloat(cs.paddingBottom || '0'); + const footerH = footerRef.current?.offsetHeight ?? 0; + const headerH = headerRef.current?.offsetHeight ?? 0; + if (inner <= 0) return; // not laid out yet — a later tick retries + + const out: MeasuredPage[] = []; + let i = 0; + let guard = 0; + while (i < blocks.length && guard++ < blocks.length + 5000) { + const chapterStart = i === 0 || blocks[i].chapterId !== blocks[i - 1].chapterId; + const { chapterId, chapterTitle } = blocks[i]; + const avail = inner - footerH - (chapterStart ? headerH : 0); + body.innerHTML = ''; + let gi = blocks[i].gi; + const startI = i; + while (i < blocks.length && blocks[i].chapterId === chapterId) { + body.insertAdjacentHTML('beforeend', blocks[i].html); + if (body.scrollHeight > avail && body.childElementCount > 1) { + body.lastElementChild?.remove(); // overflowed — push to next page + break; + } + gi = blocks[i].gi; + i += 1; + } + if (i === startI) { body.insertAdjacentHTML('beforeend', blocks[i].html); gi = blocks[i].gi; i += 1; } + out.push({ html: body.innerHTML, chapterId, chapterTitle, chapterStart, gi }); + } + body.innerHTML = ''; + setPages(out); + }, [blocks, tick]); + + // The off-screen page the consumer renders once; its size drives the measure. + const probe = ( +
+
+
Chapter

{' '}

+
+
+
{' '}0 / 0
+
+ ); + + return { pages, probe }; +} 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..4fb68bf4 --- /dev/null +++ b/orbitmines.com/src/lore/editor/Editor.tsx @@ -0,0 +1,461 @@ +'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 { useMeasuredPages, splitTopLevel, type PageBlock } from '../components/useMeasuredPages'; +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]]; it's split into A5 pages automatically.\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 [treeOrder, setTreeOrder] = useState>({}); + const dragPath = useRef(null); + const textareaRef = useRef(null); + + // Manual per-kind ordering of the vault tree (drag to reorder; editor-local). + useEffect(() => { + try { const raw = localStorage.getItem('lore:treeOrder'); if (raw) setTreeOrder(JSON.parse(raw)); } catch { /* ignore */ } + }, []); + + 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 applyOrder = (kind: string, items: VaultFile[]) => { + const saved = treeOrder[kind] || []; + const rank = (p: string) => { const i = saved.indexOf(p); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; + return [...items].sort((a, b) => (rank(a.path) - rank(b.path)) || a.name.localeCompare(b.name)); + }; + const onDragStartItem = (e: React.DragEvent, f: VaultFile) => { + dragPath.current = f.path; e.dataTransfer.effectAllowed = 'move'; + }; + const onDragOverItem = (e: React.DragEvent, f: VaultFile) => { + const from = dragPath.current; + if (from && files.find((x) => x.path === from)?.kind === f.kind) e.preventDefault(); // only within a kind + }; + const onDropItem = (e: React.DragEvent, f: VaultFile) => { + e.preventDefault(); + const from = dragPath.current; dragPath.current = null; + if (!from || from === f.path || files.find((x) => x.path === from)?.kind !== f.kind) return; + const ordered = applyOrder(f.kind, files.filter((x) => x.kind === f.kind)).map((x) => x.path); + const fi = ordered.indexOf(from), ti = ordered.indexOf(f.path); + if (fi < 0 || ti < 0) return; + ordered.splice(ti, 0, ordered.splice(fi, 1)[0]); + const next = { ...treeOrder, [f.kind]: ordered }; + setTreeOrder(next); + try { localStorage.setItem('lore:treeOrder', JSON.stringify(next)); } catch { /* ignore */ } + }; + + const grouped = KIND_ORDER.map((k) => ({ k, items: applyOrder(k, 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'} + +
+ +
+
+
+ + + + + + + +
+