diff --git a/.gitignore b/.gitignore index f3657ad..98de87d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -.od-skills/ -*.artifact.json +node_modules/ +dist/ +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/.od-skills/blog-post-2d43614db1/SKILL.md b/.od-skills/blog-post-2d43614db1/SKILL.md new file mode 100644 index 0000000..2eed0aa --- /dev/null +++ b/.od-skills/blog-post-2d43614db1/SKILL.md @@ -0,0 +1,80 @@ +--- +name: blog-post +description: | + A long-form article / blog post — masthead, hero image placeholder, + article body with figures and pull quotes, author byline, related posts. + Use when the brief asks for "blog", "article", "post", "essay", or + "case study". +triggers: + - "blog" + - "blog post" + - "article" + - "essay" + - "case study" + - "newsletter" + - "博客" + - "文章" +od: + mode: prototype + platform: desktop + scenario: marketing + preview: + type: html + entry: index.html + design_system: + requires: true + sections: [color, typography, layout, components] + craft: + requires: [typography, typography-hierarchy, typography-hierarchy-editorial, rtl-and-bidi] +--- + +# Blog Post Skill + +Produce a single long-form article page — editorial layout, no chrome. + +## Workflow + +1. **Read the active DESIGN.md** (injected above). Lean into the typography + tokens — long-form is 70% type, 20% image, 10% chrome. +2. **Pick the topic** from the brief and write a real article — at least 600 + words across 4–6 H2 sections. No lorem ipsum. +3. **Sections**, in order: + - **Masthead** — small wordmark + 4–6 nav links, plain. + - **Article header** — category eyebrow, headline (display token, large), + deck (1–2 sentence subhead), author name + role + date. + - **Hero image** — a 16:9 placeholder block using a DS-tinted gradient or + solid fill (no external images). Add a 1-line caption underneath. + - **Body** — alternating prose paragraphs with at least: + - 1 pull quote (large display type, accent rule on the inline-start edge so the layout flips correctly under `dir="rtl"`). + - 1 figure (image placeholder + caption). + - 1 list (numbered or bulleted). + - 1 inline blockquote. + - **Author footer** — author avatar (initials in a circle), bio paragraph. + - **Related** — 3 cards linking to other posts. Each card: tiny image + block, title, 1-line excerpt, date. +4. **Write** a single HTML document: + - `` through ``, CSS inline. + - Article body uses the DS body font, centered, max-width per DS layout + rule (typically 680–720px). + - Drop caps (`first-letter`) only if the DS mood is editorial / serif — + skip on tech-y DSes. + - `data-od-id` on the headline, hero, body, pull quote, related grid. +5. **Self-check**: + - Type hierarchy is unambiguous — H1 is clearly the headline; H2s are + section dividers; pull quotes do not compete with H1. + - Line length 60–75 chars for body prose. + - Accent appears at most twice (eyebrow + pull-quote rule, or one link). + - The page reads like a magazine, not a marketing landing. + +## Output contract + +Emit between `` tags: + +``` + + +... + +``` + +One sentence before the artifact, nothing after. diff --git a/.od-skills/blog-post-2d43614db1/example.html b/.od-skills/blog-post-2d43614db1/example.html new file mode 100644 index 0000000..a8e1e46 --- /dev/null +++ b/.od-skills/blog-post-2d43614db1/example.html @@ -0,0 +1,80 @@ + + + + + + Why we rewrote our sync engine in Rust — Filebase + + + +
+ +
Engineering
+

Why we rewrote our sync engine in Rust

+ +

For two years our Go sync engine was good enough. Then video editors started joining the customer list, and the GC pauses we'd been politely ignoring turned into bug reports we couldn't ignore.

+
+ +

The decision wasn't sudden. We'd been watching the GC pause distribution shift for six months before we admitted what the data was telling us. P50 latency was great. P99 was a horror movie. Customers syncing 30 GB of .psd files in active editing sessions were the ones writing in.

+ +

Rewriting an entire sync engine sounds like the kind of project a startup is told never to do. We did it anyway. Here's how it went, what surprised us, and the parts I'd do differently.

+ +

The trigger: GC pauses we couldn't fix

+

Go's garbage collector is brilliant. It is also, fundamentally, a tradeoff. Our hot path allocated short-lived buffer slices on every block diff — and at our scale, on a heavy uploader, the collector ran often enough that the P99 pause crept past 50ms.

+ +

We tried the usual fixes: pooling buffers with sync.Pool, tuning GOGC, reducing allocations in the merge path. They each helped a little. None of them got us under 20ms, and the customers we cared about needed under 5.

+ +
"We can't fix this in Go. We can fix it in something without a GC."
+ +

Our staff engineer Sasha said this in a meeting in October. He was right. The question wasn't whether to leave Go. It was what to leave it for, and how much we could keep.

+ +

What we kept; what we threw out

+

The CLI stayed in Go. The control plane stayed in Go. The bit that does block-level diffing in a hot loop on a customer's laptop — that became Rust. The boundary became a single FFI surface with a small, opinionated protocol.

+ +
+
38ms → 4ms
P99 sync latency
+
62%
Memory drop
+
11 weeks
From RFC to ship
+
+ +

The numbers above are real and from production. They are also misleading without context: the Rust port doesn't just remove the GC, it also removes a layer of abstraction we'd been carrying since the Go MVP.

+ +

What I'd do differently

+

One thing: the FFI boundary. We chose cgo for symmetry — Go calling Rust feels right when you already have Go everywhere. But the binding ceremony is brittle, and we ate two production incidents from string lifetime mistakes before we wrote a wrapper layer that handled them once.

+ +

If I were starting today, I'd reach for uniffi or generate the bindings from a schema. The lessons isn't don't use cgo; it's treat the boundary like an external API the moment you cross language families.

+ +
Filebase is hiring engineers who like writing this kind of post. See open roles →
+
+ + diff --git a/.od-skills/blog-post/SKILL.md b/.od-skills/blog-post/SKILL.md new file mode 100644 index 0000000..aebd088 --- /dev/null +++ b/.od-skills/blog-post/SKILL.md @@ -0,0 +1,79 @@ +--- +name: blog-post +description: | + A long-form article / blog post — masthead, hero image placeholder, + article body with figures and pull quotes, author byline, related posts. + Use when the brief asks for "blog", "article", "post", "essay", or + "case study". +triggers: + - "blog" + - "blog post" + - "article" + - "essay" + - "case study" + - "newsletter" + - "博客" + - "文章" +od: + mode: prototype + platform: desktop + scenario: marketing + featured: 11 + preview: + type: html + entry: index.html + design_system: + requires: true + sections: [color, typography, layout, components] +--- + +# Blog Post Skill + +Produce a single long-form article page — editorial layout, no chrome. + +## Workflow + +1. **Read the active DESIGN.md** (injected above). Lean into the typography + tokens — long-form is 70% type, 20% image, 10% chrome. +2. **Pick the topic** from the brief and write a real article — at least 600 + words across 4–6 H2 sections. No lorem ipsum. +3. **Sections**, in order: + - **Masthead** — small wordmark + 4–6 nav links, plain. + - **Article header** — category eyebrow, headline (display token, large), + deck (1–2 sentence subhead), author name + role + date. + - **Hero image** — a 16:9 placeholder block using a DS-tinted gradient or + solid fill (no external images). Add a 1-line caption underneath. + - **Body** — alternating prose paragraphs with at least: + - 1 pull quote (large display type, accent rule on the left). + - 1 figure (image placeholder + caption). + - 1 list (numbered or bulleted). + - 1 inline blockquote. + - **Author footer** — author avatar (initials in a circle), bio paragraph. + - **Related** — 3 cards linking to other posts. Each card: tiny image + block, title, 1-line excerpt, date. +4. **Write** a single HTML document: + - `` through ``, CSS inline. + - Article body uses the DS body font, centered, max-width per DS layout + rule (typically 680–720px). + - Drop caps (`first-letter`) only if the DS mood is editorial / serif — + skip on tech-y DSes. + - `data-od-id` on the headline, hero, body, pull quote, related grid. +5. **Self-check**: + - Type hierarchy is unambiguous — H1 is clearly the headline; H2s are + section dividers; pull quotes do not compete with H1. + - Line length 60–75 chars for body prose. + - Accent appears at most twice (eyebrow + pull-quote rule, or one link). + - The page reads like a magazine, not a marketing landing. + +## Output contract + +Emit between `` tags: + +``` + + +... + +``` + +One sentence before the artifact, nothing after. diff --git a/.od-skills/blog-post/example.html b/.od-skills/blog-post/example.html new file mode 100644 index 0000000..a8e1e46 --- /dev/null +++ b/.od-skills/blog-post/example.html @@ -0,0 +1,80 @@ + + + + + + Why we rewrote our sync engine in Rust — Filebase + + + +
+ +
Engineering
+

Why we rewrote our sync engine in Rust

+ +

For two years our Go sync engine was good enough. Then video editors started joining the customer list, and the GC pauses we'd been politely ignoring turned into bug reports we couldn't ignore.

+
+ +

The decision wasn't sudden. We'd been watching the GC pause distribution shift for six months before we admitted what the data was telling us. P50 latency was great. P99 was a horror movie. Customers syncing 30 GB of .psd files in active editing sessions were the ones writing in.

+ +

Rewriting an entire sync engine sounds like the kind of project a startup is told never to do. We did it anyway. Here's how it went, what surprised us, and the parts I'd do differently.

+ +

The trigger: GC pauses we couldn't fix

+

Go's garbage collector is brilliant. It is also, fundamentally, a tradeoff. Our hot path allocated short-lived buffer slices on every block diff — and at our scale, on a heavy uploader, the collector ran often enough that the P99 pause crept past 50ms.

+ +

We tried the usual fixes: pooling buffers with sync.Pool, tuning GOGC, reducing allocations in the merge path. They each helped a little. None of them got us under 20ms, and the customers we cared about needed under 5.

+ +
"We can't fix this in Go. We can fix it in something without a GC."
+ +

Our staff engineer Sasha said this in a meeting in October. He was right. The question wasn't whether to leave Go. It was what to leave it for, and how much we could keep.

+ +

What we kept; what we threw out

+

The CLI stayed in Go. The control plane stayed in Go. The bit that does block-level diffing in a hot loop on a customer's laptop — that became Rust. The boundary became a single FFI surface with a small, opinionated protocol.

+ +
+
38ms → 4ms
P99 sync latency
+
62%
Memory drop
+
11 weeks
From RFC to ship
+
+ +

The numbers above are real and from production. They are also misleading without context: the Rust port doesn't just remove the GC, it also removes a layer of abstraction we'd been carrying since the Go MVP.

+ +

What I'd do differently

+

One thing: the FFI boundary. We chose cgo for symmetry — Go calling Rust feels right when you already have Go everywhere. But the binding ceremony is brittle, and we ate two production incidents from string lifetime mistakes before we wrote a wrapper layer that handled them once.

+ +

If I were starting today, I'd reach for uniffi or generate the bindings from a schema. The lessons isn't don't use cgo; it's treat the boundary like an external API the moment you cross language families.

+ +
Filebase is hiring engineers who like writing this kind of post. See open roles →
+
+ + diff --git a/.od-skills/web-prototype-36dbb042a6/SKILL.md b/.od-skills/web-prototype-36dbb042a6/SKILL.md new file mode 100644 index 0000000..d2b5469 --- /dev/null +++ b/.od-skills/web-prototype-36dbb042a6/SKILL.md @@ -0,0 +1,97 @@ +--- +name: web-prototype +description: | + General-purpose desktop web prototype. Single self-contained HTML file built + by copying the seed `assets/template.html` and pasting section layouts from + `references/layouts.md`. Default for any landing / marketing / docs / SaaS + page when no more specific skill matches. +triggers: + - "prototype" + - "mockup" + - "landing" + - "single page" + - "marketing page" + - "homepage" +od: + mode: prototype + platform: desktop + scenario: design + preview: + type: html + entry: index.html + design_system: + requires: true + sections: [color, typography, layout, components] +--- + +# Web Prototype Skill + +Produce a single, self-contained HTML prototype using the bundled seed and layout library — **not** by writing CSS from scratch. The seed already encodes good defaults (typography, spacing, accent budget). Your job is to compose it. + +## Resource map + +``` +web-prototype/ +├── SKILL.md ← you're reading this +├── assets/ +│ └── template.html ← seed: tokens + class system + chrome (READ FIRST) +└── references/ + ├── layouts.md ← 8 paste-ready section skeletons + └── checklist.md ← P0/P1/P2 self-review +``` + +## Workflow + +### Step 0 — Pre-flight (do this once before writing anything) + +1. **Read `assets/template.html` end-to-end** — at minimum through the ` + + +
+
+ + + +
+
+ +
+ +
+
+

[REPLACE] Eyebrow

+

[REPLACE] One sharp sentence about what this is.

+

[REPLACE] One subhead sentence — concrete value, not a tagline.

+
+ + +
+
+
+
+ +
+
+ © [REPLACE] Brand · [REPLACE] Year + [REPLACE] tagline · contact@example.com +
+
+ + diff --git a/.od-skills/web-prototype-36dbb042a6/example.html b/.od-skills/web-prototype-36dbb042a6/example.html new file mode 100644 index 0000000..9b7dc49 --- /dev/null +++ b/.od-skills/web-prototype-36dbb042a6/example.html @@ -0,0 +1,81 @@ + + + + + + Tomato — focused work timer + + + +
+ + +
+
+
+

Twenty-five minutes at a time.

+

The pomodoro timer that actually keeps your hands off Slack. Block notifications, log every cycle, ship more before lunch.

+
+ + +
+
+
+
+
+

Block on, not off

+

Slack and email go quiet for 25 minutes. They come back loud at the break, with a digest.

+
+
+
+

Stats that don't lie

+

Weekly review tells you which days you actually shipped versus which you only seemed busy.

+
+
+
+

Team-friendly silences

+

Your status auto-updates so teammates know when to ask, when to wait, and when you're done.

+
+
+
+

Stop measuring meetings. Start measuring focus.

+

Free for solo. $4/mo per teammate after that.

+ +
+
+
© Tomato Labs · Made for people who'd rather be making.
+ + diff --git a/.od-skills/web-prototype-36dbb042a6/open-design.json b/.od-skills/web-prototype-36dbb042a6/open-design.json new file mode 100644 index 0000000..331aa4e --- /dev/null +++ b/.od-skills/web-prototype-36dbb042a6/open-design.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://open-design.ai/schemas/plugin.v1.json", + "specVersion": "1.0.0", + "name": "example-web-prototype", + "title": "Web Prototype", + "version": "0.1.1", + "description": "General-purpose desktop web prototype. Single self-contained HTML file built\nby copying the seed `assets/template.html` and pasting section layouts from\n`references/layouts.md`. Default for any landing / marketing / docs / SaaS\npage when no more specific skill matches.", + "license": "MIT", + "author": { + "name": "Open Design", + "url": "https://github.com/nexu-io" + }, + "homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/web-prototype", + "tags": [ + "example", + "first-party", + "prototype", + "design", + "web", + "desktop", + "mockup", + "landing", + "single-page", + "marketing-page", + "homepage" + ], + "compat": { + "agentSkills": [ + { + "path": "./SKILL.md" + } + ] + }, + "od": { + "kind": "scenario", + "taskKind": "new-generation", + "mode": "prototype", + "platform": "desktop", + "scenario": "design", + "surface": "web", + "preview": { + "type": "html", + "entry": "./example.html" + }, + "useCase": { + "query": { + "en": "Create a premium product-studio {{fidelity}} {{artifactKind}} for {{audience}}: sharp information architecture, elegant visual hierarchy, polished interaction states, and a refined design-system-driven interface that feels shipped by a top-tier product team. Use {{designSystem}} as the design-system direction and start from {{template}}. Build one self-contained HTML file by copying the seed `assets/template.html` and pasting layouts from `references/layouts.md`.", + "zh-CN": "用高端产品工作室的完成度,为 {{audience}} 打磨一个 {{fidelity}} 的 {{artifactKind}}:信息架构清晰、视觉层级优雅、交互状态完整,整体像顶级产品团队交付的可演示原型。设计系统方向使用 {{designSystem}},从 {{template}} 开始。使用 `assets/template.html` 种子并从 `references/layouts.md` 粘贴版面,输出单文件 HTML。" + }, + "exampleOutputs": [ + { + "path": "./example.html", + "title": "Web Prototype" + } + ] + }, + "inputs": [ + { + "name": "artifactKind", + "label": "Artifact kind", + "type": "string", + "required": true, + "placeholder": "SaaS landing page", + "default": "web prototype" + }, + { + "name": "fidelity", + "label": "Fidelity", + "type": "select", + "required": true, + "options": [ + "wireframe", + "high-fidelity" + ], + "default": "high-fidelity" + }, + { + "name": "audience", + "label": "Audience", + "type": "string", + "required": true, + "placeholder": "startup founders evaluating an AI CRM", + "default": "product evaluators" + }, + { + "name": "designSystem", + "label": "Design system", + "type": "string", + "placeholder": "OpenAI, Linear, shadcn, or custom brand notes", + "default": "the active project design system" + }, + { + "name": "template", + "label": "Template", + "type": "string", + "placeholder": "marketing homepage, dashboard, docs page", + "default": "the bundled web prototype seed" + } + ], + "context": { + "skills": [ + { + "path": "./SKILL.md" + } + ], + "designSystem": { + "primary": true + }, + "assets": [ + "./example.html", + "./assets/template.html", + "./references/checklist.md", + "./references/layouts.md" + ] + }, + "pipeline": { + "stages": [ + { + "id": "generate", + "atoms": [ + "file-write", + "live-artifact" + ] + } + ] + }, + "capabilities": [ + "prompt:inject", + "fs:write" + ] + } +} diff --git a/.od-skills/web-prototype-36dbb042a6/references/checklist.md b/.od-skills/web-prototype-36dbb042a6/references/checklist.md new file mode 100644 index 0000000..0725a9c --- /dev/null +++ b/.od-skills/web-prototype-36dbb042a6/references/checklist.md @@ -0,0 +1,44 @@ +# Web prototype checklist + +Run this before emitting ``. P0 = must pass; P1 = should pass; P2 = nice to have. + +## P0 — must pass + +- [ ] **No raw hex outside `:root` token block.** Every color is `var(--bg)` / `var(--fg)` / `var(--muted)` / `var(--border)` / `var(--accent)` / `var(--surface)` (or a `color-mix()` of those). Grep `#[0-9a-fA-F]{3,8}` outside `:root{}` should return nothing. +- [ ] **All headings use `var(--font-display)`.** No sans-serif `

` / `

`. Inter / Roboto / system-sans never serve as a display face. +- [ ] **Accent appears at most twice per screen.** Count: eyebrow color, primary CTA fill, anything else? If three or more, demote one to `var(--fg)` or `var(--muted)`. +- [ ] **No purple/violet gradient backgrounds.** No `linear-gradient(... #a855f7 / #8b5cf6 / purple ...)`. The seed template has no gradients on backgrounds — keep it that way. +- [ ] **No emoji used as feature icons.** Use the inline SVG monoline marks shipped in Layout 3, or a tasteful single-character glyph in `--font-mono`. ✨ 🚀 🎯 are out. +- [ ] **No invented metrics.** Every number on the page came from the user, the brief, or is clearly labelled as a placeholder (e.g. `[REPLACE] · 38×`). "10× faster", "99.9% uptime" without source = remove. +- [ ] **No filler copy.** Zero "Feature One / Feature Two", lorem ipsum, "Lorem ipsum dolor". If a section feels empty, delete it; do not pad. +- [ ] **`data-od-id` on every top-level `
`.** Used by comment mode to target sections. +- [ ] **Mobile reflow works.** All `grid-2`, `grid-3`, `grid-4`, `grid-2-1`, `grid-1-2` collapse to one column at ≤920px (the default media query in `template.html` does this). Verify by mentally narrowing — no horizontal scroll. +- [ ] **No `scrollIntoView()` calls.** Breaks the OD preview iframe. Use `scrollTo({...})` if you need scroll behaviour. + +## P1 — should pass + +- [ ] **One decisive flourish.** A pull quote, a striking stat, a real-feeling photograph, one micro-animation on the hero. *One.* Not three. +- [ ] **Section rhythm alternates.** No two stat rows in a row. No two feature triplets in a row. No two quote blocks in a row. +- [ ] **Headlines under 14 words.** If longer, the writing is doing the design's job. +- [ ] **Lead text under 56 ch / two sentences.** `max-width: 60ch` on `.lead` enforces this; don't override. +- [ ] **CTA buttons say what happens.** "Start free" beats "Get Started". "Read the story" beats "Learn More". +- [ ] **Hover states present** for all `` and `.btn`. Seed template covers this. +- [ ] **Numerics use `.num` (mono, tabular).** Prices, stats, version numbers, dates. +- [ ] **One image style per page.** Don't mix square portrait headshots with widescreen product hero with vertical phone mock — pick a lane. + +## P2 — nice to have + +- [ ] **`text-wrap: pretty` / `balance`** on long paragraphs / headings (already on `

` and `h*` in seed). +- [ ] **`color-mix()` for derived tones.** No additional `--accent-50` / `--accent-300` Bootstrap-style tokens — derive on the spot. +- [ ] **Sticky topnav has frosted glass** (already in seed via `backdrop-filter: blur()`). +- [ ] **Loaded fonts are system-first.** Iowan Old Style / Charter for serif, system stack for sans. Only pull a Google Font if DESIGN.md specifies one. + +## Anti-slop spot-check + +Look at the page for two seconds. If your gut says any of: + +- "looks like every Cursor / Linear / Vercel ripoff I've seen this month" +- "this could be any AI startup's homepage" +- "the feature row has an icon, a heading, and three lines of vague benefit copy" + +…go back, replace one feature cell with something more specific to *this* product (a screenshot, a concrete example, a sample of the actual output), and remove one accent. diff --git a/.od-skills/web-prototype-36dbb042a6/references/layouts.md b/.od-skills/web-prototype-36dbb042a6/references/layouts.md new file mode 100644 index 0000000..b8a0b4e --- /dev/null +++ b/.od-skills/web-prototype-36dbb042a6/references/layouts.md @@ -0,0 +1,247 @@ +# Web prototype layouts + +**8 paste-ready section skeletons.** Drop into `

` of `assets/template.html`. Don't write sections from scratch — pick the closest layout, paste, swap copy. + +## Pre-flight (do this once before pasting anything) + +1. **Read `assets/template.html`** through the end of the ` - - -
- - - - - - - - - - - diff --git a/combat-hud-game.html b/combat-hud-game.html deleted file mode 100644 index 221115a..0000000 --- a/combat-hud-game.html +++ /dev/null @@ -1,1467 +0,0 @@ - - - - - -COMBAT HUD — VOID NAV - - - - - - - -
- - - - - - diff --git a/combat-hud.html b/combat-hud.html deleted file mode 100644 index bc1056b..0000000 --- a/combat-hud.html +++ /dev/null @@ -1,1192 +0,0 @@ - - - - - - VOID::NAV — Combat HUD - - - -
- - - - - - - - - - - diff --git a/gdd-docs-hub.html.artifact.json b/gdd-docs-hub.html.artifact.json new file mode 100644 index 0000000..0c1ffef --- /dev/null +++ b/gdd-docs-hub.html.artifact.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "kind": "html", + "title": "GDD::DOCS — Game Design Documentation Hub", + "entry": "gdd-docs-hub.html", + "renderer": "html", + "status": "complete", + "exports": [ + "html", + "pdf", + "zip" + ], + "createdAt": "2026-05-22T01:27:51.600Z", + "updatedAt": "2026-05-22T01:27:51.696Z", + "sourceSkillId": "blog-post", + "metadata": { + "identifier": "gdd-docs-hub", + "artifactType": "text/html", + "inferred": false + } +} \ No newline at end of file diff --git a/index.html b/index.html index 4de45c7..80ae253 100644 --- a/index.html +++ b/index.html @@ -1,969 +1,12 @@ - + - - - - GDD::DOCS — EVE-Inspired Multiplayer Prototype - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - + + + + VOID::NAV + + +
+ + diff --git a/package.json b/package.json new file mode 100644 index 0000000..932ec64 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "space-game", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@react-three/drei": "^9.122.0", + "@react-three/fiber": "^8.17.10", + "@tailwindcss/vite": "^4.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1", + "tailwindcss": "^4.3.0", + "three": "^0.160.0", + "vite": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@types/three": "^0.160.0", + "typescript": "^5.8.3" + }, + "packageManager": "pnpm@9.15.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..428ac81 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1802 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-three/drei': + specifier: ^9.122.0 + version: 9.122.0(@react-three/fiber@8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.29)(@types/three@0.160.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)(use-sync-external-store@1.6.0(react@18.3.1)) + '@react-three/fiber': + specifier: ^8.17.10 + version: 8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.30.1 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + three: + specifier: ^0.160.0 + version: 0.160.1 + vite: + specifier: ^7.0.0 + version: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0) + devDependencies: + '@types/react': + specifier: ^18.3.23 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.29) + '@types/three': + specifier: ^0.160.0 + version: 0.160.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mediapipe/tasks-vision@0.10.17': + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + + '@react-spring/animated@9.7.5': + resolution: {integrity: sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@react-spring/core@9.7.5': + resolution: {integrity: sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@react-spring/rafz@9.7.5': + resolution: {integrity: sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==} + + '@react-spring/shared@9.7.5': + resolution: {integrity: sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@react-spring/three@9.7.5': + resolution: {integrity: sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==} + peerDependencies: + '@react-three/fiber': '>=6.0' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + three: '>=0.126' + + '@react-spring/types@9.7.5': + resolution: {integrity: sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==} + + '@react-three/drei@9.122.0': + resolution: {integrity: sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==} + peerDependencies: + '@react-three/fiber': ^8 + react: ^18 + react-dom: ^18 + three: '>=0.137' + peerDependenciesMeta: + react-dom: + optional: true + + '@react-three/fiber@8.18.0': + resolution: {integrity: sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=18 <19' + react-dom: '>=18 <19' + react-native: '>=0.64' + three: '>=0.133' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react-reconciler@0.26.7': + resolution: {integrity: sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==} + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.160.0': + resolution: {integrity: sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + camera-controls@2.10.1: + resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==} + peerDependencies: + three: '>=0.126.1' + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-gpu@5.0.70: + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + engines: {node: '>=10.13.0'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glsl-noise@0.0.0: + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + its-fine@1.2.5: + resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==} + peerDependencies: + react: '>=18.0' + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + meshline@3.3.1: + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + + meshoptimizer@0.18.1: + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + react-composer@5.0.3: + resolution: {integrity: sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-reconciler@0.27.0: + resolution: {integrity: sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^18.0.0 + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.21.0: + resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + three-mesh-bvh@0.7.8: + resolution: {integrity: sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==} + deprecated: Deprecated due to three.js version incompatibility. Please use v0.8.0, instead. + peerDependencies: + three: '>= 0.151.0' + + three-stdlib@2.36.1: + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + + three@0.160.1: + resolution: {integrity: sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + troika-three-text@0.52.4: + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} + peerDependencies: + three: '>=0.125.0' + + troika-three-utils@0.52.4: + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} + peerDependencies: + three: '>=0.125.0' + + troika-worker-utils@0.52.0: + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + webgl-constants@1.1.1: + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} + + webgl-sdf-generator@1.1.1: + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + zustand@3.7.2: + resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==} + engines: {node: '>=12.7.0'} + peerDependencies: + react: '>=16.8' + peerDependenciesMeta: + react: + optional: true + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mediapipe/tasks-vision@0.10.17': {} + + '@monogrid/gainmap-js@3.4.0(three@0.160.1)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.160.1 + + '@react-spring/animated@9.7.5(react@18.3.1)': + dependencies: + '@react-spring/shared': 9.7.5(react@18.3.1) + '@react-spring/types': 9.7.5 + react: 18.3.1 + + '@react-spring/core@9.7.5(react@18.3.1)': + dependencies: + '@react-spring/animated': 9.7.5(react@18.3.1) + '@react-spring/shared': 9.7.5(react@18.3.1) + '@react-spring/types': 9.7.5 + react: 18.3.1 + + '@react-spring/rafz@9.7.5': {} + + '@react-spring/shared@9.7.5(react@18.3.1)': + dependencies: + '@react-spring/rafz': 9.7.5 + '@react-spring/types': 9.7.5 + react: 18.3.1 + + '@react-spring/three@9.7.5(@react-three/fiber@8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(react@18.3.1)(three@0.160.1)': + dependencies: + '@react-spring/animated': 9.7.5(react@18.3.1) + '@react-spring/core': 9.7.5(react@18.3.1) + '@react-spring/shared': 9.7.5(react@18.3.1) + '@react-spring/types': 9.7.5 + '@react-three/fiber': 8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) + react: 18.3.1 + three: 0.160.1 + + '@react-spring/types@9.7.5': {} + + '@react-three/drei@9.122.0(@react-three/fiber@8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.29)(@types/three@0.160.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)(use-sync-external-store@1.6.0(react@18.3.1))': + dependencies: + '@babel/runtime': 7.29.7 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.160.1) + '@react-spring/three': 9.7.5(@react-three/fiber@8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(react@18.3.1)(three@0.160.1) + '@react-three/fiber': 8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) + '@use-gesture/react': 10.3.1(react@18.3.1) + camera-controls: 2.10.1(three@0.160.1) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.16 + maath: 0.10.8(@types/three@0.160.0)(three@0.160.1) + meshline: 3.3.1(three@0.160.1) + react: 18.3.1 + react-composer: 5.0.3(react@18.3.1) + stats-gl: 2.4.2(@types/three@0.160.0)(three@0.160.1) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@18.3.1) + three: 0.160.1 + three-mesh-bvh: 0.7.8(three@0.160.1) + three-stdlib: 2.36.1(three@0.160.1) + troika-three-text: 0.52.4(three@0.160.1) + tunnel-rat: 0.1.2(@types/react@18.3.29)(react@18.3.1) + utility-types: 3.11.0 + zustand: 5.0.13(@types/react@18.3.29)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + - use-sync-external-store + + '@react-three/fiber@8.18.0(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@types/react-reconciler': 0.26.7 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 1.2.5(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-reconciler: 0.27.0(react@18.3.1) + react-use-measure: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + scheduler: 0.21.0 + suspend-react: 0.1.3(react@18.3.1) + three: 0.160.1 + zustand: 3.7.2(react@18.3.1) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + + '@remix-run/router@1.23.2': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0) + + '@types/draco3d@1.4.10': {} + + '@types/estree@1.0.8': {} + + '@types/offscreencanvas@2019.7.3': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react-reconciler@0.26.7': + dependencies: + '@types/react': 18.3.29 + + '@types/react-reconciler@0.28.9(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.160.0': + dependencies: + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.6.10 + meshoptimizer: 0.18.1 + + '@types/webxr@0.5.24': {} + + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@18.3.1)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 18.3.1 + + base64-js@1.5.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + camera-controls@2.10.1(three@0.160.1): + dependencies: + three: 0.160.1 + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + detect-gpu@5.0.70: + dependencies: + webgl-constants: 1.1.1 + + detect-libc@2.1.2: {} + + draco3d@1.5.7: {} + + enhanced-resolve@5.22.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.6.10: {} + + fsevents@2.3.3: + optional: true + + glsl-noise@0.0.0: {} + + graceful-fs@4.2.11: {} + + hls.js@1.6.16: {} + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + is-promise@2.2.2: {} + + isexe@2.0.0: {} + + its-fine@1.2.5(@types/react@18.3.29)(react@18.3.1): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@18.3.29) + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + maath@0.10.8(@types/three@0.160.0)(three@0.160.1): + dependencies: + '@types/three': 0.160.0 + three: 0.160.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + meshline@3.3.1(three@0.160.1): + dependencies: + three: 0.160.1 + + meshoptimizer@0.18.1: {} + + nanoid@3.3.12: {} + + object-assign@4.1.1: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + potpack@1.0.2: {} + + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + react-composer@5.0.3(react@18.3.1): + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-reconciler@0.27.0(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.21.0 + + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + + react-router@6.30.3(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + + react-use-measure@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + require-from-string@2.0.2: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + scheduler@0.21.0: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + stats-gl@2.4.2(@types/three@0.160.0)(three@0.160.1): + dependencies: + '@types/three': 0.160.0 + three: 0.160.1 + + stats.js@0.17.0: {} + + suspend-react@0.1.3(react@18.3.1): + dependencies: + react: 18.3.1 + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + three-mesh-bvh@0.7.8(three@0.160.1): + dependencies: + three: 0.160.1 + + three-stdlib@2.36.1(three@0.160.1): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.160.1 + + three@0.160.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + troika-three-text@0.52.4(three@0.160.1): + dependencies: + bidi-js: 1.0.3 + three: 0.160.1 + troika-three-utils: 0.52.4(three@0.160.1) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 + + troika-three-utils@0.52.4(three@0.160.1): + dependencies: + three: 0.160.1 + + troika-worker-utils@0.52.0: {} + + tunnel-rat@0.1.2(@types/react@18.3.29)(react@18.3.1): + dependencies: + zustand: 4.5.7(@types/react@18.3.29)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + - react + + typescript@5.9.3: {} + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + utility-types@3.11.0: {} + + vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + webgl-constants@1.1.1: {} + + webgl-sdf-generator@1.1.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + zustand@3.7.2(react@18.3.1): + optionalDependencies: + react: 18.3.1 + + zustand@4.5.7(@types/react@18.3.29)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + react: 18.3.1 + + zustand@5.0.13(@types/react@18.3.29)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.29 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) diff --git a/public/assets/mpg7uppn-eve_like_multiplayer_prototype_design_doc.docx b/public/assets/mpg7uppn-eve_like_multiplayer_prototype_design_doc.docx new file mode 100644 index 0000000..72d362b Binary files /dev/null and b/public/assets/mpg7uppn-eve_like_multiplayer_prototype_design_doc.docx differ diff --git a/public/assets/mpgzmjy2-drawing-2026-05-22T14-01-06-091Z.png b/public/assets/mpgzmjy2-drawing-2026-05-22T14-01-06-091Z.png new file mode 100644 index 0000000..a61037d Binary files /dev/null and b/public/assets/mpgzmjy2-drawing-2026-05-22T14-01-06-091Z.png differ diff --git a/public/assets/mph38gxn-drawing-2026-05-22T15-42-07-328Z.png b/public/assets/mph38gxn-drawing-2026-05-22T15-42-07-328Z.png new file mode 100644 index 0000000..c3c0b72 Binary files /dev/null and b/public/assets/mph38gxn-drawing-2026-05-22T15-42-07-328Z.png differ diff --git a/public/assets/mpj6kt4n-drawing-2026-05-24T02-51-14-351Z.png b/public/assets/mpj6kt4n-drawing-2026-05-24T02-51-14-351Z.png new file mode 100644 index 0000000..df1dbc6 Binary files /dev/null and b/public/assets/mpj6kt4n-drawing-2026-05-24T02-51-14-351Z.png differ diff --git a/public/docs/gap-analysis.md b/public/docs/gap-analysis.md new file mode 100644 index 0000000..1c4732c --- /dev/null +++ b/public/docs/gap-analysis.md @@ -0,0 +1,305 @@ +# Gap Analysis — EVE-Inspired Multiplayer Prototype GDD + +**Date:** 2026-05-24 +**Last Updated:** 2026-05-25 (Session 7) +**Scope:** Full cross-reference of design documentation vs. interactive demos vs. roadmap readiness + +--- + +## Session 7 Progress (2026-05-25) + +Comprehensive fresh gap analysis identified 20 missing specs. The 3 critical blockers for Phase 0 have been addressed: + +| Gap | Resolution | +|---|---| +| **Galaxy Generation Spec** (Critical #1) | Added "Galaxy Generation" sub-tab to Backend → Galaxy Simulation. Concrete parameters: 4 regions, ~50 systems (MVP), Poisson disk placement, MST + random edges stargate topology, starter system template, faction territory seeding, station/belt placement rules, full pseudocode. Deterministic seeded RNG. | +| **Ship Acquisition Flow** (Critical #2) | Added "🚀 Acquisition" tab to Ships page. Free Rookie Frigate on spawn and death respawn. 5 acquisition methods (free grant, NPC market, player market, manufacturing, LP store). NPC ship pricing table. Ship switching flow (dock → hangar → select → activate). Hangar storage model. Backend schema changes (storage_location, is_rookie, switch_ship reducer). | +| **Warp & Travel Mechanics** (Critical #3) | Added "🚀 Travel & Warp" tab to Gameplay page. 3 travel modes (sub-warp, warp, gate jump) with speeds. Warp sequence (align → accelerate → cruise → exit). Warp speeds by ship class (3.0–6.0 AU/s). Stargate mechanics (2.5km activation, 5s jump cooldown, 30s gate cloak). Gate guns by security level. Docking/undocking with invulnerability. Autopilot vs manual piloting. Warp disruption. Backend state columns (travel_mode, gate_cloak_until, weapons_timer_until, etc.). | + +### Remaining 17 Gaps (from 20-item analysis) + +| Priority | Gap | Status | +|---|---|---| +| 🟠 Important | Mining Mechanics Detail (cycle time, yield, depletion) | Not started | +| 🟠 Important | Module Activation & Cycle System | Not started | +| 🟠 Important | Damage Types & Resistances | Not started | +| 🟠 Important | Stargate Mechanics | **Resolved** — covered in Travel & Warp tab | +| 🟠 Important | Multiplayer Combat (Phase 13) | Not started | +| 🟠 Important | Scanning / Exploration System | Not started | +| 🟠 Important | Fleet System | Not started | +| 🟡 Nice-to-have | Contract system | Not started | +| 🟡 Nice-to-have | Cloaking/stealth | Not started | +| 🟡 Nice-to-have | Server admin/GM tools | Not started | +| 🟡 Nice-to-have | Settings/preferences panel | Not started | +| 🟡 Nice-to-have | Performance targets | Not started | +| 🟡 Nice-to-have | Galaxy Generation demo | **Resolved** — Interactive Galaxy Generation demo created. Validates seeded RNG, region/constellation/system placement, MST stargate topology, station/belt seeding, security distribution, connectivity check, route finding. 12th demo. | +| 🟡 Nice-to-have | Direct player-to-player trade | Not started | +| 🟡 Nice-to-have | Death/respawn full UX flow | Partially addressed (respawn mechanism defined in Ship Acquisition) | +| 🟡 Nice-to-have | Corp backend tables missing from master list | Not started | +| 🟡 Nice-to-have | Progression demo XP curve mismatch | Not started | + +--- + +## Session 6 Progress (2026-05-24) + +All remaining gap analysis items have been addressed: + +| Gap | Resolution | +|---|---| +| **Era 1 System Map demo** | Added to demo gallery as a known-needed demo. Listed in demo gallery header note. Implementation is Phase 1 scope (after movement model is built). | +| **Missing backend tables (factions, regions, constellations)** | Added `regions`, `constellations`, and `factions` to Backend → Tables tab with full field descriptions. `systems` table now has `constellation_id` FK. | +| **Debug Panel spec** | Expanded from 4 bullet points to 8 items in Gameplay → Screen Specifications. Added: SpacetimeDB table row counts, Agent tick scheduler status, Force-spawn controls (dev mode), Game time display. | +| **Tutorial / Onboarding spec** | Already resolved in Session 5 (OV-05). 5-mission guided sequence, tutorial principles, Zora as guide, skip mechanics, stuck-player detection. | +| **Error handling / Reconnection spec** | Added to Architecture → ARCH-4. 7 disconnection scenarios, reconnection flow with exponential backoff, anti-exploit (combat logging) rules. | +| **Session persistence / Save-Load spec** | Added to Architecture → ARCH-5. No save button, no localStorage. SpacetimeDB is continuous persistence. Full table-by-table persistence guarantee. | +| **Sound / Audio design spec** | Added to Architecture → ARCH-6. 6 audio categories, 6 volume sliders, spatial audio rules, Phase 7 scope. | +| **Localization / i18n decision** | Added to Architecture → ARCH-7. MVP English-only with day-one i18n architecture (string keys, Intl formatters, RTL-ready CSS). | +| **Accessibility spec** | Added to Architecture → ARCH-8. 8 accessibility areas with requirements and implementation details. Gate 4 acceptance tests. | +| **Corporations & Territory spec** | Added as new tab to Social page (SOC-CORP). Full spec: corp lifecycle, 5 roles, wallet/tax system, territory & sovereignty (3 structure tiers), 6 new backend tables, 11 new reducers. Phase 14 scope. | + +--- + +## Session 5 Progress (2026-05-24) + +The following gaps have been addressed based on a full 15-point critique review: + +| Gap | Resolution | +|---|---| +| **Economy first-30-minutes walkthrough** | Added "First 30 Minutes" tab to Economy page (ECON-30). 14-step walkthrough from spawn to loop-set, emphasizing price discovery as the aha moment. | +| **Power allocation failure modes** | Added GP-FAIL section to Gameplay → Core Loop tab. Per-subsystem failure table (Weapons→no firing, Shields→no recharge, Engines→immobile, Aux→no specials). Reroute timing (1.5–3s). | +| **Flight Mode HUD cognitive load** | Added Red Alert mode to GP-FAIL. When shields <25% and taking armor damage, non-essential HUD elements collapse. Combat HUD expands. Cannot be disabled. | +| **Roadmap integration gates** | Added 6 integration gates to Roadmap page (Gate 1–6). Each gate covers a phase group and defines a focused playtest. | +| **Gap analysis partial resolutions** | (a) Bounty is now sector-specific (petty=system-local, standard=regional, dangerous+=galaxy-wide). (b) AI module examples added to both fitting examples in Ships page. (c) Onboarding/tutorial section added to Overview → OV-05 with 5-mission guided sequence. | +| **Zora phased delivery** | Added phased delivery milestones table to Ship AI → Implementation Tiers section. Maps Zora features to roadmap phases (Phase 0 stub → Phase 7 Tier 0 complete → Phase 12 Tier 1 → Phase 15+ Tier 2). Full design kept intact. | +| **Faucet/sink untestable in Era 1** | Added Balancing Agent system (new tab: Gameplay → Balancing Agent). Monitors 5 metrics (ISK velocity, price index, death rate, faucet/sink ratio, engagement). Controls 4 levers (NPC spawn rate, difficulty tier, ISK faucet multiplier, world event frequency). New tables: balance_metrics, balance_levers, balance_audit. | +| **ER diagram** | Added ER Diagram tab to Backend page. 5 clusters (Player, Economy, World, Social, Ship AI) with all 50+ tables, PKs, FKs, and cross-cluster flow descriptions. | +| **Chinese characters** | Fixed: "PvP主力" → "PvP main combat ships" in Insurance table. | +| **Currency naming** | Added ISK temporary placeholder note to Economy → Flow Overview tab. All ISK references are subject to renaming. | +| **Security level 0.0 gap** | Fixed: Security 0.0 is explicitly null-sec. Added clarification to Gameplay → Security Levels intro text. | +| **Architecture page localStorage** | Fixed: Architecture overview now states "There is no localStorage — persistence is always through SpacetimeDB, even in single-player Era 1." | +| **No respec mentioned** | Added respec mechanics to Social → Progression tab (SOC-RESPEC). 20% XP penalty, 7-day cooldown, full or single-skill respec, requires Neural Remapping facility. | +| **Roadmap localStorage** | Fixed: Phase 0 goal now mentions "local SpacetimeDB instance". Phase 7 doneWhen says "SpacetimeDB persists all state — no localStorage". Era 1 subtitle reflects SpacetimeDB-from-day-1. | +| **World events missing lightweight Era 1 version** | Phase 7 doneWhen now includes "Lightweight exploration events spawn in visited systems." Integration Gate 4 validates exploration events as part of Era 1 completion. | + +--- + +## Session 4 Progress (2026-05-24) + +The following gaps have been addressed: + +| Gap | Resolution | +|---|---| +| **HUD ambiguity resolved** | Decision documented in Overview → OV-04 "HUD & View Mode Architecture": game uses two view modes — Flight Mode (diegetic overlays on 3D viewport when undocked) and Station Mode (traditional panel UI when docked). Map Mode is shared. Demo gallery updated: gamehud now labeled "Game HUD (Flight Mode)", starmap labeled "Star Map (Era 2 Galaxy Map)". Transition rules specified (undock, dock, open map, combat ambush). | +| **Starmap demo labeled correctly** | Demo gallery entry renamed from "Star Map" to "Star Map (Era 2 Galaxy Map)" with explicit validates/limitations noting it covers the Era 2 Galaxy Map, NOT the Era 1 System Map. Era 1 System Map still needs a separate demo. | +| **Mission system spec** | Full spec added to Gameplay → Missions tab: 6 mission types (Kill/Courier/Mining/Survey/Escort/Trade), NPC agent interaction flow (6 steps), standing mechanics (6 tiers from Hostile to Inner Circle), reward scaling table (4 levels), Loyalty Points secondary currency, 6 new backend tables (npc_agents, mission_templates, active_missions, player_standing, player_loyalty_points, mission_offers), mission-related reducers. | +| **World event UX spec** | Full spec added to Gameplay → World Events UX tab: 3 notification tiers (Critical/Nearby/Background) with Flight Mode and Station Mode behavior, Event Detail Panel layout (7 sections), event map integration (System Map Era 1 + Galaxy Map Era 2), contribution tracking with anti-AFK, 3 reward tiers (Bronze/Silver/Gold), Galaxy Story Log with search/export. | +| **Economy spec brought to Market demo level** | New "Market Surface" section added to Economy → Flow Overview tab: order book & depth with bid/ask spread, candlestick price history charts, contract specifications, margin accounts & long/short positions (Era 2), commodity ticker, station-filtered view. Acknowledges the Market demo implements all of these. | +| **Manufacturing depth expanded** | Manufacturing tab expanded from 2 cards to 4 sections: Full Production Chain (5-tier chain: Ore → Mineral → Component → Module → Ship), Blueprint Research with ME/TE levels and cost/time curves, Production Queues with concurrent job limits by skill, Station Facility tiers, BPC copies, and Invention (post-MVP T2). | +| **Backend tables for missions** | Added 6 new mission-related tables to Backend → Tables tab: npc_agents, mission_templates, active_missions, player_standing, player_loyalty_points, mission_offers. Total tables now 44. | + +--- + +## Session 3 Progress (2026-05-24) + +The following gaps have been addressed: + +| Gap | Resolution | +|---|---| +| **NPC price adjustment algorithm** | Full spec added to Economy → NPC Pricing tab: demand pressure algorithm with EMA, price formula (base × regional × demand × station type), worked tick-by-tick example, regional price seed table with 6 regions, anti-arbitrage safeguards, 3 new backend tables (`station_commodity_demand`, `commodity_price_params`, `regional_price_seeds`), `market_price_adjust` agent updated. | +| **Chat/comms prototype** | New interactive demo (`js/demos/chat.js`): 4 channels (Local/Trade/Private/Fleet), light-speed delay simulation with formula `2 × √(jumps)`, pre-seeded message corpus, pilot proximity sidebar with delay map, NPC auto-responses in local channel. Registered in sidebar, loader, app, and demo gallery. | +| **Zora Tier 0 demo** | New interactive demo (`js/demos/zora.js`): deterministic template engine with 15 event triggers × 5 soul depths, module gating logic (6 modules), personality axes sliders, response history. Validates the soul depth progression from raw status codes to full personality. Registered in sidebar, loader, app, and demo gallery. | +| **Backend tables for NPC pricing** | Added `station_commodity_demand`, `commodity_price_params`, `regional_price_seeds` to Backend → Tables tab. | +| **Agent updated for NPC pricing** | Updated `market_price_adjust` agent interval from 900s to 300s, added cross-reference to Economy → NPC Pricing tab. | + +--- + +## Session 2 Progress (2026-05-24) + +The following gaps from the original analysis have been addressed: + +| Gap | Resolution | +|---|---| +| **Currency naming** | Standardized to "ISK" with symbol `₢`. Removed all "credits" references. Updated overview, ships, roadmap. | +| **Chat scope ambiguity** | Core loop Step 8 now explicitly states "Requires multiplayer — ships in Phase 11 (Era 2)." Summary paragraph clarified Steps 1–7 as Era 1. | +| **Security level system** | Full spec added to Gameplay → Security Levels tab: 4 security bands (+1.0 to −1.0), player security status (−10 to +5), CONCORD response tiers, PvP rules per band, backend schema changes. | +| **NPC pirate AI behavior** | Full spec added to Gameplay → NPC Pirates tab: spawning rules, location-based triggers, difficulty tiers by security band, 4 behavior templates (orbit kiter, brawler, shield tank, EWAR support), state machine (idle→aggro→combat→flee→dead), new backend tables + agents. | +| **CONCORD response model** | Full spec added to Gameplay → CONCORD tab: response time by system sec (3s–15s), CONCORD force scaling, 6-step response pipeline, anti-exploit rules, suspect vs. criminal flag system, weapons timer. | +| **Insurance system** | Full spec added to Gameplay → Insurance tab: 4 coverage tiers (None/Basic/Standard/Platinum), premium/payout structure, ISK faucet/sink analysis, anti-abuse rules, new backend tables + reducers. | +| **Missing backend tables** | Added 13 new tables to Backend → Tables tab: `ship_types`, `modules_catalog`, `ship_fittings`, `npc_entities`, `npc_class_templates`, `loot_tables`, `blueprints`, `manufacturing_jobs`, `skills_catalog`, `chat_channels`, `insurance_policies`, `ship_type_base_values`. | +| **Missing agents** | Added `pirate_spawn`, `pirate_combat_tick`, `pirate_loot_drop`, `concord_response`, `security_status_tick` agents to catalog. | +| **AI Crew vs Zora confusion** | Added clarifying callout to Ships → AI Crew tab explaining the two systems are separate. | +| **Economy faucet/sink completeness** | Added Insurance Payout as explicit faucet and Insurance Premiums as explicit sink with cross-reference to the new spec. | + +--- + +## Executive Summary + +The GDD is remarkably thorough — 12 design pages, 9 interactive demos, a 16-phase roadmap, and a detailed backend schema. The documentation quality is high and internally consistent. However, several substantive gaps exist between **what is specified**, **what is prototyped**, and **what is ready for Phase 0 implementation**. These gaps fall into four categories: + +1. **Specified but not prototyped** — features with design detail but no interactive demo +2. **Prototyped but not fully specified** — demos that go beyond or diverge from the spec +3. **Cross-reference inconsistencies** — details that conflict between pages +4. **Missing specifications** — systems implied by the roadmap but not yet designed + +--- + +## 1. Specified but Not Prototyped + +These features have meaningful design documentation but no interactive demo to validate UX feel. + +| Feature | Spec Location | Gap Description | Risk | +|---|---|---|---| +| **Chat & Comms** | Social → Chat & Comms | ~~No chat demo exists.~~ Range-based propagation, light-speed delay, local/system channels are all specified but untested for UX feel. | **Resolved** — Chat & Comms demo created. Validates delay mechanics, channel switching, and pilot proximity. | +| **Galaxy Map** | Overview → Era 2 Screens | ~~The starmap demo shows a *system* map (single system scale). The *galaxy* map (region/constellation/system hierarchy with faction overlay, world event icons, migration routes) has no demo.~~ **Resolved:** Demo gallery now labels starmap as Era 2 Galaxy Map. The remaining gap is a separate Era 1 System Map demo. | **Partially Resolved** — Era 1 System Map still needs a demo. | +| **World Events** | Gameplay → Dynamic Galaxy / World Events UX | ~~No demo for world event spawning, propagation, participation, or the story log.~~ Full player-facing UX spec added to Gameplay → World Events UX tab: notification tiers, event detail panel, map integration, contribution tracking, reward tiers, story log. No interactive demo yet. | **Spec Resolved** — UX fully designed. Demo is nice-to-have, not blocking. | +| **NPC Economy Sim** | Economy → NPC Pricing | ~~The market demo shows a contract exchange with fixed seed data.~~ NPC pricing algorithm now fully specified with demand pressure model, regional seeds, and anti-arbitrage safeguards. No demo for full NPC supply/demand simulation over time yet. | **Spec Resolved** — Algorithm documented. Demo for full multi-station simulation is nice-to-have, not blocking. | +| **Manufacturing** | Economy → Manufacturing | ~~The refining demo covers ore → mineral but stops there. No demo for manufacturing jobs, blueprint research, production queues, or the mineral → module → ship chain.~~ Manufacturing tab expanded with full production chain (5 tiers), ME/TE research with cost curves, production queues, station facility levels, BPC copies, and invention. No demo yet. | **Spec Resolved** — Full manufacturing chain specified. Demo is Phase 5 scope. | +| **Ship AI (Zora)** | Ship AI (entire page) | ~~No demo at all.~~ Zora Tier 0 demo created validating deterministic template engine. Tier 1 (LLM-assisted) and Tier 2 (full agent) still unvalidated. | **Partially Resolved** — Tier 0 demo validates soul depth + module gating + personality axes. Higher tiers are post-MVP. | +| **Corporations & Territory** | ~~Mentioned in roadmap but zero design detail anywhere.~~ **Resolved:** Full spec added to Social → Corporations tab. Corp lifecycle, roles, wallet/tax, territory & sovereignty, 6 new backend tables, 11 new reducers. Phase 14 scope. | +| **Debug Panel** | ~~Listed as an Era 1 screen with minimal spec.~~ **Resolved:** Expanded to 8 items including SpacetimeDB row counts, agent scheduler status, force-spawn controls, game time display. Utility panel — no demo needed. | +| **Waypoints & Bookmarks** | Fully specified with backend tables, but no demo. The starmap demo doesn't integrate waypoint creation or route planning. | **Medium** — Navigation UX demo deferred to Phase 1 when System Map is built. Spec is complete. | + +--- + +## 2. Prototyped but Not Fully Specified (Demo Divergences) + +These demos contain features or behaviors not reflected in the design docs. + +| Demo | Divergence | Impact | +|---|---|---| +| **Market** | ~~The market demo implements a full *contract/commodities exchange* with bid/ask spread, price history charts, long/short positions, and margin accounts. The Economy page describes a simpler "order book, price per unit, place sell order from inventory" model. The demo is significantly more ambitious than the spec.~~ **Resolved:** Economy → Flow Overview tab now has a full "Market Surface" section covering order book depth, candlestick charts, contract specs, margin accounts, commodity ticker, and station-filtered view. Spec now matches the demo. | **Resolved** — Economy spec updated to match Market demo's feature set. | +| **Market** | Uses `₢` symbol for ISK in some places. The Overview page uses "ISK" and "credits" interchangeably. No canonical symbol defined. | **Inconsistency** — minor but should be standardized. | +| **Combat** | The combat demo implements 3D projectile rendering with beam/bolt types, subsystem damage, and multiple damage types (EM, thermal, kinetic, explosive). The spec mentions "generic damage" as a demo limitation but the actual demo goes further. | **Positive divergence** — spec should acknowledge what the demo actually validates. | +| **Combat** | Demo uses 4 power subsystems (Weapons/Shields/Engines/Aux). The spec consistently uses the same 4. This is consistent — no gap. | ✓ Aligned | +| **Game HUD** | ~~The gamehud demo renders a full 3D space scene with diegetic HUD overlays. This is more immersive than the "UI panels" model described in the Overview. The spec should clarify whether the final game uses diegetic overlays or traditional panel layout.~~ **Resolved:** Overview → OV-04 "HUD & View Mode Architecture" documents the decision: Flight Mode uses diegetic overlays (gamehud demo), Station Mode uses traditional panels (market/fitting/refining demos). Hybrid approach. | **Resolved** — HUD ambiguity resolved with two-view-mode architecture. | +| **Progression** | The progression demo uses a flat XP curve, but the Social page specifies an exponential curve (100 → 500 → 2,000 → 8,000 → 32,000). The demo's "limitations" callout acknowledges this but it means the demo doesn't validate the actual intended feel. | **Known limitation** — should be tracked for Phase 7. | + +--- + +## 3. Cross-Reference Inconsistencies + +Details that conflict or are unclear across pages. + +| Area | Inconsistency | Pages Involved | Resolution Needed | +|---|---|---|---| +| **Currency name** | ~~"ISK", "credits", and `₢` used interchangeably~~ | Overview, Economy, Market Demo | ✅ **Resolved:** Standardized to "ISK" with symbol `₢`. All "credits" references replaced. | +| **Ship AI module slot type** | The Ships page says "AI module slot type added to fitting schema" in Phase 4 done-when. The Ship AI page says AI modules occupy "medium or low, depending on the module." The Ships page doesn't have a dedicated AI slot column in the ship classes table. | Ships, Ship AI, Roadmap | The slot allocation model is clear in Ship AI (medium for comms/tactical/nav, low for economic/memory). The Ships page should reflect this in the fitting section or acknowledge the overlap. | +| **Ship AI Tier 0 timeline** | The Roadmap says "Tier 0 Zora" ships in Phase 7. The Ship AI page says Tier 0 = deterministic, no LLM. But Phase 7 also says "bare-bones soul state vector in SpacetimeDB." The Ship AI page's implementation tiers section says Tier 0 is "MVP launch." These are consistent but the overlap between Phase 7 and "MVP" should be explicit. | Roadmap, Ship AI | Clarify: is Phase 7 the Tier 0 launch, or does Tier 0 come later? The current text is consistent but could be more explicit. | +| **Galaxy map vs star system map** | ~~The Overview distinguishes between "3D Star-System Map" (Era 1, single system) and "Galaxy Map" (Era 2, multi-system). The starmap demo renders a multi-system galaxy view with warp routes. This is the Era 2 galaxy map, not the Era 1 system map.~~ **Resolved:** Demo gallery now labels starmap as "Star Map (Era 2 Galaxy Map)". Overview → OV-04 explicitly notes Era 1 System Map as needed. | **Partially Resolved** — Labeling fixed. Era 1 System Map demo still needed. | +| **Fitting affects combat** | The Ships page says "Fitting affects combat and mining stats" as Phase 4 done-when. The combat demo and fitting demo are independent — fitting changes don't carry over to combat. | Ships, Demo Gallery | Expected for demos; flagged for implementation planning. | +| **Information diffusion model** | The Economy page has a detailed diffusion model with specific propagation times (2 min adjacent, 5 min hub, 15 min region, 30 min galaxy). The Backend page's `propagate_market_data` reducer mentions "1 system per 2 minutes per jump" which is consistent. No gap, but the propagation pipeline has no demo. | Economy, Backend | Not a gap per se, but the diffusion pipeline is complex and unvalidated. | +| **Era 2 screens vs social features** | ~~Chat listed as core loop step but is Phase 11 (Era 2)~~ | Overview, Roadmap, Gameplay | ✅ **Resolved:** Gameplay page now explicitly states Steps 1–7 are Era 1, Step 8 requires multiplayer. | +| **Kill feed visibility** | Social page says "galaxy-wide feed." Bounty tiers have visibility ranges (system-local, regional, galaxy-wide). The kill feed demo shows all events everywhere. | Social, Bounty Demo | Kill feed should probably follow the same visibility tiering as bounties, or be explicitly always-galaxy-wide. | +| **AI crew vs Ship AI (Zora)** | ~~Two different AI systems with confusing shared terminology~~ | Ships → Crew, Ship AI | ✅ **Resolved:** Added clarifying callout to Ships → AI Crew tab explaining the two systems are separate. Future expansion may connect them post-MVP. | +| **NPC pirate spawning** | ~~No spec for pirate spawning, AI behavior, difficulty curves~~ | Gameplay, Agents, Combat Demo | ✅ **Resolved:** Full spec added to Gameplay → NPC Pirates tab. New agents added: `pirate_spawn`, `pirate_combat_tick`, `pirate_loot_drop`. | + +--- + +## 4. Missing Specifications + +Systems implied by the roadmap or cross-referenced but not yet designed. + +| Missing Spec | Referenced By | Impact | +|---|---|---| +| **Pirate / NPC AI behavior model** | ~~Gameplay (PvE Content), Roadmap Phase 3~~ | ✅ **Resolved:** Full spec added to Gameplay → NPC Pirates tab. | +| **Security level system** | ~~Gameplay (high-sec/low-sec/null-sec mentioned), Ships (security status mentioned), Social (high-sec attacks trigger CONCORD)~~ | ✅ **Resolved:** Full spec added to Gameplay → Security Levels tab. | +| **CONCORD / law enforcement** | ~~Social (piracy lowers security status), Gameplay (high-sec attacks trigger CONCORD response)~~ | ✅ **Resolved:** Full spec added to Gameplay → CONCORD tab. | +| **Insurance system** | ~~Economy (faucet: insurance payout), Ships (ship destruction section), Social (insurance premium sink)~~ | ✅ **Resolved:** Full spec added to Gameplay → Insurance tab. | +| **Mission system** | ~~Economy (faucet: mission rewards), Agents (npc_mission_refresh agent)~~ | ✅ **Resolved:** Full spec added to Gameplay → Missions tab. 6 mission types, agent interaction, standing, rewards, LP, backend tables. | +| **Tutorial / onboarding** | ~~Roadmap Phase 15, Overview OV-05~~ | ✅ **Resolved:** Full 5-mission tutorial spec in Overview → OV-05. Zora as guide. Skip allowed. Stuck detection. | +| **Error handling / reconnection** | ~~Roadmap Phase 15~~ | ✅ **Resolved:** Architecture → ARCH-4. 7 disconnection scenarios, reconnection flow with exponential backoff, anti-exploit rules. | +| **Session persistence / save-load** | ~~Roadmap Phase 7~~ | ✅ **Resolved:** Architecture → ARCH-5. No save button, no localStorage. SpacetimeDB is continuous persistence. Full table-by-table persistence guarantee. | +| **Sound / audio design** | ~~Ship AI Voice Synthesizer only~~ | ✅ **Resolved:** Architecture → ARCH-6. 6 audio categories, 6 volume sliders, spatial audio rules. | +| **Localization / i18n** | ~~Not mentioned~~ | ✅ **Resolved:** Architecture → ARCH-7. MVP English-only with day-one i18n architecture. | +| **Accessibility** | ~~Not mentioned~~ | ✅ **Resolved:** Architecture → ARCH-8. 8 accessibility areas, Gate 4 acceptance tests. | + +--- + +## 5. Backend Schema Gaps + +The Backend page lists 44 tables. Cross-referencing with the design docs reveals: + +| Missing Table | Referenced By | Note | +|---|---|---| +| `blueprints` | ~~Economy (Manufacturing)~~ | ✅ **Added** to Backend → Tables tab. | +| `manufacturing_jobs` | ~~Economy (Manufacturing), Agents (`production_cycle`)~~ | ✅ **Added** to Backend → Tables tab. | +| `ship_fittings` | ~~Ships (Fitting System)~~ | ✅ **Added** to Backend → Tables tab. | +| `modules_catalog` | ~~Ships (Fitting System)~~ | ✅ **Added** to Backend → Tables tab. | +| `ship_types` | ~~Ships (Ship Classes)~~ | ✅ **Added** to Backend → Tables tab. | +| `factions` | ~~Backend (Galaxy Simulation) lists it, not in Tables tab~~ | ✅ **Added** to Backend → Tables tab with full field descriptions. | +| `skills_catalog` | ~~Social (XP & Skills)~~ | ✅ **Added** to Backend → Tables tab. | +| `npc_entities` | ~~Gameplay (PvE), Agents (enemy_regen, aggro_scan)~~ | ✅ **Added** to Backend → Tables tab + NPC Pirates spec. | +| `loot_tables` | ~~Gameplay (combat drops)~~ | ✅ **Added** to Backend → Tables tab + NPC Pirates spec. | +| `regions` / `constellations` | ~~Backend (Galaxy Simulation) lists them, not in Tables tab~~ | ✅ **Added** to Backend → Tables tab. `systems` now has `constellation_id` FK. | +| `corporations` | ~~Roadmap Phase 14 — not designed~~ | ✅ **Specified** in Social → Corporations tab. 6 new tables added. Phase 14 scope. | +| `chat_channels` | ~~Social (Chat)~~ | ✅ **Added** to Backend → Tables tab. | + +--- + +## 6. Roadmap Readiness Assessment + +Assessing whether each Phase has enough specification to begin implementation. + +| Phase | Title | Spec Readiness | Blockers | +|---|---|---|---| +| **0** | Local Skeleton | ✅ Ready | None. Tech stack is specified. File structure is defined. | +| **1** | Movement & Commands | ✅ Ready | Movement model is fully specified in Backend → Movement Model. | +| **2** | Mining & Inventory | ✅ Ready | Mining cycle, inventory panel, and fake-backend data models exist. | +| **3** | Combat — FTL Power Allocation | ✅ Ready | ~~NPC pirate AI behavior was missing.~~ **Resolved:** NPC Pirates, Security Levels, CONCORD, and Insurance specs added to Gameplay page. | +| **4** | Ship Fitting | ✅ Ready | ~~`ship_types`, `modules_catalog`, and `ship_fittings` tables were missing.~~ **Resolved:** All three added to Backend → Tables tab. | +| **5** | Refining & Manufacturing | ✅ Ready | ~~Refining is well-specified. Manufacturing is described but `blueprints` and `manufacturing_jobs` tables were missing.~~ **Resolved:** Both tables added to Backend → Tables tab. | +| **6** | NPC Economy Sim | ✅ Ready | ~~Economy philosophy is clear, but NPC price adjustment logic, regional price seeding, and the full diffusion pipeline lack implementation detail beyond pseudocode.~~ **Resolved:** NPC pricing algorithm fully specified. Regional price seeds documented. Demand pressure model defined. Backend tables added. | +| **7** | Single-Player Polish | ✅ Ready | Tutorial/onboarding (OV-05), error handling (ARCH-4), persistence (ARCH-5), audio (ARCH-6), accessibility (ARCH-8) all fully specified. Tier 0 Zora demo exists. Mission system fully specified. | +| **8–15** | Era 2 | ✅ Ready | Era 2 depends on SpacetimeDB integration. Chat demo exists. World event UX fully specified. Corporations and territory fully specified (SOC-CORP). Multiplayer combat needs spec work (Phase 13 scope). | + +--- + +## 7. Priority Recommendations + +### Immediate (before starting Phase 0) + +1. ~~**Standardize currency naming**~~ ✅ **Done.** ISK + ₢ symbol. +2. ~~**Clarify core loop scope**~~ ✅ **Done.** Steps 1–7 = Era 1, Step 8 = Era 2. +3. ~~**Create `ship_types` and `modules_catalog` table specs**~~ ✅ **Done.** Added to Backend → Tables tab. + +### Before Phase 3 (Combat) + +4. ~~**Specify NPC pirate AI behavior**~~ ✅ **Done.** Gameplay → NPC Pirates tab. +5. ~~**Define security level system**~~ ✅ **Done.** Gameplay → Security Levels tab. +6. ~~**Specify CONCORD response model**~~ ✅ **Done.** Gameplay → CONCORD tab. + +### Before Phase 6 (Economy) + +7. ~~**Detail NPC price adjustment algorithm**~~ ✅ **Done.** Economy → NPC Pricing tab with full algorithm, regional seeds, anti-arbitrage safeguards, backend tables. +8. ~~**Create a chat prototype**~~ ✅ **Done.** Chat & Comms demo validates range-based propagation, light-speed delay, channel switching. + +### Before Era 2 + +9. ~~**Design the Galaxy Map**~~ ✅ **Done.** Era 2 Galaxy Map demo exists. Era 1 System Map is Phase 1 implementation scope. +10. ~~**World event UX prototype**~~ ✅ **Done.** Full UX spec added to Gameplay → World Events UX tab. +11. ~~**Clarify AI Crew vs Zora relationship**~~ ✅ **Done.** Added clarifying callout to Ships → AI Crew tab. +12. ~~**Corporations & Territory spec**~~ ✅ **Done.** Full spec added to Social → Corporations tab. 6 new tables, 11 new reducers, 3 structure tiers, sovereignty mechanics. Phase 14 scope. + +### Ongoing + +12. ~~**Add missing backend tables**~~ ✅ **Done.** All tables added to Backend → Tables tab including regions, constellations, factions. +13. ~~**Create a Zora Tier 0 demo**~~ ✅ **Done.** Deterministic template engine with 15 events × 5 soul depths, module gating, personality axes. Validates soul depth progression and module tradeoffs. +14. ~~**Error handling & reconnection spec**~~ ✅ **Done.** Architecture → ARCH-4. +15. ~~**Session persistence spec**~~ ✅ **Done.** Architecture → ARCH-5. +16. ~~**Audio design spec**~~ ✅ **Done.** Architecture → ARCH-6. +17. ~~**Localization decision**~~ ✅ **Done.** Architecture → ARCH-7. +18. ~~**Accessibility spec**~~ ✅ **Done.** Architecture → ARCH-8. + +--- + +## 8. What's Working Well + +The gaps above should not overshadow what this GDD does exceptionally well: + +- **Consistent vision**: The "spreadsheet simulator" design pillar is maintained across all pages. No page accidentally designs a flight sim. +- **Roadmap is honest**: Phase 0 is marked "in progress" and every subsequent phase has a concrete done-when condition. +- **Ship AI design is exceptional**: The soul/module/agent architecture is one of the most detailed and original companion AI designs I've seen in a game design document. The three implementation tiers (deterministic → LLM-assisted → full agent) show practical engineering thinking. +- **Backend schema is unusually detailed for a GDD**: 56+ tables with field descriptions is ahead of most game prototypes at this stage. +- **Demo coverage**: 11 interactive demos for a pre-Phase-0 GDD is impressive. Each demo honestly states its limitations. +- **Agent lifecycle system**: The scheduled_agents model with uniform lifecycle, three scheduling strategies, and kill-switch is production-quality thinking. +- **Information diffusion model**: The economy's info-asymmetry design is well-thought-through and explicitly tied to gameplay loops. diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..3e3c509 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,86 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { DocsLayout } from "./layouts/DocsLayout"; +import { SiteLayout } from "./layouts/SiteLayout"; +import { NotFound } from "./components/NotFound"; +import { LandingPage } from "./pages/LandingPage"; +import { ApplicationPage } from "./pages/ApplicationPage"; +import { OverviewPage } from "./pages/docs/OverviewPage"; +import { ArchitecturePage } from "./pages/docs/ArchitecturePage"; +import { TechStackPage } from "./pages/docs/TechStackPage"; +import { BackendPage } from "./pages/docs/BackendPage"; +import { AgentsPage } from "./pages/docs/AgentsPage"; +import { GameplayPage } from "./pages/docs/GameplayPage"; +import { ShipsPage } from "./pages/docs/ShipsPage"; +import { EconomyPage } from "./pages/docs/EconomyPage"; +import { SocialPage } from "./pages/docs/SocialPage"; +import { ShipAIPage } from "./pages/docs/ShipAIPage"; +import { RoadmapPage } from "./pages/docs/RoadmapPage"; +import { RisksPage } from "./pages/docs/RisksPage"; +import { DemoGalleryPage } from "./pages/docs/DemoGalleryPage"; +import { GapAnalysisPage } from "./pages/docs/GapAnalysisPage"; +import { DesignDocPage } from "./pages/docs/DesignDocPage"; +import { StarMapDemo } from "./prototypes/existing-demos/StarMapDemo"; +import { ShipMovementDemo } from "./prototypes/existing-demos/ShipMovementDemo"; +import { WarpTravelDemo } from "./prototypes/existing-demos/WarpTravelDemo"; +import { GameLoopSliceDemo } from "./prototypes/existing-demos/GameLoopSliceDemo"; +import { CombatDemo } from "./prototypes/existing-demos/CombatDemo"; +import { MarketDemo } from "./prototypes/existing-demos/MarketDemo"; +import { FittingDemo } from "./prototypes/existing-demos/FittingDemo"; +import { RefiningDemo } from "./prototypes/existing-demos/RefiningDemo"; +import { ProgressionDemo } from "./prototypes/existing-demos/ProgressionDemo"; +import { BountyDemo } from "./prototypes/existing-demos/BountyDemo"; +import { GameHudDemo } from "./prototypes/existing-demos/GameHudDemo"; +import { ChatDemo } from "./prototypes/existing-demos/ChatDemo"; +import { ZoraDemo } from "./prototypes/existing-demos/ZoraDemo"; +import { GalaxyDemo } from "./prototypes/existing-demos/GalaxyDemo"; +import { GameHudPrototype } from "./prototypes/standalone-huds/GameHudPrototype"; + +export function App() { + return ( + + }> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + } /> + + ); +} diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx new file mode 100644 index 0000000..c110411 --- /dev/null +++ b/src/components/NotFound.tsx @@ -0,0 +1,11 @@ +import { Link } from "react-router-dom"; + +export function NotFound() { + return ( +
+

Page Not Found

+

The requested route does not exist.

+ Back to documentation +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..c620ace --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,68 @@ +import { NavLink } from "react-router-dom"; +import { navSections } from "../data/nav"; + +type SidebarProps = { + collapsed: boolean; +}; + +export function Sidebar({ collapsed }: SidebarProps) { + return ( + + ); +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx new file mode 100644 index 0000000..9574e8f --- /dev/null +++ b/src/components/TopBar.tsx @@ -0,0 +1,37 @@ +import { Link, useLocation } from "react-router-dom"; +import { pageTitles } from "../data/nav"; + +type TopBarProps = { + collapsed: boolean; + onToggle: () => void; +}; + +export function TopBar({ collapsed, onToggle }: TopBarProps) { + const location = useLocation(); + const meta = pageTitles.get(location.pathname); + const section = meta?.section?.includes("Demo") || meta?.section?.includes("Prototype") ? "Demos" : "Docs"; + const title = meta?.title ?? "Overview"; + + return ( +
+ +
+ VOID::NAV + / + {section} + / + {title} +
+
+ Connected + + Prototype v0.1.0 +
+
+ ); +} diff --git a/src/data/fakeBackend.ts b/src/data/fakeBackend.ts new file mode 100644 index 0000000..8c18332 --- /dev/null +++ b/src/data/fakeBackend.ts @@ -0,0 +1,283 @@ +// @ts-nocheck +export type ModuleSlot = 'high' | 'med' | 'low'; + +export type System = { id: string; name: string; security: number; x: number; y: number; type: string; planets: number; stations: string[]; color: string }; +export type ShipModule = { id: string; name: string; type: string; slot: ModuleSlot; power: number; cpu: number; cycle: number; active: boolean; [key: string]: unknown }; +export type MarketOrder = { id: number; station: string; type: 'buy' | 'sell'; item: string; price: number; quantity: number; seller: string }; + +/* ---- Fake Data ---- */ +const SYSTEMS = [ + { id: 'sol', name: 'Sol', security: 1.0, x: 400, y: 300, type: 'G2V Star', planets: 8, stations: ['Jita IV - Moon 4', 'Amarr VIII'], color: '#fbbf24' }, + { id: 'amarr', name: 'Amarr', security: 0.9, x: 550, y: 220, type: 'K4V Star', planets: 6, stations: ['Amarr Prime'], color: '#f59e0b' }, + { id: 'heinoo', name: 'Hek', security: 0.7, x: 320, y: 180, type: 'M3V Star', planets: 4, stations: ['Hek VII'], color: '#22c55e' }, + { id: 'rens', name: 'Rens', security: 0.6, x: 250, y: 350, type: 'G9V Star', planets: 7, stations: ['Rens VI'], color: '#22d3ee' }, + { id: 'dodixie', name: 'Dodixie', security: 0.8, x: 480, y: 400, type: 'F7V Star', planets: 5, stations: ['Dodixie IX'], color: '#a78bfa' }, + { id: 'u-irtyr', name: 'U-IRTYR', security: 0.3, x: 150, y: 250, type: 'M7V Star', planets: 3, stations: [], color: '#ef4444' }, + { id: 'pf-346', name: 'PF-346', security: 0.2, x: 600, y: 350, type: 'K7V Star', planets: 2, stations: ['PF-346 II'], color: '#ef4444' }, + { id: 'huzzah', name: 'YZ-LQL', security: 0.1, x: 100, y: 400, type: 'M2V Star', planets: 1, stations: [], color: '#dc2626' }, + { id: 'pirates', name: 'O-WAMW', security: 0.0, x: 650, y: 150, type: 'M5V Red Giant', planets: 9, stations: [], color: '#991b1b' }, +]; + +const CONNECTIONS = [ + ['sol', 'amarr'], ['sol', 'heinoo'], ['sol', 'rens'], + ['amarr', 'dodixie'], ['amarr', 'pf-346'], ['amarr', 'pirates'], + ['heinoo', 'u-irtyr'], ['heinoo', 'rens'], + ['rens', 'u-irtyr'], ['rens', 'huzzah'], + ['dodixie', 'pf-346'], ['dodixie', 'sol'], + ['u-irtyr', 'huzzah'], ['pf-346', 'pirates'], +]; + +const ASTEROID_TYPES = ['Veldspar', 'Scordite', 'Pyroxeres', 'Kernite', 'Omber', 'Jaspet', 'Hemorphite', 'Arkonor']; +const ORE_PRICES = { Veldspar: 12, Scordite: 28, Pyroxeres: 45, Kernite: 85, Omber: 120, Jaspet: 190, Hemorphite: 340, Arkonor: 620 }; +const MODULES = [ + { id: 'laser1', name: 'Mining Laser I', type: 'mining', slot: 'high', power: 40, cpu: 30, cycle: 10, active: false }, + { id: 'laser2', name: 'Mining Laser II', type: 'mining', slot: 'high', power: 50, cpu: 40, cycle: 8, active: false }, + { id: 'shield1', name: 'Shield Booster I', type: 'shield', slot: 'med', power: 30, cpu: 30, cycle: 5, active: false }, + { id: 'turret1', name: '150mm Railgun', type: 'weapon', slot: 'high', power: 50, cpu: 35, damage: 25, cycle: 3, active: false }, + { id: 'turret2', name: '200mm Autocannon', type: 'weapon', slot: 'high', power: 45, cpu: 30, damage: 35, cycle: 2.5, active: false }, + { id: 'warp1', name: '1MN Afterburner', type: 'propulsion', slot: 'med', power: 20, cpu: 25, speed: 1.5, cycle: 0, active: false }, + { id: 'scram1', name: 'Warp Scrambler I', type: 'ewar', slot: 'med', power: 25, cpu: 25, range: 20, cycle: 0, active: false }, + { id: 'armor1', name: 'Armor Plate I', type: 'armor', slot: 'low', power: 20, cpu: 10, cycle: 0, active: false }, + { id: 'magstab1', name: 'Magnetic Field Stabilizer', type: 'damage_mod', slot: 'low', power: 5, cpu: 15, cycle: 0, active: false }, + { id: 'cargo1', name: 'Cargo Expander I', type: 'cargo', slot: 'low', power: 0, cpu: 15, cycle: 0, active: false }, +]; + +const MARKET_ORDERS = [ + { id: 1, station: 'Jita IV - Moon 4', type: 'sell', item: 'Veldspar', price: 14, quantity: 45000, seller: 'MinerKing42' }, + { id: 2, station: 'Jita IV - Moon 4', type: 'sell', item: 'Scordite', price: 32, quantity: 12000, seller: 'RockHound' }, + { id: 3, station: 'Jita IV - Moon 4', type: 'buy', item: 'Arkonor', price: 580, quantity: 500, seller: 'IndustrialMega' }, + { id: 4, station: 'Jita IV - Moon 4', type: 'sell', item: 'Kernite', price: 90, quantity: 8000, seller: 'DeepMiner' }, + { id: 5, station: 'Jita IV - Moon 4', type: 'buy', item: 'Pyroxeres', price: 42, quantity: 20000, seller: 'RefineryCorp' }, + { id: 6, station: 'Amarr Prime', type: 'sell', item: 'Omber', price: 135, quantity: 6000, seller: 'AmarrTrader' }, + { id: 7, station: 'Amarr Prime', type: 'buy', item: 'Jaspet', price: 180, quantity: 3000, seller: 'HighSecOps' }, + { id: 8, station: 'Rens VI', type: 'sell', item: 'Hemorphite', price: 360, quantity: 1200, seller: 'NullRunner' }, + { id: 9, station: 'Rens VI', type: 'sell', item: 'Veldspar', price: 11, quantity: 90000, seller: 'BulkMiner' }, + { id: 10, station: 'Dodixie IX', type: 'buy', item: 'Scordite', price: 30, quantity: 15000, seller: 'GallenteForge' }, +]; + +const PLAYER_INVENTORY = [ + { item: 'Veldspar', quantity: 8500, unitPrice: 12 }, + { item: 'Scordite', quantity: 2300, unitPrice: 28 }, + { item: 'Kernite', quantity: 400, unitPrice: 85 }, + { item: 'Pyroxeres', quantity: 1200, unitPrice: 45 }, +]; + +const PLAYER_SHIPS = [ + { id: 'ship1', name: 'Merlin', class: 'Frigate', system: 'Sol', status: 'active', highSlots: 3, medSlots: 3, lowSlots: 2, cpu: 120, powerGrid: 40, fitted: ['laser1', 'turret1', 'warp1'] }, + { id: 'ship2', name: 'Thrasher', class: 'Destroyer', system: 'Amarr', status: 'docked', highSlots: 7, medSlots: 3, lowSlots: 3, cpu: 180, powerGrid: 65, fitted: ['turret1', 'turret2', 'scram1'] }, +]; + +const PLAYER_BOUNTIES = [ + { target: 'PirateKing99', pool: 125000, tier: 'Dangerous', lastHostile: '2h ago' }, + { target: 'NullSecWarlord', pool: 520000, tier: 'Most Wanted', lastHostile: '30m ago' }, +]; + +const PLAYER_SKILLS = [ + { name: 'Gunnery', level: 2, xp: 380, nextLevel: 500, category: 'Combat' }, + { name: 'Mining', level: 3, xp: 1850, nextLevel: 2000, category: 'Industry' }, + { name: 'Refining', level: 2, xp: 420, nextLevel: 500, category: 'Industry' }, + { name: 'Navigation', level: 1, xp: 80, nextLevel: 100, category: 'Navigation' }, + { name: 'Broker Relations', level: 1, xp: 45, nextLevel: 100, category: 'Trade' }, +]; + +const KILL_FEED = [ + { victim: 'MinerBob', killer: 'PirateKing99', ship: 'Rifter', system: 'U-IRTYR', bounty: 5000, time: '5m ago' }, + { victim: 'TraderAlice', killer: 'CMDR Worf', ship: 'Hauler', system: 'Hek', bounty: 0, time: '12m ago' }, + { victim: 'PirateScout', killer: 'CMDR Picard', ship: 'Merlin', system: 'Sol', bounty: 2000, time: '25m ago' }, +]; + +/* ---- Simulated API ---- */ +const delay = (ms) => new Promise(r => setTimeout(r, ms + Math.random() * 50)); + +export const api = { + getSystems: async () => { await delay(80); return SYSTEMS.map(s => ({ ...s })); }, + getConnections: async () => { await delay(60); return CONNECTIONS.map(c => [...c]); }, + getSystemDetail: async (id) => { await delay(100); return SYSTEMS.find(s => s.id === id) || null; }, + setDestination: async (systemId) => { await delay(150); return { success: true, destination: systemId, eta: '3m 42s' }; }, + + getShipStatus: async () => { + await delay(100); + return { + name: 'Merlin', class: 'Frigate', system: 'Sol', + x: 400, y: 300, status: 'idle', speed: 0, maxSpeed: 250, + shields: 100, armor: 100, hull: 100, capacitor: 85, + cargo: { used: 12400, total: 25000 }, + target: null, + }; + }, + + getModules: async () => { await delay(80); return MODULES.map(m => ({ ...m, active: false })); }, + toggleModule: async (moduleId) => { await delay(100); return { success: true, moduleId, active: true }; }, + + getNearbyEntities: async () => { + await delay(120); + return [ + { id: 'npc1', name: 'Guristas Pirate', type: 'hostile', x: 430, y: 280, shields: 100, distance: 45 }, + { id: 'asteroid1', name: 'Veldspar Asteroid', type: 'asteroid', x: 370, y: 320, resource: 'Veldspar', quantity: 8500, distance: 12 }, + { id: 'station1', name: 'Jita IV - Moon 4', type: 'station', x: 410, y: 310, distance: 8 }, + { id: 'player2', name: 'CMDR LaForge', type: 'player', x: 390, y: 260, distance: 55 }, + ]; + }, + + getMarketOrders: async (stationId) => { await delay(150); return MARKET_ORDERS.filter(o => !stationId || o.station === stationId); }, + getPlayerInventory: async () => { await delay(80); return PLAYER_INVENTORY.map(i => ({ ...i })); }, + placeOrder: async (type, item, price, quantity) => { await delay(200); return { success: true, orderId: Date.now() }; }, + sellItem: async (item, quantity, stationId) => { await delay(250); return { success: true, isk: quantity * (ORE_PRICES[item] || 10) }; }, + + getChatMessages: async () => { + await delay(60); + return [ + { id: 1, sender: 'CMDR Picard', body: 'Heading to Jita with a cargo of Kernite.', time: '14:22' }, + { id: 2, sender: 'CMDR Worf', body: 'Pirates spotted near U-IRTYR gate. Stay alert.', time: '14:25' }, + { id: 3, sender: 'CMDR Data', body: 'Scordite prices up 12% in Amarr this hour.', time: '14:28' }, + { id: 4, sender: 'CMDR Troi', body: 'Anyone want to form a mining fleet in Sol?', time: '14:31' }, + ]; + }, + sendMessage: async (body) => { await delay(50); return { success: true, id: Date.now() }; }, + + getOrePrices: async () => { await delay(80); return { ...ORE_PRICES }; }, + + getShipFittings: async (shipId) => { await delay(100); const ship = PLAYER_SHIPS.find(s => s.id === shipId); return ship ? ship.fitted.map(id => MODULES.find(m => m.id === id)).filter(Boolean) : []; }, + getPlayerShips: async () => { await delay(100); return PLAYER_SHIPS.map(s => ({ ...s })); }, + getAvailableModules: async () => { await delay(80); return MODULES.map(m => ({ ...m })); }, + fitModule: async (shipId, moduleId) => { await delay(150); return { success: true, shipId, moduleId }; }, + unfitModule: async (shipId, slotIndex) => { await delay(100); return { success: true, shipId, slotIndex }; }, + + refineOre: async (oreType, quantity) => { + await delay(200); + const prices = ORE_PRICES[oreType] || 10; + return { success: true, ore: oreType, quantity, iskEarned: Math.floor(quantity * prices * 0.7), efficiency: 0.7 }; + }, + manufactureItem: async (blueprintId, stationId) => { await delay(300); return { success: true, jobId: Date.now(), eta: '5m 00s' }; }, + + getBounties: async () => { await delay(100); return PLAYER_BOUNTIES.map(b => ({ ...b })); }, + placeBounty: async (targetPlayer, amount) => { await delay(150); return { success: true, target: targetPlayer, amount }; }, + getKillFeed: async () => { await delay(80); return KILL_FEED.map(k => ({ ...k })); }, + + getPlayerSkills: async () => { await delay(100); return PLAYER_SKILLS.map(s => ({ ...s })); }, + + sendPrivateMessage: async (recipient, body) => { + const dist = Math.floor(Math.random() * 20); + const lightDelay = Math.floor(Math.sqrt(dist) * 2); + await delay(50); + return { success: true, id: Date.now(), recipient, lightDelay }; + }, + + getBookmarks: async () => { + await delay(80); + return [ + { id: 1, name: 'Safe spot Alpha', system: 'Sol', x: 380, y: 290, type: 'safe' }, + { id: 2, name: 'Good Veldspar belt', system: 'Amarr', x: 560, y: 210, type: 'mining' }, + { id: 3, name: 'Ambush point', system: 'U-IRTYR', x: 160, y: 260, type: 'tactical' }, + ]; + }, +}; + +const CELESTIAL_BODIES = { + 'sol': { + description: 'The cradle of humanity. A stable G2V main-sequence star hosting the busiest trade hub in known space.', + faction: 'CONCORD', + population: '12.4 billion', + resources: ['Veldspar', 'Scordite', 'Pyroxeres', 'Kernite'], + bodies: [ + { name: 'Mercury', type: 'rocky', orbit: 6, period: 4, size: 0.3, color: '#a0a0a0', ecc: 0.2, inc: 0.12, moons: [] }, + { name: 'Venus', type: 'rocky', orbit: 9, period: 6.5, size: 0.5, color: '#e8c56d', ecc: 0.01, inc: 0.06, moons: [], atmosphere: '#e8c56d' }, + { name: 'Earth', type: 'terrestrial', orbit: 13, period: 10, size: 0.55, color: '#4a90d9', ecc: 0.017, inc: 0, atmosphere: '#6ac0ff', moons: [{ name: 'Luna', orbit: 2, period: 2, size: 0.15, color: '#c0c0c0' }] }, + { name: 'Mars', type: 'rocky', orbit: 17, period: 14, size: 0.4, color: '#c1440e', ecc: 0.09, inc: 0.03, moons: [{ name: 'Phobos', orbit: 1.2, period: 0.8, size: 0.08, color: '#8a7a6a' }, { name: 'Deimos', orbit: 1.8, period: 1.5, size: 0.06, color: '#7a6a5a' }] }, + { name: 'Asteroid Belt', type: 'belt', innerOrbit: 22, outerOrbit: 26, count: 80 }, + { name: 'Jupiter', type: 'gas', orbit: 32, period: 35, size: 1.2, color: '#c88b3a', ecc: 0.048, inc: 0.02 }, + { name: 'Saturn', type: 'gas', orbit: 42, period: 50, size: 1.0, color: '#d4a560', ecc: 0.054, inc: 0.04, hasRings: true }, + ], + }, + 'amarr': { + description: 'Seat of the Amarr Empire. A warm K4V star surrounded by heavily industrialized worlds.', + faction: 'Amarr Empire', + population: '9.1 billion', + resources: ['Kernite', 'Omber', 'Pyroxeres'], + bodies: [ + { name: 'Amarr I', type: 'rocky', orbit: 5, period: 3, size: 0.35, color: '#d4a040', ecc: 0.02, inc: 0.01, moons: [] }, + { name: 'Amarr II', type: 'terrestrial', orbit: 10, period: 8, size: 0.65, color: '#c06030', ecc: 0.01, inc: 0.05, atmosphere: '#c08050', moons: [] }, + { name: 'Amarr III', type: 'gas', orbit: 18, period: 22, size: 0.9, color: '#8b6914', ecc: 0.03, inc: 0.02, moons: [{ name: 'Amarr III-a', orbit: 2, period: 1.5, size: 0.12, color: '#a08040' }] }, + { name: 'Asteroid Belt', type: 'belt', innerOrbit: 24, outerOrbit: 27, count: 60 }, + ], + }, + 'heinoo': { + description: 'A frontier system popular with independent miners. Rich in common ores and frequently trafficked by haulers.', + faction: 'Minmatar Republic', + population: '340 million', + resources: ['Veldspar', 'Scordite', 'Plagioclase'], + bodies: [ + { name: 'Hek I', type: 'rocky', orbit: 5, period: 3.5, size: 0.3, color: '#7a8a6a', ecc: 0.05, inc: 0.02, moons: [] }, + { name: 'Hek II', type: 'terrestrial', orbit: 11, period: 9, size: 0.55, color: '#4a8a5a', ecc: 0.03, inc: 0.01, atmosphere: '#6aaa7a', moons: [{ name: 'Hek II-a', orbit: 2, period: 1.8, size: 0.1, color: '#8a8a7a' }] }, + { name: 'Hek III', type: 'gas', orbit: 20, period: 25, size: 0.8, color: '#5a7a9a', ecc: 0.02, inc: 0.03 }, + ], + }, + 'rens': { + description: 'Major trade hub in the Minmatar Republic. Bustling commerce and a strong naval presence keep pirates at bay.', + faction: 'Minmatar Republic', + population: '2.1 billion', + resources: ['Scordite', 'Pyroxeres', 'Kernite'], + bodies: [ + { name: 'Rens I', type: 'rocky', orbit: 6, period: 4, size: 0.35, color: '#9a7a5a', ecc: 0.03, inc: 0.01, moons: [] }, + { name: 'Rens II', type: 'terrestrial', orbit: 12, period: 10, size: 0.6, color: '#5a7aaa', ecc: 0.02, inc: 0.04, atmosphere: '#7a9acc', moons: [{ name: 'Rens II-a', orbit: 1.8, period: 1.4, size: 0.12, color: '#aaaaaa' }] }, + { name: 'Asteroid Belt', type: 'belt', innerOrbit: 18, outerOrbit: 22, count: 70 }, + { name: 'Rens III', type: 'gas', orbit: 28, period: 35, size: 1.1, color: '#aa7a3a', ecc: 0.04, inc: 0.02 }, + ], + }, + 'dodixie': { + description: 'Federation commerce hub. High-tech industry and cutting-edge research facilities orbit its F7V star.', + faction: 'Gallente Federation', + population: '3.8 billion', + resources: ['Omber', 'Kernite', 'Jaspet'], + bodies: [ + { name: 'Dodixie I', type: 'rocky', orbit: 4, period: 2.5, size: 0.25, color: '#6a5a7a', ecc: 0.01, inc: 0, moons: [] }, + { name: 'Dodixie II', type: 'terrestrial', orbit: 9, period: 7, size: 0.5, color: '#5a8aaa', ecc: 0.015, inc: 0.02, atmosphere: '#7aaacc', moons: [] }, + { name: 'Dodixie III', type: 'gas', orbit: 16, period: 18, size: 0.85, color: '#7a6aaa', ecc: 0.02, inc: 0.01, hasRings: true, moons: [{ name: 'Dodixie III-a', orbit: 2.5, period: 2, size: 0.1, color: '#9a8aba' }] }, + ], + }, + 'u-irtyr': { + description: 'Dangerous low-security system. Pirate activity is rampant and uncharted asteroid fields hide valuable ores.', + faction: 'Unclaimed', + population: '~2,000', + resources: ['Hemorphite', 'Jaspet', 'Arkonor'], + bodies: [ + { name: 'U-IRTYR I', type: 'rocky', orbit: 5, period: 3, size: 0.35, color: '#6a4a3a', ecc: 0.15, inc: 0.08, moons: [] }, + { name: 'U-IRTYR II', type: 'rocky', orbit: 10, period: 8, size: 0.3, color: '#5a3a2a', ecc: 0.12, inc: 0.1, moons: [] }, + { name: 'Asteroid Belt', type: 'belt', innerOrbit: 15, outerOrbit: 22, count: 100 }, + ], + }, + 'pf-346': { + description: 'Low-sec border system. Contested territory with valuable resources and frequent skirmishes.', + faction: 'Contested', + population: '~15,000', + resources: ['Jaspet', 'Hemorphite', 'Kernite'], + bodies: [ + { name: 'PF-346 I', type: 'rocky', orbit: 6, period: 4, size: 0.3, color: '#7a5a4a', ecc: 0.08, inc: 0.04, moons: [] }, + { name: 'PF-346 II', type: 'terrestrial', orbit: 12, period: 10, size: 0.5, color: '#4a6a5a', ecc: 0.05, inc: 0.03, moons: [{ name: 'PF-346 II-a', orbit: 1.5, period: 1.2, size: 0.08, color: '#8a8a7a' }] }, + ], + }, + 'huzzah': { + description: 'Null-sec wasteland. No law, no stations, no mercy. Rare ores attract the desperate and the bold.', + faction: 'Unclaimed', + population: '< 100', + resources: ['Arkonor', 'Bistot', 'Crokmite'], + bodies: [ + { name: 'YZ-LQL I', type: 'rocky', orbit: 5, period: 3.5, size: 0.25, color: '#4a3a2a', ecc: 0.2, inc: 0.15, moons: [] }, + { name: 'Asteroid Belt', type: 'belt', innerOrbit: 8, outerOrbit: 15, count: 120 }, + ], + }, + 'pirates': { + description: 'Deep null-sec. Pirate stronghold with hidden bases and rich, unexploited asteroid belts.', + faction: 'Guristas Pirates', + population: 'Unknown', + resources: ['Arkonor', 'Mercoxit', 'Dark Ochre'], + bodies: [ + { name: 'O-WAMW I', type: 'gas', orbit: 8, period: 6, size: 0.7, color: '#8a3a3a', ecc: 0.06, inc: 0.03, moons: [{ name: 'O-WAMW I-a', orbit: 2, period: 1.5, size: 0.12, color: '#6a4a4a' }] }, + { name: 'O-WAMW II', type: 'gas', orbit: 18, period: 20, size: 1.0, color: '#5a2a5a', ecc: 0.04, inc: 0.05, hasRings: true }, + { name: 'Asteroid Belt', type: 'belt', innerOrbit: 25, outerOrbit: 32, count: 90 }, + { name: 'O-WAMW III', type: 'rocky', orbit: 38, period: 45, size: 0.4, color: '#3a2a3a', ecc: 0.1, inc: 0.08, moons: [] }, + ], + }, +}; + +export const CONSTANTS = { SYSTEMS, CONNECTIONS, ASTEROID_TYPES, ORE_PRICES, MODULES, MARKET_ORDERS, CELESTIAL_BODIES }; diff --git a/src/data/nav.ts b/src/data/nav.ts new file mode 100644 index 0000000..9b5b4d2 --- /dev/null +++ b/src/data/nav.ts @@ -0,0 +1,63 @@ +export type NavItem = { + path: string; + label: string; + icon: string; +}; + +export type NavSection = { + title: string; + items: NavItem[]; +}; + +export const navSections: NavSection[] = [ + { + title: "Documentation", + items: [ + { path: "/docs/overview", icon: "◈", label: "Overview" }, + { path: "/docs/architecture", icon: "⬡", label: "Architecture" }, + { path: "/docs/tech-stack", icon: "⟐", label: "Tech Stack" }, + { path: "/docs/backend", icon: "⊞", label: "Backend Model" }, + { path: "/docs/agents", icon: "⏣", label: "Agent Lifecycle" }, + { path: "/docs/gameplay", icon: "◉", label: "Gameplay Loop" }, + { path: "/docs/ships", icon: "◇", label: "Ships & Fitting" }, + { path: "/docs/economy", icon: "⇄", label: "Economy & Industry" }, + { path: "/docs/social", icon: "✧", label: "Progression & Social" }, + { path: "/docs/ship-ai", icon: "◈", label: "Ship AI - Zora" }, + { path: "/docs/roadmap", icon: "⊞", label: "Roadmap" }, + { path: "/docs/risks", icon: "◬", label: "Risks & Questions" }, + { path: "/docs/gap-analysis", icon: "□", label: "Gap Analysis" }, + { path: "/docs/design-doc", icon: "▣", label: "Design Doc" }, + ], + }, + { + title: "Interactive Demos", + items: [ + { path: "/docs/demos/game-loop", icon: "◎", label: "MVP Loop Slice" }, + { path: "/docs/demos/starmap", icon: "✦", label: "Star Map" }, + { path: "/docs/demos/movement", icon: "→", label: "Ship Movement" }, + { path: "/docs/demos/warp", icon: "⟡", label: "Warp Travel" }, + { path: "/docs/demos/combat", icon: "✸", label: "Combat System" }, + { path: "/docs/demos/market", icon: "⇄", label: "Market" }, + { path: "/docs/demos/fitting", icon: "⊞", label: "Ship Fitting" }, + { path: "/docs/demos/refining", icon: "⚗", label: "Refining & Manufacturing" }, + { path: "/docs/demos/progression", icon: "▲", label: "Skill Progression" }, + { path: "/docs/demos/bounty", icon: "✸", label: "Bounty & Kill Feed" }, + { path: "/docs/demos/game-hud", icon: "◉", label: "Game HUD" }, + { path: "/docs/demos/chat", icon: "💬", label: "Chat & Comms" }, + { path: "/docs/demos/zora", icon: "🤖", label: "Zora Tier 0" }, + { path: "/docs/demos/galaxy", icon: "🌌", label: "Galaxy Generator" }, + ], + }, + { + title: "Style Reference", + items: [ + { path: "/docs/prototypes/game-hud", icon: "◉", label: "HUD Style Reference" }, + ], + }, +]; + +export const pageTitles = new Map( + navSections.flatMap((section) => + section.items.map((item) => [item.path, { title: item.label, section: section.title }] as const), + ), +); diff --git a/src/layouts/DocsLayout.tsx b/src/layouts/DocsLayout.tsx new file mode 100644 index 0000000..9a38b61 --- /dev/null +++ b/src/layouts/DocsLayout.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { Sidebar } from "../components/Sidebar"; +import { TopBar } from "../components/TopBar"; + +export function DocsLayout() { + const [collapsed, setCollapsed] = useState(false); + const { pathname } = useLocation(); + const isFullscreenDemo = pathname.startsWith("/docs/demos/") || pathname.startsWith("/docs/prototypes/"); + + if (isFullscreenDemo) { + return ( +
+
+
+ +
+
+
+ ); + } + + return ( +
+ +
+ setCollapsed((value) => !value)} /> +
+ +
+
+
+ ); +} diff --git a/src/layouts/SiteLayout.tsx b/src/layouts/SiteLayout.tsx new file mode 100644 index 0000000..d07038b --- /dev/null +++ b/src/layouts/SiteLayout.tsx @@ -0,0 +1,5 @@ +import { Outlet } from "react-router-dom"; + +export function SiteLayout() { + return ; +} diff --git a/src/lib/threeHelpers.ts b/src/lib/threeHelpers.ts new file mode 100644 index 0000000..0f7e357 --- /dev/null +++ b/src/lib/threeHelpers.ts @@ -0,0 +1,823 @@ +// @ts-nocheck +import * as THREE from 'three'; + +/* ── Renderer factory ── */ +export const createRenderer = function (container: HTMLElement, opts: any = {}) { + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: opts.alpha || false, + powerPreference: 'high-performance', + }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.setClearColor(opts.clearColor || 0x040810, 1); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + container.appendChild(renderer.domElement); + renderer.domElement.style.display = 'block'; + return renderer; +}; + +/* ── Resize helper ── */ +export const handleResize = function (renderer: any, camera: any, container: HTMLElement) { + const w = container.clientWidth; + const h = container.clientHeight; + renderer.setSize(w, h); + if (camera.isPerspectiveCamera) { + camera.aspect = w / h; + camera.updateProjectionMatrix(); + } +}; + +/* ── Star field (Points) ── */ +export const createStarField = function (count = 2000, spread = 2000) { + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + const sizes = new Float32Array(count); + for (let i = 0; i < count; i++) { + positions[i * 3] = (Math.random() - 0.5) * spread; + positions[i * 3 + 1] = (Math.random() - 0.5) * spread; + positions[i * 3 + 2] = (Math.random() - 0.5) * spread; + const brightness = 0.4 + Math.random() * 0.6; + colors[i * 3] = brightness; + colors[i * 3 + 1] = brightness; + colors[i * 3 + 2] = brightness + Math.random() * 0.15; + sizes[i] = 0.5 + Math.random() * 1.5; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const mat = new THREE.PointsMaterial({ + size: 1.5, + vertexColors: true, + transparent: true, + opacity: 0.8, + sizeAttenuation: true, + }); + return new THREE.Points(geo, mat); +}; + +/* ── Nebula background (soft sphere) ── */ +export const addNebula = function (scene: any, color = 0x22d3ee, pos: [number, number, number] = [0, 0, -500], scale = 600) { + const geo = new THREE.SphereGeometry(1, 16, 16); + const mat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.015, + side: THREE.BackSide, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(...pos); + mesh.scale.setScalar(scale); + scene.add(mesh); + return mesh; +}; + +/* ── Ship mesh (wedge shape) ── */ +export const createShipMesh = function (color = 0xc8d6e5, emissive = 0xf0a030, size = 1) { + const s = size; + // Wedge-shaped ship using BufferGeometry + const vertices = new Float32Array([ + // Top face + 14*s, 0, 0, -8*s, 0, -6*s, -8*s, 0, 6*s, + // Bottom face + 14*s, -2*s, 0, -8*s, -2*s, 6*s, -8*s, -2*s, -6*s, + // Front + 14*s, 0, 0, 14*s, -2*s, 0, -8*s, -2*s, 6*s, + 14*s, 0, 0, -8*s, -2*s, 6*s, -8*s, 0, 6*s, + // Front-right + 14*s, 0, 0, -8*s, 0, -6*s, 14*s, -2*s, 0, + 14*s, -2*s, 0, -8*s, 0, -6*s, -8*s, -2*s, -6*s, + // Back + -8*s, 0, -6*s, -8*s, 0, 6*s, -8*s, -2*s, 6*s, + -8*s, 0, -6*s, -8*s, -2*s, 6*s, -8*s, -2*s, -6*s, + // Left side + -8*s, 0, 6*s, -8*s, -2*s, 6*s, 14*s, -2*s, 0, + -8*s, 0, 6*s, 14*s, -2*s, 0, 14*s, 0, 0, + // Right side + 14*s, 0, 0, 14*s, -2*s, 0, -8*s, -2*s, -6*s, + 14*s, 0, 0, -8*s, -2*s, -6*s, -8*s, 0, -6*s, + ]); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); + geo.computeVertexNormals(); + + const mat = new THREE.MeshStandardMaterial({ + color, + emissive, + emissiveIntensity: 0.15, + metalness: 0.6, + roughness: 0.3, + flatShading: true, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.castShadow = true; + return mesh; +}; + +/* ── Engine glow ── */ +export const createEngineGlow = function (color = 0x22d3ee, intensity = 2, distance = 15) { + const light = new THREE.PointLight(color, intensity, distance); + return light; +}; + +export const createEngineTrail = function (color = 0x22d3ee, particleCount = 30) { + const positions = new Float32Array(particleCount * 3); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ + color, + size: 1.5, + transparent: true, + opacity: 0.5, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + return new THREE.Points(geo, mat); +}; + +/* ── Grid plane ── */ +export const createGrid = function (size = 600, divisions = 30, color = 0x0d1520) { + const grid = new THREE.GridHelper(size, divisions, color, color); + grid.material.transparent = true; + grid.material.opacity = 0.25; + // GridHelper is XZ-plane by default — keep as is + return grid; +}; + +/* ── Glow sprite (for star systems, explosions) ── */ +export const createGlowSprite = function (color = 0xffffff, size = 5) { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + gradient.addColorStop(0, `rgba(255,255,255,0.8)`); + gradient.addColorStop(0.2, `rgba(255,255,255,0.4)`); + gradient.addColorStop(0.5, `rgba(255,255,255,0.1)`); + gradient.addColorStop(1, `rgba(255,255,255,0)`); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 64, 64); + + const texture = new THREE.CanvasTexture(canvas); + const mat = new THREE.SpriteMaterial({ + map: texture, + color, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const sprite = new THREE.Sprite(mat); + sprite.scale.setScalar(size); + return sprite; +}; + +/* ── Text label (Sprite with canvas texture) ── */ +export const createLabel = function (text, color = '#d4dce8', fontSize = 24) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`; + const metrics = ctx.measureText(text); + const width = Math.ceil(metrics.width) + 16; + const height = fontSize + 12; + + canvas.width = width; + canvas.height = height; + + ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = color; + ctx.fillText(text, width / 2, height / 2); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + const mat = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + depthWrite: false, + }); + const sprite = new THREE.Sprite(mat); + const aspect = width / height; + sprite.scale.set(aspect * 1.2, 1.2, 1); + return sprite; +}; + +/* ── Shield sphere ── */ +export const createShield = function (radius = 2.5, color = 0x22d3ee, opacity = 0.08) { + const geo = new THREE.SphereGeometry(radius, 24, 24); + const mat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity, + side: THREE.DoubleSide, + depthWrite: false, + }); + return new THREE.Mesh(geo, mat); +}; + +/* ── Projectile ── */ +export const createProjectile = function (type = 'bullet') { + let mesh; + if (type === 'beam') { + const geo = new THREE.CylinderGeometry(0.05, 0.05, 1, 4); + geo.rotateX(Math.PI / 2); + const mat = new THREE.MeshBasicMaterial({ color: 0xef4444, transparent: true, opacity: 0.9 }); + mesh = new THREE.Mesh(geo, mat); + } else if (type === 'missile') { + const geo = new THREE.SphereGeometry(0.15, 6, 6); + const mat = new THREE.MeshBasicMaterial({ color: 0xf0a030 }); + mesh = new THREE.Mesh(geo, mat); + } else if (type === 'pulse') { + const geo = new THREE.RingGeometry(0.5, 1, 32); + const mat = new THREE.MeshBasicMaterial({ color: 0xa78bfa, transparent: true, opacity: 0.7, side: THREE.DoubleSide }); + mesh = new THREE.Mesh(geo, mat); + } else { + const geo = new THREE.SphereGeometry(0.1, 4, 4); + const mat = new THREE.MeshBasicMaterial({ color: 0xf0a030 }); + mesh = new THREE.Mesh(geo, mat); + } + return mesh; +}; + +/* ── Impact flash ── */ +export const createImpact = function (color = 0xef4444, size = 2) { + return createGlowSprite(color, size); +}; + +/* ── Asteroid mesh ── */ +export const createAsteroid = function (size = 1, color = 0x3d2a5c) { + const geo = new THREE.IcosahedronGeometry(size, 0); + // Perturb vertices for rocky look + const pos = geo.attributes.position; + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i); + const y = pos.getY(i); + const z = pos.getZ(i); + const noise = 0.7 + Math.random() * 0.6; + pos.setXYZ(i, x * noise, y * noise, z * noise); + } + geo.computeVertexNormals(); + const mat = new THREE.MeshStandardMaterial({ + color, + emissive: 0xa78bfa, + emissiveIntensity: 0.05, + flatShading: true, + roughness: 0.8, + metalness: 0.2, + }); + return new THREE.Mesh(geo, mat); +}; + +/* ── Station mesh ── */ +export const createStation = function (size = 2, color = 0x22d3ee) { + const group = new THREE.Group(); + // Main body + const bodyGeo = new THREE.BoxGeometry(size * 2, size, size); + const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.1, metalness: 0.7, roughness: 0.3 }); + const body = new THREE.Mesh(bodyGeo, mat); + group.add(body); + // Cross bar + const crossGeo = new THREE.BoxGeometry(size, size * 0.3, size * 2); + const cross = new THREE.Mesh(crossGeo, mat); + group.add(cross); + // Glow + const glow = createGlowSprite(color, size * 3); + glow.position.z = -size; + group.add(glow); + return group; +}; + +/* ── Connection line (for star map) ── */ +export const createConnectionLine = function (from, to, color = 0x1c2a3f, opacity = 0.6) { + const points = [ + new THREE.Vector3(from.x, from.y, from.z || 0), + new THREE.Vector3(to.x, to.y, to.z || 0), + ]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity }); + return new THREE.Line(geo, mat); +}; + +/* ── Raycaster for mouse picking ── */ +export const raycast = function (mouse, camera, objects) { + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(mouse, camera); + return raycaster.intersectObjects(objects, true); +}; + +/* ── Orbit camera (simple, no OrbitControls dependency) ── */ +export const createOrbitCamera = function (target, distance = 50, angle = Math.PI / 4) { + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 5000); + camera.position.set( + target.x + Math.cos(angle) * distance, + target.y + Math.sin(angle) * distance * 0.5, + target.z + Math.sin(angle) * distance + ); + camera.lookAt(target); + return camera; +}; + +/* ── Enhanced camera controller (orbit + pan + smooth fly-to) ── */ +export const OrbitController = function (camera, domElement, target = new THREE.Vector3()) { + this.camera = camera; + this.target = target.clone(); + this.distance = camera.position.distanceTo(target); + this.theta = Math.atan2(camera.position.x - target.x, camera.position.z - target.z); + this.phi = Math.acos(Math.min(1, Math.max(-1, (camera.position.y - target.y) / Math.max(0.001, this.distance)))); + + // Configuration + this.damping = 0.92; + this.dampingFactor = 0.06; + this.rotateSpeed = 0.4; + this.zoomSpeed = 0.8; + this.panSpeed = 0.5; + this.minDistance = 5; + this.maxDistance = 500; + this.minPolarAngle = 0.1; + this.maxPolarAngle = Math.PI - 0.1; + this.enablePan = true; + this.panButton = 2; // right-click + + // State + this.isDragging = false; + this.isPanning = false; + this.lastMouse = { x: 0, y: 0 }; + this.velocity = { theta: 0, phi: 0 }; + this.panVelocity = { x: 0, y: 0 }; + + // Fly-to animation state + this._flyActive = false; + this._flyStart = { theta: 0, phi: 0, dist: 0, tx: 0, ty: 0, tz: 0 }; + this._flyEnd = { theta: 0, phi: 0, dist: 0, tx: 0, ty: 0, tz: 0 }; + this._flyT = 0; + this._flyDuration = 1.0; // seconds + + const self = this; + + const _ctxMenuHandler = function(e) { e.preventDefault(); }; + + const onDown = (e) => { + // Cancel fly-to on user interaction + self._flyActive = false; + if (e.button === self.panButton && self.enablePan) { + self.isPanning = true; + } else if (e.button === 0) { + self.isDragging = true; + } + self.lastMouse = { x: e.clientX, y: e.clientY }; + }; + + const onUp = () => { + self.isDragging = false; + self.isPanning = false; + }; + + const onMove = (e) => { + const dx = e.clientX - self.lastMouse.x; + const dy = e.clientY - self.lastMouse.y; + self.lastMouse = { x: e.clientX, y: e.clientY }; + + if (self.isDragging) { + self.velocity.theta -= dx * 0.005 * self.rotateSpeed; + self.velocity.phi += dy * 0.005 * self.rotateSpeed; + } + + if (self.isPanning && self.enablePan) { + // Pan in the camera's local X/Y plane + const panOffset = new THREE.Vector3(); + const right = new THREE.Vector3(); + const up = new THREE.Vector3(0, 1, 0); + right.crossVectors(self.camera.getWorldDirection(new THREE.Vector3()), up).normalize(); + const actualUp = new THREE.Vector3().crossVectors(right, self.camera.getWorldDirection(new THREE.Vector3())).normalize(); + + const factor = self.distance * 0.002 * self.panSpeed; + panOffset.addScaledVector(right, -dx * factor); + panOffset.addScaledVector(actualUp, dy * factor); + + self.target.add(panOffset); + self.panVelocity.x = -dx * factor * 0.3; + self.panVelocity.y = dy * factor * 0.3; + } + }; + + const onWheel = (e) => { + self._flyActive = false; + const delta = e.deltaY * 0.05 * self.zoomSpeed; + // Exponential zoom so it feels smooth at all distances + self.distance *= (1 + delta / self.distance * 2); + self.distance = Math.max(self.minDistance, Math.min(self.maxDistance, self.distance)); + }; + + // Register event listeners + domElement.addEventListener('mousedown', onDown); + domElement.addEventListener('mouseup', onUp); + domElement.addEventListener('mouseleave', onUp); + domElement.addEventListener('mousemove', onMove); + domElement.addEventListener('wheel', onWheel, { passive: false }); + domElement.addEventListener('contextmenu', _ctxMenuHandler); + + // Smooth fly-to a world position + this.flyTo = function (x, y, z, dist) { + this._flyStart = { + theta: this.theta, + phi: this.phi, + dist: this.distance, + tx: this.target.x, + ty: this.target.y, + tz: this.target.z, + }; + const dx = x - this.target.x; + const dz = z - this.target.z; + const dy = y - this.target.y; + this._flyEnd = { + theta: Math.atan2(dx, dz), + phi: Math.PI / 3, // 60° — good overhead-ish angle + dist: dist || Math.min(60, this.distance * 0.6), + tx: x, + ty: y, + tz: z, + }; + this._flyT = 0; + this._flyActive = true; + }; + + // Easing function (cubic ease-in-out) + const easeInOut = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + + this.update = function (dt) { + // Fly-to animation + if (this._flyActive) { + this._flyT += (dt || 0.016) / this._flyDuration; + if (this._flyT >= 1) { + this._flyT = 1; + this._flyActive = false; + } + const t = easeInOut(this._flyT); + this.theta = this._flyStart.theta + (this._flyEnd.theta - this._flyStart.theta) * t; + this.phi = this._flyStart.phi + (this._flyEnd.phi - this._flyStart.phi) * t; + this.distance = this._flyStart.dist + (this._flyEnd.dist - this._flyStart.dist) * t; + this.target.x = this._flyStart.tx + (this._flyEnd.tx - this._flyStart.tx) * t; + this.target.y = this._flyStart.ty + (this._flyEnd.ty - this._flyStart.ty) * t; + this.target.z = this._flyStart.tz + (this._flyEnd.tz - this._flyStart.tz) * t; + } else { + // Damped rotation + if (!this.isDragging) { + this.velocity.theta *= this.damping; + this.velocity.phi *= this.damping; + } + this.theta += this.velocity.theta; + this.phi += this.velocity.phi; + + // Damped pan + if (!this.isPanning) { + this.target.x += this.panVelocity.x; + this.target.y += this.panVelocity.y; + this.panVelocity.x *= this.damping; + this.panVelocity.y *= this.damping; + } + } + + // Clamp polar angle + this.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.phi)); + + // Clamp distance + this.distance = Math.max(this.minDistance, Math.min(this.maxDistance, this.distance)); + + this.camera.position.set( + this.target.x + Math.sin(this.theta) * Math.sin(this.phi) * this.distance, + this.target.y + Math.cos(this.phi) * this.distance, + this.target.z + Math.cos(this.theta) * Math.sin(this.phi) * this.distance + ); + this.camera.lookAt(this.target); + }; + + this.dispose = function () { + domElement.removeEventListener('mousedown', onDown); + domElement.removeEventListener('mouseup', onUp); + domElement.removeEventListener('mouseleave', onUp); + domElement.removeEventListener('mousemove', onMove); + domElement.removeEventListener('wheel', onWheel); + domElement.removeEventListener('contextmenu', _ctxMenuHandler); + }; +}; + +/* ── Lighting setup for space scenes ── */ +export const setupSpaceLighting = function (scene) { + const ambient = new THREE.AmbientLight(0x223344, 0.4); + scene.add(ambient); + const dir = new THREE.DirectionalLight(0xffffff, 0.6); + dir.position.set(50, 100, 50); + scene.add(dir); + const fill = new THREE.DirectionalLight(0x22d3ee, 0.15); + fill.position.set(-50, -30, -50); + scene.add(fill); +}; + +/* ── Warp/dashed line (for routes) ── */ +export const createRouteLine = function (points, color = 0x22d3ee) { + const geo = new THREE.BufferGeometry().setFromPoints( + points.map(p => new THREE.Vector3(p.x, p.y, p.z || 0)) + ); + const mat = new THREE.LineDashedMaterial({ + color, + dashSize: 3, + gapSize: 1.5, + transparent: true, + opacity: 0.6, + }); + const line = new THREE.Line(geo, mat); + line.computeLineDistances(); + return line; +}; + +/* ── Update label text (recreates canvas texture) ── */ +export const updateLabelText = function (sprite, text, color = '#d4dce8', fontSize = 24) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`; + const metrics = ctx.measureText(text); + const width = Math.ceil(metrics.width) + 16; + const height = fontSize + 12; + canvas.width = width; + canvas.height = height; + ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = color; + ctx.fillText(text, width / 2, height / 2); + if (sprite.material.map) sprite.material.map.dispose(); + sprite.material.map = new THREE.CanvasTexture(canvas); + sprite.material.map.minFilter = THREE.LinearFilter; + const aspect = width / height; + sprite.scale.set(aspect * 1.2, 1.2, 1); + sprite.material.needsUpdate = true; +}; + +/* ── Star system sphere (for starmap) ── */ +export const createStarSystem = function (system, scale = 1) { + const group = new THREE.Group(); + group.userData = system; + + // Core sphere + const coreGeo = new THREE.SphereGeometry(1.5 * scale, 12, 12); + const coreMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(system.color) }); + const core = new THREE.Mesh(coreGeo, coreMat); + group.add(core); + + // Glow + const glow = createGlowSprite(new THREE.Color(system.color).getHex(), 6 * scale); + group.add(glow); + + // Label + const label = createLabel(system.name, '#d4dce8', 20); + label.position.y = 4 * scale; + group.add(label); + + group.position.set(system.x * scale, system.y * scale, 0); + return group; +}; + +/* ── Explosion particles ── */ +export const createExplosion = function (position, color = 0xef4444, count = 20) { + const particles = []; + for (let i = 0; i < count; i++) { + const geo = new THREE.SphereGeometry(0.1, 4, 4); + const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 1 }); + const p = new THREE.Mesh(geo, mat); + p.position.copy(position); + p.userData.velocity = new THREE.Vector3( + (Math.random() - 0.5) * 2, + (Math.random() - 0.5) * 2, + (Math.random() - 0.5) * 2 + ); + p.userData.life = 1; + particles.push(p); + } + return particles; +}; + +/* ── Camera follow (smooth) ── */ +export const followTarget = function (camera, target, offset = { x: 0, y: 30, z: 30 }, smoothing = 0.05) { + const desired = new THREE.Vector3( + target.x + offset.x, + target.y + offset.y, + target.z + offset.z + ); + camera.position.lerp(desired, smoothing); + camera.lookAt(target); +}; + +/* ── Lock brackets (wireframe targeting indicator) ── */ +export const createLockBrackets = function (size = 3, color = 0xf0a030) { + const group = new THREE.Group(); + const mat = new THREE.LineBasicMaterial({ color }); + const bracketLen = size * 0.3; + + const corners = [ + { x: -size, y: size, z: size, dx: 1, dy: 0, dz: 0 }, // top-left-front + { x: -size, y: size, z: size, dx: 0, dy: 0, dz: -1 }, + { x: size, y: size, z: size, dx: -1, dy: 0, dz: 0 }, + { x: size, y: size, z: size, dx: 0, dy: 0, dz: -1 }, + { x: -size, y: -size, z: size, dx: 1, dy: 0, dz: 0 }, + { x: -size, y: -size, z: size, dx: 0, dy: 1, dz: 0 }, + { x: size, y: -size, z: size, dx: -1, dy: 0, dz: 0 }, + { x: size, y: -size, z: size, dx: 0, dy: 1, dz: 0 }, + { x: -size, y: size, z: -size, dx: 1, dy: 0, dz: 0 }, + { x: -size, y: size, z: -size, dx: 0, dy: -1, dz: 0 }, + { x: size, y: size, z: -size, dx: -1, dy: 0, dz: 0 }, + { x: size, y: size, z: -size, dx: 0, dy: -1, dz: 0 }, + { x: -size, y: -size, z: -size, dx: 1, dy: 0, dz: 0 }, + { x: -size, y: -size, z: -size, dx: 0, dy: 1, dz: 0 }, + { x: size, y: -size, z: -size, dx: -1, dy: 0, dz: 0 }, + { x: size, y: -size, z: -size, dx: 0, dy: 1, dz: 0 }, + ]; + + corners.forEach(c => { + const points = [ + new THREE.Vector3(c.x, c.y, c.z), + new THREE.Vector3(c.x + c.dx * bracketLen, c.y + c.dy * bracketLen, c.z + c.dz * bracketLen), + ]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + const line = new THREE.Line(geo, mat); + group.add(line); + }); + + return group; +}; + +/* ═══════════════════════════════════════════ + Orbital Physics System + ═══════════════════════════════════════════ */ + +/* ── Orbital body — Keplerian motion ── */ +export const OrbitalBody = function (opts) { + this.mesh = opts.mesh || null; + this.parentPos = opts.parentPos ? opts.parentPos.clone() : new THREE.Vector3(0, 0, 0); + this.orbitRadius = opts.orbitRadius || 10; + this.eccentricity = Math.min(opts.eccentricity || 0, 0.95); + this.inclination = opts.inclination || 0; + this.phase = opts.phase != null ? opts.phase : Math.random() * Math.PI * 2; + this.period = Math.max(opts.period || 10, 0.1); + this.angularVelocity = (2 * Math.PI) / this.period; + this.children = []; + this._worldPos = new THREE.Vector3(); +}; + +OrbitalBody.prototype.update = function (dt) { + this.phase += this.angularVelocity * dt; + var theta = this.phase; + var e = this.eccentricity; + var a = this.orbitRadius; + var denom = 1 + e * Math.cos(theta); + if (Math.abs(denom) < 0.001) denom = 0.001; + var r = a * (1 - e * e) / denom; + var x = r * Math.cos(theta); + var z = r * Math.sin(theta); + var y = z * Math.sin(this.inclination); + var zf = z * Math.cos(this.inclination); + + this._worldPos.set( + this.parentPos.x + x, + this.parentPos.y + y, + this.parentPos.z + zf + ); + + if (this.mesh) { + this.mesh.position.copy(this._worldPos); + if (this.mesh.rotation) this.mesh.rotation.y += dt * 0.5; + } + + for (var i = 0; i < this.children.length; i++) { + this.children[i].parentPos.copy(this._worldPos); + this.children[i].update(dt); + } +}; + +OrbitalBody.prototype.getWorldPosition = function () { + return this._worldPos.clone(); +}; + +/* ── Planet mesh with optional atmosphere ── */ +export const createPlanetMesh = function (size, color, atmosphere) { + var group = new THREE.Group(); + var geo = new THREE.SphereGeometry(size, 24, 24); + var mat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(color), roughness: 0.7, metalness: 0.15, + }); + group.add(new THREE.Mesh(geo, mat)); + if (atmosphere) { + var aGeo = new THREE.SphereGeometry(size * 1.18, 24, 24); + var aMat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(atmosphere), transparent: true, opacity: 0.12, side: THREE.BackSide, + }); + group.add(new THREE.Mesh(aGeo, aMat)); + } + return group; +}; + +/* ── Ring system (Saturn-like) ── */ +export const createRingSystem = function (innerR, outerR, color, opacity) { + var geo = new THREE.RingGeometry(innerR, outerR, 64); + geo.rotateX(-Math.PI / 2); + var mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(color || '#d4a560'), transparent: true, opacity: opacity || 0.25, side: THREE.DoubleSide, + }); + return new THREE.Mesh(geo, mat); +}; + +/* ── Orbit trail (elliptical ring) ── */ +export const createOrbitTrail = function (radius, eccentricity, inclination, color, opacity) { + var segments = 96; + var points = []; + var e = eccentricity || 0; + var inc = inclination || 0; + for (var i = 0; i <= segments; i++) { + var theta = (i / segments) * Math.PI * 2; + var d = 1 + e * Math.cos(theta); + if (Math.abs(d) < 0.001) d = 0.001; + var r = radius * (1 - e * e) / d; + var x = r * Math.cos(theta); + var z = r * Math.sin(theta); + points.push(new THREE.Vector3(x, z * Math.sin(inc), z * Math.cos(inc))); + } + var geo = new THREE.BufferGeometry().setFromPoints(points); + var mat = new THREE.LineBasicMaterial({ + color: color || 0x1c3a5f, transparent: true, opacity: opacity || 0.2, + }); + return new THREE.Line(geo, mat); +}; + +/* ── Asteroid belt (orbital particle ring) ── */ +export const createAsteroidBelt = function (innerRadius, outerRadius, count, opts) { + opts = opts || {}; + var positions = new Float32Array(count * 3); + var colors = new Float32Array(count * 3); + var orbits = []; + for (var i = 0; i < count; i++) { + var radius = innerRadius + Math.random() * (outerRadius - innerRadius); + var angle = Math.random() * Math.PI * 2; + var inc = (Math.random() - 0.5) * (opts.maxInclination || 0.15); + var ecc = Math.random() * (opts.maxEccentricity || 0.08); + var baseSpeed = (2 * Math.PI) / (opts.basePeriod || 20); + var speed = baseSpeed * Math.pow(innerRadius / Math.max(radius, 0.1), 1.5); + orbits.push({ radius: radius, angle: angle, inc: inc, ecc: ecc, speed: speed }); + positions[i * 3] = radius * Math.cos(angle); + positions[i * 3 + 1] = 0; + positions[i * 3 + 2] = radius * Math.sin(angle); + var b = 0.3 + Math.random() * 0.4; + colors[i * 3] = b + Math.random() * 0.1; + colors[i * 3 + 1] = b * 0.85; + colors[i * 3 + 2] = b * 0.7; + } + var geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + var mat = new THREE.PointsMaterial({ + size: opts.size || 0.35, vertexColors: true, transparent: true, opacity: 0.6, + sizeAttenuation: true, blending: THREE.AdditiveBlending, depthWrite: false, + }); + var pts = new THREE.Points(geo, mat); + pts.userData.orbits = orbits; + return pts; +}; + +/* ── Update asteroid belt positions ── */ +export const updateAsteroidBelt = function (belt: any, dt: number, parentPos?: any) { + var orbits = belt.userData.orbits; + if (!orbits) return; + var pos = belt.geometry.attributes.position.array; + var px = parentPos ? parentPos.x : 0; + var py = parentPos ? parentPos.y : 0; + var pz = parentPos ? parentPos.z : 0; + for (var i = 0; i < orbits.length; i++) { + var o = orbits[i]; + o.angle += o.speed * dt; + var d = 1 + o.ecc * Math.cos(o.angle); + if (Math.abs(d) < 0.001) d = 0.001; + var r = o.radius * (1 - o.ecc * o.ecc) / d; + var x = r * Math.cos(o.angle); + var z = r * Math.sin(o.angle); + pos[i * 3] = px + x; + pos[i * 3 + 1] = py + z * Math.sin(o.inc); + pos[i * 3 + 2] = pz + z * Math.cos(o.inc); + } + belt.geometry.attributes.position.needsUpdate = true; +}; + +/* ── Orbital station mesh ── */ +export const createOrbitalStation = function (size: number, color?: number) { + var group = new THREE.Group(); + var mat = new THREE.MeshStandardMaterial({ + color: color || 0x22d3ee, emissive: color || 0x22d3ee, + emissiveIntensity: 0.12, metalness: 0.7, roughness: 0.3, + }); + group.add(new THREE.Mesh(new THREE.BoxGeometry(size * 2, size, size), mat)); + group.add(new THREE.Mesh(new THREE.BoxGeometry(size, size * 0.3, size * 2), mat)); + var panelMat = new THREE.MeshStandardMaterial({ color: 0x1a3a5c, metalness: 0.8, roughness: 0.2 }); + group.add(new THREE.Mesh(new THREE.BoxGeometry(size * 3, size * 0.04, size * 0.7), panelMat)); + return group; +}; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..96eaa4c --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import "./styles/tailwind.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/src/pages/ApplicationPage.tsx b/src/pages/ApplicationPage.tsx new file mode 100644 index 0000000..b357ead --- /dev/null +++ b/src/pages/ApplicationPage.tsx @@ -0,0 +1,18 @@ +import { Link } from "react-router-dom"; + +export function ApplicationPage() { + return ( +
+
+
Future game shell
+

Application

+

Game implementation pending

+

+ The playable game will live here once development begins. Existing design + documentation, demos, and the HUD style reference are available under the documentation area. +

+ Open documentation +
+
+ ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx new file mode 100644 index 0000000..23fda35 --- /dev/null +++ b/src/pages/LandingPage.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; + +export function LandingPage() { + return ( +
+
+
EVE-inspired multiplayer prototype
+

VOID::NAV

+

+ A browser-based space game design hub for the persistent galaxy, economy, + HUD, backend, and prototype systems that will become the playable game. +

+
+ Read Documentation + Open Application +
+
+ Demo gallery + Roadmap + Gap analysis +
+
+
+ ); +} diff --git a/src/pages/docs/AgentsPage.tsx b/src/pages/docs/AgentsPage.tsx new file mode 100644 index 0000000..c4d4b20 --- /dev/null +++ b/src/pages/docs/AgentsPage.tsx @@ -0,0 +1,670 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +/* ── Shared inline styles ── */ +const mono = { fontFamily: 'var(--font-mono)' }; +const dimText = { color: 'var(--fg-dim)', fontSize: '0.9rem' }; +const dimSmall = { color: 'var(--fg-dim)', fontSize: '0.82rem' }; + +/* ── Small sub-components ── */ +function AgentLifecycleDiagram() { + const phases = [ + { id: 'register', label: 'REGISTER', color: 'var(--purple)', desc: 'Scheduled table row inserted with initial interval, reducer target, and payload.' }, + { id: 'sleep', label: 'SLEEP', color: 'var(--muted)', desc: 'Row sits inert. No CPU, no allocation. SpacetimeDB\'s scheduler owns the timer.' }, + { id: 'wake', label: 'WAKE', color: 'var(--accent)', desc: 'Scheduler fires the bound reducer at the scheduled timestamp. Transaction begins.' }, + { id: 'execute', label: 'EXECUTE', color: 'var(--cyan)', desc: 'Reducer logic runs: read tables, mutate state, emit events, maybe re-schedule itself.' }, + { id: 'complete', label: 'COMPLETE', color: 'var(--green)', desc: 'Transaction commits or aborts. On commit, row updated (next_fire_at) or deleted. Subscribers notified.' }, + ]; + + return ( +
+ {phases.map((p, i) => ( + +
+
+ {p.label} +
+

+ {p.desc} +

+
+ {i < phases.length - 1 && ( +
+ → +
+ )} +
+ ))} +
+ ); +} + +function ScheduleDiagram() { + return ( +
+ {/* Timeline visualization */} +
+ {/* Time axis */} +
+
t=0
+
t+5min
+ + {/* Fixed-interval ticks */} + {[0.15, 0.35, 0.55, 0.75, 0.92].map((pct, i) => ( + +
+
+ + ))} + + {/* Conditional ticks */} + {[0.45, 0.78].map((pct, i) => ( + +
+
+ + ))} + + {/* One-shot */} +
+
+ + {/* Labels */} +
+ ● Fixed interval + ● Conditional + ● One-shot +
+
+
+ ); +} + +/* ── Main page component ── */ +export function AgentsPage() { + const [activeSection, setActiveSection] = useState('lifecycle'); + const [activeCatalogPkg, setActiveCatalogPkg] = useState('game'); + + const tabs = [ + { id: 'lifecycle', label: 'Lifecycle' }, + { id: 'scheduling', label: 'Scheduling' }, + { id: 'killswitch', label: 'Kill-Switch' }, + { id: 'catalog', label: 'Agent Catalog' }, + ]; + + return ( +
+

Agent Lifecycle & Scheduling

+

+ The server runs dozens of background agents that drive the living world — from enemy health regeneration + and building decay to NPC trade migrations and day/night cycles. These agents are not OS-level processes + or threads; they are SpacetimeDB scheduled tables that + invoke reducer functions at configurable intervals, turning the database itself into a reliable task scheduler. +

+ + {/* Section tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + {/* ────────────────────── LIFECYCLE ────────────────────── */} + {activeSection === 'lifecycle' && ( + <> +
+ AGENT-1 +

Uniform Lifecycle

+
+ +

+ Every agent — regardless of what it simulates — follows the same five-phase lifecycle. + This uniformity means monitoring, debugging, and load-testing share one toolchain. +

+ + + +
+ AGENT-1.1 +

Scheduled Table Schema

+
+ +

+ Each agent is a row in a scheduled_agents table. + SpacetimeDB's built-in scheduler reads next_fire_at and invokes the + bound reducer at exactly that timestamp — no external cron, no process supervisor. +

+ +
+ + + + + + {[ + { col: 'agent_id', type: 'Identity', purpose: 'Unique agent instance key. Auto-generated on insert.' }, + { col: 'agent_type', type: 'String', purpose: 'Catalog key: "enemy_regen", "decay_tick", "npc_trade", "day_cycle", etc.' }, + { col: 'target_entity_id', type: 'u64', purpose: 'Optional entity this agent operates on (e.g. enemy_id, building_id).' }, + { col: 'reducer_name', type: 'String', purpose: 'Fully-qualified reducer to invoke: "game.enemy_regen_tick".' }, + { col: 'interval_ms', type: 'u64', purpose: 'Fixed interval in milliseconds. Zero for one-shot agents.' }, + { col: 'next_fire_at', type: 'Duration', purpose: 'Timestamp of next invocation. Scheduler reads this; agent never touches it directly after registration.' }, + { col: 'payload', type: 'JSON', purpose: 'Opaque reducer arguments. Each agent type defines its own schema.' }, + { col: 'state', type: 'enum', purpose: 'active | paused | killed. Only "active" rows are scheduled.' }, + { col: 'generation', type: 'u32', purpose: 'Monotonic counter incremented on each fire. Prevents stale re-schedules.' }, + { col: 'created_at', type: 'Timestamp', purpose: 'Registration time. Used for uptime metrics and TTL enforcement.' }, + { col: 'owner_module', type: 'String', purpose: 'Package that owns this agent: "game" or "global_module".' }, + ].map((row, i) => ( + + + + + + ))} + +
ColumnTypePurpose
{row.col}{row.type}{row.purpose}
+
+ +
+ AGENT-1.2 +

Registration Reducer

+
+ +
+
+ + register_agent — Pseudocode + +
+
+ + // Called by game logic to spawn a new agent
+ reducer register_agent(ctx, agent_type, target_id, interval_ms, payload) {'{'}
+   // Validate agent_type exists in agent_catalog
+   let catalog_entry = ctx.db.agent_catalog().find(agent_type);
+   require!(catalog_entry.is_some(), "Unknown agent type");
+
+   // Enforce per-type concurrency limit
+   let active_count = ctx.db.scheduled_agents().iter()
+     .filter(|a| a.agent_type == agent_type && a.state == Active).count();
+   require!(active_count {'<'} catalog_entry.max_concurrent);
+
+   ctx.db.scheduled_agents().insert(ScheduledAgent {'{'}
+     agent_id: Identity::generate(),
+     agent_type,
+     target_entity_id: target_id,
+     reducer_name: catalog_entry.reducer_name,
+     interval_ms,
+     next_fire_at: ctx.timestamp + interval_ms,
+     payload,
+     state: AgentState::Active,
+     generation: 0,
+     created_at: ctx.timestamp,
+     owner_module: catalog_entry.module,
+   {'}'});
+ {'}'} +
+
+
+ +
+ AGENT-1.3 +

Execution Contract

+
+ +
+ Atomicity guarantee: each agent fire runs inside a single SpacetimeDB transaction. + If the reducer panics or returns an error, the entire transaction rolls back — no partial state mutation. + The agent row is not updated, so the scheduler retries at the same next_fire_at on the next tick. +
+ +
+ {[ + { title: 'Read phase', color: 'var(--cyan)', items: [ + 'Load agent row + payload from scheduled_agents', + 'Read target entity state (enemy HP, building condition, NPC inventory)', + 'Query related tables (faction relations, player density, market state)', + ]}, + { title: 'Mutate phase', color: 'var(--accent)', items: [ + 'Apply simulation logic (regen HP, decay condition, move NPC, rotate cycle)', + 'Write updated entity state back to tables', + 'Maybe emit world events, story log entries, or chat notifications', + ]}, + { title: 'Re-schedule phase', color: 'var(--green)', items: [ + 'Increment generation counter (prevents double-fire)', + 'Set next_fire_at = now + interval_ms (fixed) or compute next (conditional)', + 'Or delete the row entirely (one-shot or TTL expired)', + ]}, + ].map((phase, i) => ( +
+

{phase.title}

+
    + {phase.items.map((item, j) =>
  • {item}
  • )} +
+
+ ))} +
+ + )} + + {/* ────────────────────── SCHEDULING ────────────────────── */} + {activeSection === 'scheduling' && ( + <> +
+ AGENT-2 +

Three Scheduling Strategies

+
+ +

+ All agents share the same table schema, but when they fire follows one of three + strategies. The choice is baked into the agent's catalog entry and cannot be changed at + runtime without re-registration. +

+ + + + {/* Strategy cards */} + {[ + { + id: 'fixed', + title: 'Fixed Interval', + color: 'var(--accent)', + tag: 'Most common', + desc: 'Fire every N milliseconds, unconditionally. The workhorse strategy — used for health regen, resource ticks, decay timers, and day/night cycles.', + mechanics: [ + 'next_fire_at = last_fire_at + interval_ms — simple arithmetic, no conditional logic.', + 'If the server is overloaded and fires late, the next fire is still interval_ms from the actual fire time (not from the scheduled time). Prevents cascade acceleration.', + 'Configurable jitter: ±10% of interval_ms to spread simultaneous agent fires across a window. Prevents thundering-herd CPU spikes when 200 enemies share the same regen tick.', + ], + examples: 'enemy_regen (2s), building_decay (60s), resource_respawn (300s), day_night_cycle (600s)', + }, + { + id: 'conditional', + title: 'Conditional', + color: 'var(--cyan)', + tag: 'Event-gated', + desc: 'Fire only when a precondition table meets criteria. The scheduler still checks on a fixed cadence, but the reducer body is a no-op until the condition is true.', + mechanics: [ + 'The reducer reads a condition table first (e.g. "has at least one player in system?"). If false, it re-schedules without doing work.', + 'Avoids wasting cycles on empty systems: NPC trade migrations only run in systems with active markets and at least one player docked.', + 'Condition tables are indexed. The read cost of a no-op conditional fire is ~microseconds.', + ], + examples: 'npc_trade_route (fires only if market activity above threshold), faction_tension_eval (fires only if two factions share a border system with standing < -5)', + }, + { + id: 'oneshot', + title: 'One-Shot', + color: 'var(--purple)', + tag: 'Delayed execution', + desc: 'Fire exactly once after a delay, then self-delete. Used for delayed consequences, timed explosions, cooldown expirations, and cascading event chains.', + mechanics: [ + 'interval_ms = 0 in the row. The scheduler fires at next_fire_at and the reducer deletes the row.', + 'No re-schedule step — the agent simply ceases to exist after execution.', + 'Can be re-registered by the reducer itself or by another agent, creating event chains: "pirate_raid_warning fires → 30s later pirate_raid_arrival fires → raid logic runs".', + ], + examples: 'delayed_damage (3s after mine explosion), event_cascade (fires next event in a story chain), cooldown_expire (marks ability ready again)', + }, + ].map((strategy) => ( +
+
+

{strategy.title}

+ {strategy.tag} +
+

{strategy.desc}

+

Mechanics

+
    + {strategy.mechanics.map((m, i) =>
  • {m}
  • )} +
+
+ Examples: {strategy.examples} +
+
+ ))} + +
+ AGENT-2.1 +

Jitter & Thundering Herd Prevention

+
+ +
+ When 500 enemy entities all share a 2-second regen interval, they were likely registered at the same time — meaning + all 500 fire simultaneously. Jitter spreads each agent's next_fire_at by a random ±X% of its interval, + turning a single CPU spike into a smooth load curve. +
+ +
+
+ + Jitter Application — Pseudocode + +
+
+ + fn apply_jitter(base_interval_ms: u64, jitter_pct: f32) {"->"} Duration {'{'}
+   let jitter_range = (base_interval_ms as f64) * (jitter_pct as f64);
+   let offset = thread_rng().gen_range(-jitter_range..jitter_range);
+   Duration::from_millis((base_interval_ms as f64 + offset) as u64)
+ {'}'}
+
+ // Applied during registration and each re-schedule:
+ agent.next_fire_at = ctx.timestamp + apply_jitter(agent.interval_ms, 0.10); +
+
+
+ + )} + + {/* ────────────────────── KILL-SWITCH ────────────────────── */} + {activeSection === 'killswitch' && ( + <> +
+ AGENT-3 +

Global Kill-Switch

+
+ +

+ A single admin-level toggle can pause or terminate every agent of a given type — or all agents + across the entire server. This is the primary operational lever for hotfixes, load shedding, + and emergency maintenance. +

+ +
+
+
+

Emergency Shutdown

+

+ A single reducer kill_all_agents iterates every row + in scheduled_agents and sets state = Killed. The scheduler skips killed + rows entirely. No reducers fire. No CPU. Agents can be selectively revived by type or module. +

+
+
+ ⬛ KILL ALL +
+
+
+ +
+ AGENT-3.1 +

Kill-Switch Mechanics

+
+ +
+ + + + + + {[ + { op: 'kill_all_agents()', scope: 'Server-wide', behavior: 'Sets state=Killed on every row. Scheduler skips all. Agents retain their payload and next_fire_at for potential revival.' }, + { op: 'kill_agents_by_type(type)', scope: 'Type-wide', behavior: 'Kills only agents matching agent_type. Used when one simulation is misbehaving (e.g. enemy_regen running amok) without touching NPC trades.' }, + { op: 'kill_agents_by_module(module)', scope: 'Package-wide', behavior: 'Kills all agents owned by a module ("game" or "global_module"). Allows safe module hot-reload.' }, + { op: 'pause_agent(agent_id)', scope: 'Single agent', behavior: 'Sets state=Paused. Scheduler skips but row persists. Used to suspend a specific NPC\'s trade route.' }, + { op: 'revive_agent(agent_id)', scope: 'Single agent', behavior: 'Sets state=Active, recalculates next_fire_at from now. Agent resumes as if freshly registered.' }, + { op: 'revive_all_by_type(type)', scope: 'Type-wide', behavior: 'Revives all killed/paused agents of a type. Re-applies jitter to prevent synchronized firing.' }, + ].map((row, i) => ( + + + + + + ))} + +
OperationScopeBehavior
{row.op}{row.scope}{row.behavior}
+
+ +
+ AGENT-3.2 +

Safety Properties

+
+ +
+ {[ + { + title: 'No orphan state', + color: 'var(--green)', + desc: 'When an agent is killed mid-execution (transaction aborted), the entity it was modifying rolls back. No enemy is half-healed, no building is half-decayed. The atomicity guarantee ensures killed agents leave zero partial state.', + }, + { + title: 'Revival preserves payload', + color: 'var(--cyan)', + desc: 'Killed agents retain their payload, target_entity_id, and interval_ms. Revival simply flips state back to Active and recalculates next_fire_at. The agent continues as if it had paused — no re-registration needed.', + }, + { + title: 'Cascade protection', + color: 'var(--accent)', + desc: 'If agent A fires and registers agent B (event chain), killing agent A does NOT kill agent B. Each agent row is independent. Kill operations are explicitly scoped — cascade kills require a custom reducer.', + }, + { + title: 'Generation guard', + color: 'var(--purple)', + desc: 'Each fire increments a generation counter. If a kill arrives between the scheduler reading the row and the reducer executing, the generation mismatch aborts the transaction. Prevents zombie fires.', + }, + ].map((item, i) => ( +
+

{item.title}

+

{item.desc}

+
+ ))} +
+ + )} + + {/* ────────────────────── CATALOG ────────────────────── */} + {activeSection === 'catalog' && ( + <> +
+ AGENT-4 +

Agent Catalog

+
+ +

+ Complete inventory of every background agent in the server, organized by owning package. + Each entry defines the reducer to invoke, scheduling strategy, interval, concurrency cap, + and what it simulates. +

+ + {/* Package tabs */} +
+ {[ + { id: 'game', label: 'game', icon: '◉' }, + { id: 'global_module', label: 'global_module', icon: '◈' }, + ].map(pkg => ( + + ))} +
+ + {activeCatalogPkg === 'game' && ( + <> +

Combat & Entity Agents

+
+ + + + + + {[ + { type: 'enemy_regen', strat: 'fixed', interval: '2s', max: '∞', desc: 'Regenerates enemy shield/armor HP per tick. Payload contains regen_rate, max_hp. Skips if enemy dead.' }, + { type: 'enemy_respawn', strat: 'one-shot', interval: '30–120s', max: '∞', desc: 'Delays enemy respawn after death. Fires once, deletes self, inserts new enemy entity row.' }, + { type: 'aggro_scan', strat: 'fixed', interval: '1s', max: '∞', desc: 'Scans nearby players within aggro range. Transitions enemy from idle → combat state.' }, + { type: 'pirate_spawn', strat: 'conditional', interval: '300s', max: '∞', desc: 'Spawns NPC pirates at belts, gates, and anomalies. Condition: player proximity OR world tick in low/null-sec. Respects per-system density cap (10 max).' }, + { type: 'pirate_combat_tick', strat: 'fixed', interval: '1s', max: '1 per engaged NPC', desc: 'Executes NPC behavior template per tick: orbit/approach/kite/flee. Applies damage to target, regenerates shields/armor. Only runs for NPCs in combat state.' }, + { type: 'pirate_loot_drop', strat: 'one-shot', interval: '0s', max: '∞', desc: 'Generates loot and bounty from destroyed NPC. Rolls loot table, awards ISK bounty to top damage contributor, creates wreck with items.' }, + { type: 'loot_decay', strat: 'fixed', interval: '120s', max: '500', desc: 'Counts down loot container lifetime. On expiry, removes container row and frees the slot.' }, + { type: 'pvp_session_timer', strat: 'one-shot', interval: 'varies', max: '∞', desc: 'Tracks PvP engagement timers (combat flags, weapons timers). Fires on expiry to clear flags.' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Agent TypeStrategyIntervalMax ConcurrentDescription
{row.type}{row.strat}{row.interval}{row.max}{row.desc}
+
+ +

World & Environment Agents

+
+ + + + + + {[ + { type: 'day_night_cycle', strat: 'fixed', interval: '600s', max: '1', desc: 'Advances the global day/night phase. Updates lighting table, triggers fauna behavior shifts, affects stealth detection range.' }, + { type: 'weather_tick', strat: 'fixed', interval: '300s', max: '1 per system', desc: 'Evolves system weather state (clear → storm → nebula haze). Affects sensor range, weapon accuracy, and visual overlay.' }, + { type: 'asteroid_respawn', strat: 'conditional', interval: '300s', max: '1 per belt', desc: 'Checks if belt is below max asteroids. Condition: player count in system > 0 AND asteroid count < belt_capacity.' }, + { type: 'anomaly_lifecycle', strat: 'one-shot', interval: 'varies', max: '1 per anomaly', desc: 'Spawned by world_tick. Self-deletes on expiry, removing the anomaly row and dropping any unresolved loot.' }, + { type: 'fauna_migration', strat: 'fixed', interval: '1800s', max: '1 per species', desc: 'Advances fauna along their migration route. Updates current_system_id, writes story log if entering a populated system.' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Agent TypeStrategyIntervalMax ConcurrentDescription
{row.type}{row.strat}{row.interval}{row.max}{row.desc}
+
+ +

Structure & Decay Agents

+
+ + + + + + {[ + { type: 'building_decay', strat: 'fixed', interval: '60s', max: '∞', desc: 'Reduces building condition per tick (0.1–2% depending on material). At 0%, marks for demolition.' }, + { type: 'fuel_consumption', strat: 'fixed', interval: '300s', max: '1 per structure', desc: 'Deducts fuel from powered structures. If fuel hits 0, disables structure services.' }, + { type: 'production_cycle', strat: 'fixed', interval: 'varies', max: '1 per structure', desc: 'Advances manufacturing jobs. On completion, moves output to structure inventory.' }, + { type: 'reinforcement_timer', strat: 'one-shot', interval: '24–72h', max: '1 per structure', desc: 'Delays structure destruction in PvP. Allows owners time to defend. Fires → structure becomes vulnerable.' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Agent TypeStrategyIntervalMax ConcurrentDescription
{row.type}{row.strat}{row.interval}{row.max}{row.desc}
+
+ + )} + + {activeCatalogPkg === 'global_module' && ( + <> +

Economy & Trade Agents

+
+ + + + + + {[ + { type: 'npc_trade_route', strat: 'conditional', interval: '600s', max: '50', desc: 'Evaluates NPC trade route viability. Condition: market activity > threshold AND at least one player docked. Moves goods between stations.' }, + { type: 'market_price_adjust', strat: 'fixed', interval: '300s', max: '1 per item_type', desc: 'Ticks NPC demand pressure algorithm: updates flow_ema, recomputes demand_pressure, applies idle decay. See Economy → NPC Pricing tab for full algorithm spec.' }, + { type: 'bounty_pool_refresh', strat: 'fixed', interval: '3600s', max: '1', desc: 'Refreshes the global bounty pool based on faction military budgets. Allocates bounty value to active threats.' }, + { type: 'insurance_payout', strat: 'one-shot', interval: '30–120s', max: '∞', desc: 'Delays ship insurance payout. Fires once, credits ISK to player, deletes self. Prevents instant-PvP-profit loops.' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Agent TypeStrategyIntervalMax ConcurrentDescription
{row.type}{row.strat}{row.interval}{row.max}{row.desc}
+
+ +

NPC & Faction Agents

+
+ + + + + + {[ + { type: 'faction_tension_eval', strat: 'conditional', interval: '300s', max: '1 per pair', desc: 'Evaluates if two factions should escalate. Condition: shared border AND standing < -5. Spawns skirmish events if threshold met.' }, + { type: 'npc_patrol_route', strat: 'fixed', interval: '60s', max: '100', desc: 'Advances NPC patrol along waypoints. Updates position, checks for player encounters, may trigger aggro.' }, + { type: 'npc_mission_refresh', strat: 'fixed', interval: '1800s', max: '1 per station', desc: 'Regenerates NPC mission offerings. Removes expired missions, adds new ones from template pool weighted by local faction state.' }, + { type: 'diplomacy_shift', strat: 'fixed', interval: '7200s', max: '1 per pair', desc: 'Slowly drifts faction standing toward baseline. Prevents permanent extreme standings from transient events.' }, + { type: 'concord_response', strat: 'one-shot', interval: '3–15s', max: '∞', desc: 'Spawns CONCORD fleet at criminal location. Delay based on system security level. Applies overwhelming damage. One-shot: fires, destroys criminal ship, deletes self.' }, + { type: 'security_status_tick', strat: 'fixed', interval: '3600s', max: '1 per player', desc: 'Passive security status recovery. +0.01 per tick for players with no hostile acts in the last hour. Clean record slowly heals.' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Agent TypeStrategyIntervalMax ConcurrentDescription
{row.type}{row.strat}{row.interval}{row.max}{row.desc}
+
+ +

Galaxy Story Agents

+
+ + + + + + {[ + { type: 'world_tick', strat: 'fixed', interval: '300s', max: '1', desc: 'Master galaxy simulation tick. Evaluates faction matrix, anomaly slots, fauna routes, player density. Spawns conditional events.' }, + { type: 'story_chapter_advance', strat: 'one-shot', interval: 'varies', max: '1 per event', desc: 'Advances multi-chapter story events. Each chapter is a one-shot agent that spawns the next on completion.' }, + { type: 'galaxy_census', strat: 'fixed', interval: '3600s', max: '1', desc: 'Snapshots player counts per system, faction territory control, active event density. Writes to metrics table for admin dashboards.' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Agent TypeStrategyIntervalMax ConcurrentDescription
{row.type}{row.strat}{row.interval}{row.max}{row.desc}
+
+ + )} + + )} +
+ ); +} diff --git a/src/pages/docs/ArchitecturePage.tsx b/src/pages/docs/ArchitecturePage.tsx new file mode 100644 index 0000000..7b9ba52 --- /dev/null +++ b/src/pages/docs/ArchitecturePage.tsx @@ -0,0 +1,480 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function ArchitecturePage() { + return ( +
+

Architecture Overview

+

+ Key principle: SpacetimeDB owns + authoritative game state. Everything else derives from it. There is no localStorage — persistence is always through + SpacetimeDB, even in the single-player Era 1 where SpacetimeDB runs locally. +

+ + {/* Architecture diagram */} +
+
+ + Layer Architecture + +
+
+ {[ + { name: 'React UI', color: 'var(--cyan)', desc: 'Inventory, market, chat, station screens, ship status, debug panel. Owns UI layout and user workflow.' }, + { name: 'Local Stores', color: 'var(--green)', desc: 'Selected entity, active panels, camera preferences, filters, sorting. Zustand/React state.' }, + { name: 'Renderer Adapter', color: 'var(--purple)', desc: 'Receives view models and emits events: select, move, mine, dock. Boundary that keeps R3F replaceable.' }, + { name: 'R3F Scene', color: 'var(--accent)', desc: 'Ships, stations, asteroids, anomalies, fauna, camera, targeting lines, world event effects. Visual layer only.' }, + { name: 'Ship AI (Zora)', color: 'var(--purple)', desc: 'Companion AI system with soul state, module gates, and autonomous agent behavior. See the Ship AI page for full design.' }, + { name: 'SpacetimeDB SDK', color: 'var(--cyan)', desc: 'Reducer calls and subscriptions. Client bridge to backend.' }, + { name: 'SpacetimeDB Module', color: 'var(--red)', desc: 'Tables, reducers, validation, persistence, authoritative game state. Source of truth.' }, + ].map((layer, i) => ( +
+
+
+ + {layer.name} + + {layer.desc} +
+
+ ))} +
+
+ +
+ ARCH-1 +

Client Architecture

+
+ +

React UI Responsibilities

+
    +
  • Global shell, HUD layout, docking/station screens, and route-like page states.
  • +
  • Inventory table, market orders table, chat, selected target panel, ship status, system overview.
  • +
  • User commands that call reducers: mine, dock, sell, place order, send chat.
  • +
  • Local-only concerns: panel layout, sorting, filters, tabs, keyboard shortcuts, tooltips.
  • +
+ +

R3F Responsibilities

+
    +
  • Render star-system scene with ships, stations, asteroids, anomalies, waypoints, fauna, and event effects.
  • +
  • Camera controls, entity picking, hover states, click-to-move command creation.
  • +
  • Interpolated movement between authoritative state updates.
  • +
  • Expose renderer events upward — never push game logic downward into the renderer.
  • +
+ +
+ ARCH-2 +

State Management Model

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
State TypeLives WhereExamples
AuthoritativeSpacetimeDB tables/subscriptionsShip position, inventory, market orders, asteroid resources.
Derived viewClient game store/view modelsSelected ship details, rendered position, distance to target.
Local UIZustand/React stateOpen panels, sort order, camera zoom, modal visibility.
Transient visualRenderer internalsHover glow, particle lifetime, temporary path line.
+ +
+ ARCH-3 +

Renderer Replaceability

+
+ +
+ Key principle: Use React Three Fiber for speed now, but prevent it from becoming + the game architecture. The renderer consumes view models and emits input events. +
+ +
+ + // Renderer interface — implementation-agnostic
+ type GameRendererInput = {
+   ships: ShipViewModel[];
+   stations: StationViewModel[];
+   asteroids: AsteroidViewModel[];
+   anomalies: AnomalyViewModel[];
+   worldEvents: WorldEventViewModel[];
+   selectedEntityId: string | null;
+ };
+
+ type GameRendererEvents = {
+   onSelectEntity(entityId: string): void;
+   onMoveCommand(position: Vec3): void;
+   onMineCommand(asteroidId: string): void;
+   onDockCommand(stationId: string): void;
+ }; +
+
+ +
+
+

Keep renderer-specific

+
    +
  • Meshes, materials, lights, particles
  • +
  • Camera controls
  • +
  • Raycasting and pointer interactions
  • +
  • Visual interpolation implementation
  • +
+
+
+

Keep renderer-independent

+
    +
  • Game types, reducers, subscriptions
  • +
  • Domain rules: mining, docking, selling
  • +
  • Inventory logic and market validation
  • +
  • Shared movement math and view models
  • +
+
+
+ + {/* ═══ RECONNECTION & ERROR HANDLING ═══ */} + +
+ ARCH-4 +

Error Handling & Reconnection

+
+ +
+ Players should never lose progress due to a disconnect. + SpacetimeDB is the source of truth and persists all authoritative state server-side. + A disconnected player's ship continues to exist in the world, subject to the same rules as every other ship. + Reconnection restores the player to their last authoritative state — nothing is lost. +
+ +

Disconnection Scenarios

+
+ + + + + + {[ + { scene: 'Idle in space', server: 'Ship remains in world. Continues orbit or holds position. No auto-actions.', reconnect: 'Full state restore. Ship where it was. No losses.', impact: 'None. Seamless.' }, + { scene: 'Mid-mining cycle', server: 'Active mining action continues to completion. Ore deposited to cargo.', reconnect: 'Mining cycle may have completed. Ore in cargo as expected. Partial cycles yield partial ore.', impact: 'Minimal. At worst, lost time on partial cycle.' }, + { scene: 'Mid-combat (PvE)', server: 'Ship continues with last known power allocation. Auto-defends with passive tank. If destroyed, insurance applies.', reconnect: 'If ship survived: full combat restore with current HP. If destroyed: respawn at home station, insurance payout queued.', impact: 'Ship may be destroyed. Insurance covers hull. Standard death penalty applies — this is the risk of disconnecting in danger.' }, + { scene: 'Mid-warp', server: 'Warp completes normally. Ship arrives at destination.', reconnect: 'Ship at destination. No interruption to warp.', impact: 'None.' }, + { scene: 'Docked at station', server: 'No risk. Station is safe. Player remains docked.', reconnect: 'Restored to station. All panels and inventory intact.', impact: 'None.' }, + { scene: 'Mid-market order', server: 'If reducer was received, order is placed. If not, no order.', reconnect: 'Check market panel for order state. ISK and inventory reflect server truth.', impact: 'At worst, order was not placed. No double-spend possible (atomic reducers).' }, + { scene: 'Mid-combat (PvP)', server: 'Ship continues with last power allocation. Enemy continues attacking.', reconnect: 'Same as PvE: ship may be destroyed. No special protection for PvP disconnect.', impact: 'Ship may be destroyed. Disconnecting during PvP carries the same risk as staying.' }, + ].map((row, i) => ( + + + + + + + ))} + +
ScenarioServer BehaviorOn ReconnectPlayer Impact
{row.scene}{row.server}{row.reconnect}{row.impact}
+
+ +
+
+

Reconnection Flow

+
+ 1. Detect disconnect — WebSocket close event or heartbeat timeout (10s no response)
+ 2. Show reconnect banner — "Connection lost. Reconnecting..." with spinning indicator. UI freezes input but continues rendering last known state.
+ 3. Auto-retry — Exponential backoff: 1s, 2s, 4s, 8s, max 30s. Up to 10 attempts over ~5 minutes.
+ 4. Re-establish subscription — On reconnect, re-subscribe to all relevant SpacetimeDB tables. Server sends full state diff.
+ 5. State reconciliation — Client merges server state into local state. Visual positions snap to authoritative positions. Inventory, market, chat all refresh.
+ 6. Resume gameplay — Banner disappears. All inputs re-enabled. Player is back in the game. +
+
+
+

Failed Reconnection

+
+

If all 10 attempts fail (5+ minutes of no connection):

+

+ {'Era 1 (local SpacetimeDB):'} + {' This should never happen. If it does, it\'s a bug. Show error screen with "restart game" button. Local SpacetimeDB state is intact.'} +

+

+ {'Era 2 (remote SpacetimeDB):'} + {' Show "Connection lost" screen with two options: [Retry Now] (immediate reconnect attempt) and [Return to Login]. Ship persists on server. No data lost.'} +

+

+ {'The ship never vanishes on disconnect. It stays in the world, obeying server rules. This is intentional \u2014 disconnecting to escape PvP is not allowed.'} +

+
+
+
+ +
+ Anti-exploit: combat disconnect. Players who disconnect during PvP combat receive no special protection. + Their ship remains in the world, continuing to fight with last-known power allocation. If destroyed, normal death + penalties apply (loot drop, insurance). This prevents "combat logging" as an escape mechanism. Zora may send + a message on reconnect: "I sustained damage while you were away. Shields at 40%. The enemy disengaged." +
+ + {/* ═══ SESSION PERSISTENCE ═══ */} + +
+ ARCH-5 +

Session Persistence & Save/Load

+
+ +
+ There is no save button and no manual save/load. + SpacetimeDB persists all authoritative state continuously — every reducer call, every tick update, every position change + is written to the database as it happens. "Saving" is not a player action; it is the natural consequence of playing the game. + Closing the browser and returning tomorrow restores the player to exactly where they left off. +
+ +

What Gets Persisted

+
+ + + + + + {[ + { cat: 'Player Identity', tables: 'players, player_skills, player_standing, player_loyalty_points', guarantee: 'Permanent. Never lost. Bound to SpacetimeDB identity.' }, + { cat: 'Ship State', tables: 'ships, ship_fittings, ship_ai_soul, ship_ai_modules, ship_ai_memory, ship_ai_directives', guarantee: 'Permanent. Ship position, fitting, AI state all persist. If destroyed, destroyed state persists until replaced.' }, + { cat: 'Economy', tables: 'inventory_items, market_orders, manufacturing_jobs, insurance_policies', guarantee: 'Permanent. Items, orders, and jobs survive restart. In-progress jobs continue server-side during offline time.' }, + { cat: 'Navigation', tables: 'bookmarks, waypoints', guarantee: 'Permanent. Saved locations and routes survive indefinitely.' }, + { cat: 'Social', tables: 'chat_messages (recent), bounties, kill_feed', guarantee: 'Messages expire after 30 days. Bounties persist until collected or decayed. Kill feed is permanent (story log).' }, + { cat: 'World State', tables: 'world_events, faction_relations, galaxy_story_log, anomalies, space_fauna', guarantee: 'Server-owned. Continues evolving while player is offline. Player returns to a changed galaxy.' }, + ].map((row, i) => ( + + + + + + ))} + +
CategoryTablesPersistence Guarantee
{row.cat}{row.tables}{row.guarantee}
+
+ +
+
+

Offline Progression

+

+ The server continues simulating the galaxy while the player is offline. Faction borders shift, + world events spawn and resolve, the economy adjusts. Manufacturing jobs placed before logging off + complete on schedule. Market orders can be filled while the player is away. +

+
+ "Come back tomorrow" is always a valid answer — your manufacturing job finishes whether you watch it or not. +
+
+
+

What Does NOT Persist

+
    +
  • UI layout: Panel positions, sorting preferences, open tabs. These reset on reload. (Future: saved layout profiles.)
  • +
  • Camera state: Zoom level, orbit angle. Reset to default on reconnection.
  • +
  • In-progress inputs: Half-typed chat messages, unfinalized market orders. Lost on disconnect.
  • +
  • Transient effects: Active weapon animations, explosion particles, HUD flash effects. Visual only — not gameplay state.
  • +
+
+
+ +
+ Era 1 vs Era 2 persistence: Both eras use SpacetimeDB exclusively — there is no localStorage. + In Era 1, SpacetimeDB runs locally on the player's machine. "Persistence" means the local database file survives browser restart. + In Era 2, SpacetimeDB runs on a server. Persistence is permanent and shared. The only difference is where the database + process runs; the persistence model is identical. +
+ + {/* ═══ SOUND & AUDIO ═══ */} + +
+ ARCH-6 +

Sound & Audio Design

+
+ +
+ Audio reinforces the spreadsheet. This game is not a flight sim and audio should not pretend it is. + Sound design serves three purposes: (1) information delivery — alerts, status changes, notifications; + (2) atmosphere — ambient space sounds that make the galaxy feel alive; (3) feedback — clicks, confirmations, + and economic sounds that make spreadsheet actions feel satisfying. Audio is always optional — the game is fully + playable with sound muted, but richer with it. +
+ +

Audio Categories

+
+ + + + + + {[ + { cat: 'Alerts', purpose: 'Critical state changes that demand attention', examples: 'Red Alert klaxon (shields <25%), target lock acquired, CONCORD warning, incoming damage alarm, disconnect sound', priority: 'Critical — always audible, respects master volume only' }, + { cat: 'UI Feedback', purpose: 'Confirm player actions in panels', examples: 'Order placed (cash register ding), module fitted (mechanical click), skill leveled up (ascending tone), ISK received (soft chime), insurance purchased (stamp sound)', priority: 'High — respects UI volume slider' }, + { cat: 'Ambient', purpose: 'Atmosphere and spatial awareness', examples: 'Station hum (docked), solar wind (in space), mining laser drone, market chatter (station background), warp tunnel whoosh', priority: 'Medium — respects ambient volume slider' }, + { cat: 'Combat', purpose: 'Combat state feedback', examples: 'Weapon firing (per type: beam hum, bolt crack, missile launch), shield hit (energy crackle), armor hit (metallic impact), hull hit (structural groan), capacitor warning (low power hum), weapon offline (power-down whine)', priority: 'High — respects combat volume slider' }, + { cat: 'World Events', purpose: 'Environment storytelling', examples: 'Anomaly detection ping, faction broadcast (radio static + voice), fauna migration rumble, explosion (distant), wormhole opening', priority: 'Medium — respects world volume slider' }, + { cat: 'Zora Voice', purpose: 'Ship AI spoken responses', examples: 'Status reports, combat warnings, market tips, tutorial hints. Voice synthesis via Voice Synthesizer module. See Ship AI → Modules.', priority: 'High — respects voice volume slider. Can be disabled entirely.' }, + ].map((row, i) => ( + + + + + + + ))} + +
CategoryPurposeExamplesPriority
{row.cat}{row.purpose}{row.examples}{row.priority}
+
+ +
+
+

Volume Controls

+
    +
  • Master: 0–100%. Controls all audio. Default 80%.
  • +
  • UI: Panel sounds, market dings, fitting clicks. Default 70%.
  • +
  • Combat: Weapons, impacts, Red Alert. Default 80%.
  • +
  • Ambient: Background atmosphere. Default 50%.
  • +
  • World: Event sounds, fauna, anomalies. Default 60%.
  • +
  • Voice: Zora and NPC dialogue. Default 90%. Has separate mute toggle.
  • +
+
+
+

Spatial Audio Rules

+
    +
  • Combat sounds are directional — weapon fire comes from the direction of the source
  • +
  • Distant events are muffled (low-pass filter scales with distance)
  • +
  • Station ambient sounds only play when docked (cross-fade on dock/undock)
  • +
  • Warp tunnel audio fades in during acceleration, peaks at cruise, fades out on deceleration
  • +
  • Zora's voice always comes from "center" — she's inside your head, not in space
  • +
  • Audio never provides exclusive gameplay information — everything audible has a visual equivalent
  • +
+
+
+ +
+ Implementation note: Audio is Phase 7 scope (Single-Player Polish). Phase 0–6 can ship with placeholder + sounds or no sounds at all. The audio system should be built on the Web Audio API with a thin abstraction layer + that maps game events to sound triggers. Sound assets can be procedurally generated or placeholder bleeps until + a proper sound design pass is done. +
+ + {/* ═══ LOCALIZATION ═══ */} + +
+ ARCH-7 +

Localization & Internationalization

+
+ +
+ MVP is English-only. This section documents the decision and the architecture that makes future localization non-breaking. + Adding languages post-launch should require translators and asset work, not code changes. All user-facing strings + flow through a lookup layer from day one — even if that layer only returns English. +
+ +
+
+

What Ships in English Only (MVP)

+
    +
  • All UI labels, button text, and panel headers
  • +
  • Tutorial mission dialogue
  • +
  • Zora personality templates (Tier 0)
  • +
  • NPC agent dialogue
  • +
  • Error messages and status notifications
  • +
  • Item, module, and ship names
  • +
+
+
+

i18n Architecture (Day-One Foundation)

+
    +
  • String keys: All user-facing text uses lookup keys, not hardcoded strings. t("market.order.placed") not "Order placed"
  • +
  • Number formatting: ISK values, quantities, percentages use Intl.NumberFormat from day one
  • +
  • Date/time: Timestamps use Intl.DateTimeFormat. Relative time ("5 minutes ago") via Intl.RelativeTimeFormat
  • +
  • Pluralization: Use ICU message format for count-aware strings ("1 item" vs "5 items")
  • +
  • Layout: UI components use CSS flexbox/grid. No hardcoded pixel widths that assume English string lengths
  • +
  • RTL ready: CSS logical properties (start/end, not left/right) for future Arabic/Hebrew support
  • +
+
+
+ +
+ Not localized (by design): Player names, chat messages, corporation names, and galaxy story log entries + are user-generated content that is never translated. Zora Tier 1+ (LLM-assisted) dialogue would need per-language + prompting — that's a Tier 2 scope concern, not MVP. +
+ +
+ Post-MVP language priority: The first languages after English would be determined by player population. + The i18n architecture supports adding a new language by dropping in a translation file — no code changes. + Estimated effort per language: 1–2 weeks for translation + 2–3 days for QA with RTL layout testing if applicable. +
+ + {/* ═══ ACCESSIBILITY ═══ */} + +
+ ARCH-8 +

Accessibility

+
+ +
+ A spreadsheet game should be the most accessible genre in the world. + The core gameplay involves reading tables, managing numbers, and clicking buttons — activities that web browsers + already excel at supporting. The following accessibility targets are baseline requirements, not nice-to-haves. + Every feature listed here ships in Phase 7 (Single-Player Polish). +
+ +

Accessibility Requirements

+
+ + + + + + {[ + { area: 'Color Blindness', req: 'All color-coded information must have a secondary indicator (pattern, icon, label, or shape)', impl: 'Shield (cyan) → label "SHD". Armor (yellow) → label "ARM". Hull (red) → label "HUL". Security levels use text labels + icons, not just color. Market price changes use ▲/▼ arrows alongside green/red.', phase: '7' }, + { area: 'Keyboard Navigation', req: 'Every action reachable by keyboard. No mouse-only workflows.', impl: 'Tab order follows logical panel flow. Enter activates focused element. Arrow keys navigate table rows. Escape closes panels. Number keys for power allocation (1=weapons, 2=shields, 3=engines, 4=aux). F1-F8 for module activation.', phase: '7' }, + { area: 'Screen Reader', req: 'All panels and data tables announce state changes. Live regions for combat updates.', impl: 'ARIA labels on all interactive elements. role="grid" on data tables with aria-rowcount. aria-live="polite" on ISK balance, cargo capacity, skill XP. aria-live="assertive" on combat damage and Red Alert. Screen reader announcements for market order fills.', phase: '7' }, + { area: 'Text Scaling', req: 'UI remains usable at 200% browser zoom and with large font settings.', impl: 'All font sizes in rem. All layouts use CSS grid/flexbox with min/max sizing. Tables scroll horizontally rather than overflow. Panel widths are percentage-based, not fixed pixels.', phase: '7' }, + { area: 'Reduced Motion', req: 'Respect prefers-reduced-motion. No animations that could cause discomfort.', impl: 'CSS media query: disable particle effects, smooth scrolling, and HUD animations. Red Alert uses static red border instead of pulsing. Power allocation bars snap instead of animating. Mining cycle uses progress bar, not spinning animation.', phase: '7' }, + { area: 'Contrast', req: 'All text meets WCAG AA contrast ratio (4.5:1 for normal text, 3:1 for large text).', impl: 'Var(--fg) on var(--bg) already exceeds 7:1. Dim text (--fg-dim) checked to meet 4.5:1. Interactive elements have visible focus indicators with 3:1 contrast against adjacent colors. Red Alert border is high-contrast red (#ff0000) against dark background.', phase: '7' }, + { area: 'Cognitive Load', req: 'Information density is manageable. Players can hide complexity.', impl: 'Collapsible panels. Summary view vs. detail view toggle. Zora provides guided assistance. Red Alert collapses non-essential HUD elements. Tutorial hints can be disabled. Settings persist per player.', phase: '7' }, + { area: 'Input Timing', req: 'No time-critical inputs required for core gameplay. Combat is manageable at any APM.', impl: "Power allocation has no per-second requirements — the skill is in choosing distribution, not speed. Mining is click-and-wait. Market orders don't expire mid-interaction. Only exception: PvP combat in Era 2, which is inherently competitive.", phase: '7' }, + ].map((row, i) => ( + + + + + + + ))} + +
AreaRequirementImplementationPhase
{row.area}{row.req}{row.impl}{row.phase}
+
+ +
+ PvP accessibility note: PvP combat in Era 2 inherently involves time pressure — rerouting power, + selecting targets, and reacting to damage. These cannot be fully de-timed without removing the competitive element. + Players with motor impairments can (1) stay in high-sec where PvP is punished, (2) focus on PvE, industry, + and market gameplay which are fully accessible, or (3) use fleet roles that require less real-time input + (logistics, scouting). The game should never require PvP to progress. +
+ +
+ Testing: Accessibility validation is part of Gate 4 (Era 1 Complete). The acceptance test is: + (1) navigate all panels via keyboard only, (2) complete a mining-sell cycle with screen reader enabled, + (3) verify all color-coded info has secondary indicators in grayscale. Automated: run axe-core or Lighthouse + accessibility audit as part of CI. +
+ +
+ ); +} diff --git a/src/pages/docs/BackendPage.tsx b/src/pages/docs/BackendPage.tsx new file mode 100644 index 0000000..ce396a3 --- /dev/null +++ b/src/pages/docs/BackendPage.tsx @@ -0,0 +1,675 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function BackendPage() { + const [activeSection, setActiveSection] = React.useState('tables'); + const [galaxySubSection, setGalaxySubSection] = React.useState('galaxy-overview'); + + return ( +
+

SpacetimeDB Backend Model

+

+ The backend holds persistent, authoritative state and exposes server-side reducers for game + actions. Clients subscribe to the rows they need and update reactively. +

+ +
+ {[ + { id: 'tables', label: 'Tables' }, + { id: 'reducers', label: 'Reducers' }, + { id: 'movement', label: 'Movement Model' }, + { id: 'galaxy', label: 'Galaxy Simulation' }, + { id: 'er', label: 'ER Diagram' }, + ].map(t => ( + + ))} +
+ + {activeSection === 'tables' && ( + <> +

Data Tables

+
+ + + + + + {[ + { name: 'players', purpose: 'Player account/session profile', fields: 'player_id, identity, display_name, current_system_id, created_at' }, + { name: 'ships', purpose: 'Active ship state', fields: 'ship_id, owner_player_id, system_id, x/y/z, destination, speed, status' }, + { name: 'regions', purpose: 'Galaxy regions (core, frontier, deep null)', fields: 'region_id, name, description, faction_id, security_profile, x/y/z (galaxy coords)' }, + { name: 'constellations', purpose: 'Star clusters within regions', fields: 'constellation_id, region_id, name, gate_connections (json), x/y/z (region coords)' }, + { name: 'factions', purpose: 'NPC factions with territory, goals, and military/economic strength', fields: 'faction_id, name, ideology, territory_region_ids (json), military_strength (f64), economy_strength (f64), diplomatic_stance (enum)' }, + { name: 'systems', purpose: 'Star systems within the single galaxy', fields: 'system_id, name, region_id, constellation_id, security_level, x/y/z (galaxy coords), star_type, description' }, + { name: 'planets', purpose: 'Planets orbiting stars', fields: 'planet_id, system_id, name, planet_type, orbit_radius, orbit_period, resources' }, + { name: 'moons', purpose: 'Moons orbiting planets', fields: 'moon_id, planet_id, name, moon_type, orbit_radius, resources' }, + { name: 'orbiting_objects', purpose: 'Stations, belts, anomalies in orbital slots', fields: 'object_id, parent_body_id, object_type, orbit_radius, name, state' }, + { name: 'stations', purpose: 'Docking and trading locations', fields: 'station_id, system_id, name, x/y/z, services' }, + { name: 'asteroids', purpose: 'Mineable resource nodes', fields: 'asteroid_id, system_id, resource_type, quantity, x/y/z' }, + { name: 'inventory_items', purpose: 'Player/station item storage', fields: 'item_id, owner_player_id, location, item_type, quantity' }, + { name: 'market_orders', purpose: 'Buy/sell orders', fields: 'order_id, station_id, seller_id, item_type, price, quantity, status' }, + { name: 'chat_messages', purpose: 'Local/system chat stream', fields: 'message_id, channel_id, sender_id, body, created_at' }, + { name: 'active_actions', purpose: 'Long-running actions', fields: 'action_id, player_id, action_type, target_id, started_at, completes_at' }, + { name: 'faction_relations', purpose: 'Dynamic NPC faction relationship matrix', fields: 'faction_a_id, faction_b_id, standing (-10 to +10), trend (rising/falling/stable), last_event_id' }, + { name: 'world_events', purpose: 'Active world simulation events', fields: 'event_id, event_type, system_id, severity, started_at, expires_at, state, participants' }, + { name: 'galaxy_story_log', purpose: 'Persistent server story timeline', fields: 'log_id, event_id, chapter, headline, body, timestamp' }, + { name: 'bookmarks', purpose: 'Player-saved locations', fields: 'bookmark_id, player_id, system_id, x/y/z, name, created_at' }, + { name: 'waypoints', purpose: 'Multi-stop navigation routes', fields: 'route_id, player_id, stops (ordered system list), name, shared' }, + { name: 'bounties', purpose: 'Bounty pool per target', fields: 'target_player_id, total_pool, tier, last_hostile_act' }, + { name: 'bounty_contributions', purpose: 'Individual bounty payments', fields: 'contribution_id, target_id, contributor_id, amount, timestamp' }, + { name: 'kill_feed', purpose: 'Ship destruction events', fields: 'kill_id, victim_id, killer_id, ship_type, system_id, bounty_collected, timestamp' }, + { name: 'player_skills', purpose: 'XP and levels per skill', fields: 'player_id, skill_name, xp, level, last_action_at' }, + { name: 'fleet_beacons', purpose: 'Temporary fleet rally points (post-MVP)', fields: 'beacon_id, fleet_id, creator_id, system_id, x/y/z, expires_at' }, + { name: 'ship_ai_soul', purpose: 'Soul document and personality state per ship', fields: 'ship_id, soul_md (text), growth_vectors (json), personality_state (json), soul_depth (u32), created_at, last_updated_at' }, + { name: 'ship_ai_modules', purpose: 'Installed AI modules and fitting state', fields: 'module_id, ship_id, module_type (enum), tier, slot (med/low), cpu_cost, grid_cost, active, fitted_at' }, + { name: 'ship_ai_tools', purpose: 'Tool registry derived from fitted modules', fields: 'tool_id, ship_id, tool_name, source_module_id, parameters_schema (json), return_schema (json)' }, + { name: 'ship_ai_memory', purpose: 'Event log and conversation history', fields: 'memory_id, ship_id, category, content, related_event_id, timestamp, importance_score' }, + { name: 'ship_ai_directives', purpose: 'Player-set goals for autonomous mode', fields: 'directive_id, ship_id, description, priority, deadline, status, created_at' }, + { name: 'ship_ai_agent_runtime', purpose: 'Per-ship agent loop state and tick schedule', fields: 'ship_id, implementation_tier (0/1/2), tick_interval_ms, next_tick_at, token_budget, status' }, + { name: 'ship_ai_soul_events', purpose: 'Audit log of soul-shaping events', fields: 'event_id, ship_id, event_type, soul_md_delta, applied_at' }, + { name: 'ship_types', purpose: 'Ship class definitions (stats, slot layout)', fields: 'type_id, name, class, hull, armor, shield, high_slots, med_slots, low_slots, cpu, power_grid, cargo, speed, mass, base_hull_value' }, + { name: 'modules_catalog', purpose: 'Module definitions (stats, slot type, costs)', fields: 'module_id, name, slot_type (high/med/low), cpu_cost, grid_cost, category, tier, effects_json' }, + { name: 'ship_fittings', purpose: 'Which modules are fitted to which ship slots', fields: 'fitting_id, ship_id, slot_index, module_id, online (bool)' }, + { name: 'npc_entities', purpose: 'Active NPC pirates and hostiles', fields: 'npc_id, system_id, class_id, behavior_template, x/y/z, hull/armor/shield, target_id, state, spawn_location, spawn_time' }, + { name: 'npc_class_templates', purpose: 'NPC type definitions and stats', fields: 'class_id, name, tier, hull_base, armor_base, shield_base, speed, damage, behavior, loot_table_id, bounty' }, + { name: 'loot_tables', purpose: 'Drop tables for NPC kills and wrecks', fields: 'table_id, entries (item_type, min_qty, max_qty, drop_chance), security_band' }, + { name: 'blueprints', purpose: 'Manufacturing recipes', fields: 'bp_id, product_type, product_qty, materials_json, time_seconds, skill_requirements' }, + { name: 'manufacturing_jobs', purpose: 'Active manufacturing queues', fields: 'job_id, player_id, station_id, bp_id, started_at, completes_at, status, output_location' }, + { name: 'skills_catalog', purpose: 'Skill definitions and XP curves', fields: 'skill_id, name, category, xp_curve (array), unlocks_json, max_level' }, + { name: 'chat_channels', purpose: 'Channel definitions and properties', fields: 'channel_id, name, type (local/trade/private/corp), scope, owner_id, created_at' }, + { name: 'insurance_policies', purpose: 'Active ship insurance contracts', fields: 'policy_id, player_id, ship_id, tier, premium_paid, payout_value, purchased_at, expires_at, active' }, + { name: 'ship_type_base_values', purpose: 'Base hull values for insurance', fields: 'ship_type_id, base_hull_value, insurance_premium_mult' }, + { name: 'station_commodity_demand', purpose: 'Per-station per-commodity demand state for NPC pricing', fields: 'station_id, commodity_id, flow_ema (f64), demand_pressure (f64, [0.8–1.4]), volume_sold_to_npc, volume_bought_from_npc, npc_stock_remaining, last_tick' }, + { name: 'commodity_price_params', purpose: 'Base prices and adjustment parameters per commodity', fields: 'commodity_id, base_price (f64), buy_spread (f64, [0.65–0.85]), sell_spread (f64, [1.10–1.35]), ema_alpha (f64), pressure_beta (f64), decay_gamma (f64)' }, + { name: 'regional_price_seeds', purpose: 'Static regional modifiers set at galaxy generation', fields: 'region_id, commodity_id, modifier (f64, [0.6–1.5])' }, + { name: 'npc_agents', purpose: 'NPC agents at stations offering missions', fields: 'agent_id, name, faction_id, station_id, specialty (kill/courier/mining/survey/trade/escort), quality (u32), mission_levels_offered, dialogue_seed' }, + { name: 'mission_templates', purpose: 'Mission type definitions and objectives', fields: 'template_id, type (enum), level (1–4), title, description_template, objectives_json, reward_base, time_limit_seconds, security_band_min, skill_requirements_json, faction_id' }, + { name: 'active_missions', purpose: 'Currently active player missions', fields: 'mission_id, player_id, agent_id, template_id, objectives_state_json, status (active/completed/failed/expired), accepted_at, expires_at, completed_at' }, + { name: 'player_standing', purpose: 'Player standing with agents and factions', fields: 'player_id, entity_id, entity_type (agent/faction), standing (f64, −10 to +10), last_mission_at' }, + { name: 'player_loyalty_points', purpose: 'Faction loyalty point balances', fields: 'player_id, faction_id, lp_balance (u64), lifetime_earned (u64)' }, + { name: 'mission_offers', purpose: 'Current mission offerings at stations', fields: 'offer_id, agent_id, station_id, template_id, reward_modifier, expires_at, generated_at' }, + { name: 'balance_metrics', purpose: 'Balancing Agent metric tracking', fields: 'metric_name, current_value (f64), healthy_min, healthy_max, last_updated, trend (rising/falling/stable)' }, + { name: 'balance_levers', purpose: 'Balancing Agent control levers', fields: 'lever_name, current_multiplier (f64), target_multiplier (f64), clamp_min, clamp_max, last_adjusted_at' }, + { name: 'balance_audit', purpose: 'Balancing Agent intervention log', fields: 'audit_id, tick_time, metrics_snapshot_json, adjustments_json, reason' }, + ].map((row, i) => ( + + + + + + ))} + +
TablePurposeKey Fields
{row.name}{row.purpose}{row.fields}
+
+ + )} + + {activeSection === 'reducers' && ( + <> +

Reducers (Server Commands)

+
+ + + + + + {[ + { name: 'connect_player(display_name)', trigger: 'Player opens app', resp: 'Create/update player row, spawn initial ship if needed.' }, + { name: 'set_destination(ship_id, x, y, z)', trigger: 'Click in space', resp: 'Validate ownership/status, update destination/vector.' }, + { name: 'dock(station_id)', trigger: 'Click dock', resp: 'Check distance, set ship docked, update location/state.' }, + { name: 'start_mining(asteroid_id)', trigger: 'Click mine', resp: 'Check range, asteroid quantity, ship status, create active action.' }, + { name: 'complete_mining(action_id)', trigger: 'Timer/event', resp: 'Transfer ore into inventory, reduce asteroid quantity.' }, + { name: 'sell_item(item_type, qty, station)', trigger: 'Sell from UI', resp: 'Validate inventory and station, exchange ore for ISK.' }, + { name: 'place_market_order(...)', trigger: 'Market UI', resp: 'Reserve inventory, create sell order.' }, + { name: 'send_chat(channel_id, body)', trigger: 'Chat box', resp: 'Validate/rate-limit, append chat message row.' }, + { name: 'world_tick(ctx)', trigger: 'Server timer (5 min)', resp: 'Evaluate galaxy state, player density, faction matrix. Conditionally spawn PvE events (faction conflicts, anomalies, migrations, raids). Propagate active events.' }, + { name: 'spawn_world_event(event_type, system_id, params)', trigger: 'World tick evaluation', resp: 'Create world_event row, generate story log entry, notify nearby players via sensors, set expiration timer.' }, + { name: 'resolve_world_event(event_id, outcome)', trigger: 'Event timer or player action', resp: 'Update galaxy state based on outcome, write story log chapter, adjust faction relations, trigger cascading events.' }, + ].map((row, i) => ( + + + + + + ))} + +
ReducerClient TriggerServer Responsibility
{row.name}{row.trigger}{row.resp}
+
+ + )} + + {activeSection === 'movement' && ( + <> +

Movement Model

+
+ Avoid sending per-frame movement. Store destination and speed. Clients interpolate visually; + the backend periodically updates authoritative positions. +
+ +
+

Movement Flow

+
+ 1. Client calls set_destination(ship_id, x, y, z)
+ 2. Server validates ownership + ship status
+ 3. Server updates ships.destination + calculates velocity vector
+ 4. Server broadcasts updated ship state to subscribers
+ 5. Client interpolates visual position between last known + destination
+ 6. Server periodically updates authoritative x/y/z position +
+
+ +
+
+

Client-side interpolation

+

+ Smooth visual movement between authoritative position updates. Uses dead reckoning + with periodic server correction. Handles latency spikes gracefully. +

+
+
+

Server authority

+

+ Backend is the source of truth for all positions. Clients never modify their own + position directly — they submit intentions and wait for confirmation. +

+
+
+ + )} + + {activeSection === 'galaxy' && ( + <> +
+ {[ + { id: 'galaxy-overview', label: 'Overview' }, + { id: 'galaxy-gen', label: 'Galaxy Generation' }, + { id: 'galaxy-events', label: 'World Events' }, + ].map(g => ( + + ))} +
+ + {galaxySubSection === 'galaxy-overview' && (<> +

Galaxy Simulation Layer

+
+ Single galaxy, simulated world. The server maintains one persistent galaxy with a connected graph of star systems, + each containing planets, moons, asteroid belts, and stations. A world simulation layer runs on top, spawning dynamic PvE events + that create a unique story per server. This is not instanced content — every player in the galaxy shares the same world state. +
+ +
+

Galaxy Topology

+
+ Galaxy → contains Regions (4–8 regions, each with distinct character)
+ Region → contains Constellations (3–6 per region, connected clusters)
+ Constellation → contains Systems (2–8 per constellation, gate-connected)
+ System → contains Star + Planets + Moons + Belts + Stations + Anomalies
+ Planet → has orbiting_objects (stations, moon mining outposts, customs offices) +
+
+ +

World Simulation Tables

+ +
+ Overlap note: faction_relations, world_events, and galaxy_story_log also appear in the Tables tab with abbreviated field descriptions. The definitions below are the expanded versions with full field detail. Both tabs describe the same underlying tables. +
+
+ + + + + + {[ + { name: 'regions', purpose: 'Galaxy regions (core, frontier, deep null)', fields: 'region_id, name, description, faction_id, security_profile' }, + { name: 'constellations', purpose: 'Star clusters within regions', fields: 'constellation_id, region_id, name, gate_connections' }, + { name: 'factions', purpose: 'NPC factions with territory and goals', fields: 'faction_id, name, ideology, territory_region_ids, military_strength, economy_strength' }, + { name: 'faction_relations', purpose: 'Dynamic relationship matrix', fields: 'faction_a_id, faction_b_id, standing (-10 to +10), trend (rising/falling/stable), last_event_id' }, + { name: 'world_events', purpose: 'Active PvE events in the galaxy', fields: 'event_id, event_type, system_id, severity (1–5), started_at, expires_at, state, participants_json, params_json' }, + { name: 'world_event_templates', purpose: 'Event blueprints with spawn conditions', fields: 'template_id, type, name, min_severity, max_severity, spawn_weight, required_faction_state, cooldown_hours' }, + { name: 'galaxy_story_log', purpose: 'Persistent server timeline — the “history” of this galaxy', fields: 'log_id, event_id, chapter_index, headline, body, affected_systems, timestamp' }, + { name: 'space_fauna', purpose: 'Migrating space creatures', fields: 'fauna_id, species, current_system_id, migration_route (json), next_waypoint, arrival_at, cycle_phase' }, + { name: 'anomalies', purpose: 'Temporary spatial phenomena', fields: 'anomaly_id, type (wormhole/nebula/storm/void), system_id, x/y/z, severity, expires_at, loot_table' }, + ].map((row, i) => ( + + + + + + ))} + +
TablePurposeKey Fields
{row.name}{row.purpose}{row.fields}
+
+ +

Event Spawn Logic

+
+
+ + World Tick Reducer — Pseudocode + +
+
+ + // Runs every 5 minutes on the server
+ reducer world_tick(ctx) {'{'}
+   // 1. Evaluate faction tension matrix
+   for each faction_pair {'{'}
+     if standing {'<'} -5 && random {'<'} tension_weight {'{'}
+       spawn_event("faction_skirmish", contested_system);
+       log_story({'"'}Hostilities erupt between {'{'}A{'}'} and {'{'}B{'}'} in {'{'}system{'}'}{'"'});
+     {'}'}
+   {'}'}
+
+   // 2. Check anomaly spawn slots
+   if active_anomalies {'<'} max_anomalies {'{'}
+     pick random system weighted by distance_from_hub;
+     spawn_anomaly(system, random_type);
+   {'}'}
+
+   // 3. Advance fauna migration routes
+   for each fauna {'{'}
+     if now {'>='} next_waypoint.arrival_at {'{'}
+       advance fauna to next system in route;
+       log_story({'"'}{'{'}species{'}'} migration enters {'{'}system{'}'}{'"'});
+     {'}'}
+   {'}'}
+
+   // 4. Cascade: check if any active events should trigger follow-ons
+   for each active_event near expiry {'{'}
+     evaluate_outcome(participants, event_state);
+     resolve_event(event_id, outcome);
+     // outcome may shift faction relations → future events
+   {'}'}
+ {'}'} +
+
+
+ )} + + {galaxySubSection === 'galaxy-gen' && (<> +
+ GALAXY-GEN +

Galaxy Generation — Seeded Parameters & Algorithm

+
+ +
+ Every galaxy begins with a seed. The galaxy generation algorithm is deterministic: the same seed always produces + the same galaxy. This means server operators can share a seed for a known-good galaxy layout, or generate a unique one. + Generation runs once at server bootstrap and writes immutable topology tables (regions, constellations, systems, stargates, + planets, moons, stations, asteroid belts). Faction territories and NPC agent placement are also seeded at this stage. +
+ +

Galaxy Parameters

+
+ + + + + + {[ + { param: 'Regions', mvp: '4', full: '6–8', reason: 'Core, Frontier, Null, Deep Null for MVP. Add 2–4 faction-specific regions at launch.' }, + { param: 'Constellations per region', mvp: '3–4', full: '4–8', reason: 'Minimum 3 for gate connectivity. More in Core region, fewer in Deep Null.' }, + { param: 'Systems per constellation', mvp: '2–5', full: '3–8', reason: 'Density varies by region type. Core constellations are denser (trade hubs).' }, + { param: 'Total systems (MVP)', mvp: '~50', full: '~300–500', reason: '4 regions × 3.5 const × 3.5 sys ≈ 49 systems. Enough for economic loops without barren stretches.' }, + { param: 'Stargates per system', mvp: '1–4', full: '1–6', reason: 'Minimum 1 (no dead ends). Hub systems get 4+. Frontier gets 2–3.' }, + { param: 'Stations per system', mvp: '1–3', full: '1–6', reason: 'High-sec: 2–3 stations. Low-sec: 1–2. Null: 0–1 NPC stations. Stations are where the economy lives.' }, + { param: 'Planets per system', mvp: '1–5', full: '2–8', reason: 'Aesthetic + resource variety. Orbital mechanics run on a slow tick (no gameplay impact in MVP).' }, + { param: 'Asteroid belts per system', mvp: '1–3', full: '1–5', reason: 'Belts are where mining happens. More belts in lower-sec = better ore = risk/reward.' }, + { param: 'Factions', mvp: '4', full: '4–6', reason: 'One per region in MVP. Each has territory, ideology, and agent networks.' }, + { param: 'NPC agents per station', mvp: '1–2', full: '1–4', reason: 'Mission-givers. Specialty and quality randomized from seed.' }, + ].map((row, i) => ( + + + + + + + ))} + +
ParameterMVP ValueFull LaunchRationale
{row.param}{row.mvp}{row.full}{row.reason}
+
+ +

Galaxy Shape & Layout

+
+ Spiral galaxy with 4 arms. The galaxy is rendered as a top-down 2D map (with a Z-depth dimension for 3D system + coordinates within each system). Regions are assigned to spiral arm segments: + Core (center), Frontier (inner arms), Null (outer arms), Deep Null (tips and gaps between arms). + Systems within a region are placed using a Poisson disk distribution to ensure minimum spacing while maintaining natural clustering. +
+ +
+
+

Region Assignment Rules

+
    +
  • Core (sec +0.8 → +1.0): Center of galaxy map. Dense, high station count, trade hub. Starter systems live here. 1 region.
  • +
  • Frontier (sec +0.1 → +0.7): Inner spiral arms. Moderate density. Faction border zones. Mission territory. 1–2 regions.
  • +
  • Null (sec 0.0 → −0.4): Outer spiral arms. Sparse stations. Rich belts. PvP-free. 1 region.
  • +
  • Deep Null (sec −0.5 → −1.0): Tips and gaps. Very sparse. Wormhole connections only. Elite content. 1 region.
  • +
+
+
+

System Placement Algorithm

+
+ 1. Place constellation centroids via Poisson disk (min 40px apart on map)
+ 2. For each centroid, place 2–5 systems in a cluster (Gaussian offset from centroid, σ = 15px)
+ 3. Assign security level band based on region assignment
+ 4. Add jitter to security within band (±0.1 random) for natural variation
+ 5. Place star type (O/B/A/F/G/K/M) weighted by frequency — G/K most common
+ 6. System name generated from faction language + sequential number +
+
+
+ +

Stargate Topology

+
+ No disconnected components. Every system must be reachable from every other system. + The stargate graph is the transportation backbone. If a system has only one gate, it\'s a dead-end — + risky because you can\'t flee without going back through the same gate. The algorithm ensures minimum 2 gates + per system (MVP) and creates "choke point" systems with 4+ gates that become natural trade hubs and conflict zones. +
+ +
+

Stargate Connectivity Algorithm

+
+ Phase 1 — Minimum Spanning Tree: Compute MST over all systems using Euclidean distance. This guarantees full connectivity with minimum total gate length.
+ Phase 2 — Intra-constellation edges: For each constellation, add 1–2 extra gates between systems within the constellation. This creates local redundancy and multiple routes within a cluster.
+ Phase 3 — Inter-region choke points: Identify 2–4 systems on region boundaries. Add gates between them to create known choke points. These become strategic PvP locations.
+ Phase 4 — Shortcut edges: Add 10–15% extra gates weighted toward connecting high-sec systems to create trade route variety. Never add shortcuts into/out of Deep Null (wormhole-only access preserved).
+ Validation: After generation, verify: (a) graph is fully connected (BFS from any node reaches all), (b) no system has <2 gates, (c) Deep Null systems have no direct high-sec gates, (d) average path length <15 jumps for MVP galaxy. +
+
+ +

Starter System Template

+
+
+

Starter System Layout

+
+ Security: +1.0 (maximum safety)
+ Stations: 3 — Home Station, Trade Hub, Factory
+ Belts: 3 — Veldspar/Scordite (easy ore)
+ NPC agents: 2 — Tutorial agent + Level 1 kill agent
+ Gates: 3 — connects to 3 adjacent high-sec systems
+ NPC pirates: None (CONCORD-protected + no belt spawns in 1.0)
+ Services: Refinery, Factory, Market, Fitting, Insurance, Medical +
+
+
+

New Player Spawn Rules

+
    +
  • New players always spawn in a starter system (sec 1.0)
  • +
  • Each faction has exactly 1 starter system in their Core region
  • +
  • Player receives a Rookie Frigate (free, uninsurable, untradeable)
  • +
  • Player receives the tutorial mission sequence from the tutorial agent
  • +
  • Starter system is guaranteed to have Veldspar and Scordite at NPC buy prices that make the first-30-minute walkthrough viable
  • +
  • Multiple new players can share the same starter system (no instancing)
  • +
+
+
+ +

Station & Belt Placement Rules

+
+ + + + + + {[ + { entity: 'Station', rule: 'Orbiting a planet or moon. Placed at galaxy gen, never moved.', density: 'High-sec: 2–3/station. Low: 1–2. Null: 0–1 NPC. Player stations (post-MVP) can be anchored.', notes: 'Stations define where economy happens. Every station has a Market. Refinery and Factory depend on station size.' }, + { entity: 'Asteroid Belt', rule: 'Circular orbit around star. 3–5 asteroids per belt.', density: 'High-sec: 1–2. Low: 2–3. Null: 2–4. Deep Null: 3–5.', notes: 'Belt ore quality scales with sec level. High-sec: Veldspar/Scordite only. Null: adds Arkonor/Megacyte.' }, + { entity: 'Moon', rule: 'Orbiting planets. 0–3 moons per planet.', density: '1–3 moons per planet (uniform distribution).', notes: 'Moons have moon minerals (post-MVP). MVP: moons exist for visual flavor and as station anchors.' }, + { entity: 'Stargate', rule: 'At system edge, paired with gate in target system. Placed at fixed (x,y) = system edge toward destination.', density: 'Matches graph topology. 2–4 per system typically.', notes: 'Gates are always paired. Jumping gate A→B always works. No fuel cost for jumping (MVP).' }, + { entity: 'NPC Agent', rule: 'Located at a station. 1–2 per station in MVP.', density: 'High-sec stations: 2. Low-sec: 1. Null: 1 or 0.', notes: 'Agent specialty drawn from faction pool. Quality randomized from seed.' }, + ].map((row, i) => ( + + + + + + + ))} + +
EntityPlacement RuleDensity by SecNotes
{row.entity}{row.rule}{row.density}{row.notes}
+
+ +

Faction Territory Seeding

+
+

Faction Assignment at Galaxy Gen

+
+ 1. Assign regions: Each faction claims 1 region as home territory. The Core region is shared (contested).
+ 2. Place capitals: Each faction gets a capital station in their home region — largest station, most agents, best services.
+ 3. Seed diplomatic stance: Initial faction relations set to baseline matrix (allies: +5, neutral: 0, rivals: −3). This drifts via world simulation.
+ 4. Distribute agents: NPC agents placed at stations within faction territory. Specialty weighted by faction ideology (militarist → kill agents, trader → trade/escort agents).
+ 5. Set regional price seeds: regional_price_seeds table populated at gen time. Each region gets commodity modifiers (0.6–1.5) that create baseline price differences for traders to discover.
+ 6. Faction military/economy: Initial military_strength and economy_strength set from faction template. These are the starting values the world simulation modifies. +
+
+ +

Generation Pseudocode

+
+
+ + Galaxy Generation — Pseudocode + +
+
+ + fn generate_galaxy(seed: u64) {'{'}
+   let rng = SeededRng::new(seed);
+
+   // 1. Create regions
+   let regions = ['{'Core, Frontier_A, Null, Deep_Null'}'];
+   for region in regions {'{'}
+     insert region row (id, name, faction_id, security_profile);
+   {'}'}
+
+   // 2. Place constellation centroids (Poisson disk)
+   let centroids = poisson_disk(min_dist=40, rng);
+   for centroid in centroids {'{'}
+     let region = assign_region(centroid.position);
+     insert constellation row (region_id, centroid.x, centroid.y);
+   {'}'}
+
+   // 3. Place systems within constellations (Gaussian cluster)
+   for constellation in constellations {'{'}
+     let count = rng.range(2..5); // MVP: 2–5 systems per constellation
+     for i in 0..count {'{'}
+       let offset = gaussian(σ=15, rng);
+       let sec = assign_security(constellation.region) + rng.range(-0.1..+0.1);
+       insert system row (name, constellation_id, sec, star_type, x, y);
+       place_planets(system, rng);
+       place_stations(system, sec, rng);
+       place_belts(system, sec, rng);
+     {'}'}
+   {'}'}
+
+   // 4. Stargate topology
+   let mst = minimum_spanning_tree(all_systems, euclidean_distance);
+   for edge in mst {'{'} insert_gate(edge.a, edge.b); {'}'}
+   add_intra_constellation_edges(rng); // +1–2 per constellation
+   add_region_chokepoints(rng); // 2–4 cross-region gates
+   add_shortcut_edges(percentage=0.12, rng); // 12% extra high-sec shortcuts
+   validate_connectivity(); // BFS from node 0 reaches all?
+
+   // 5. Faction seeding
+   seed_faction_territories(factions, regions);
+   seed_capital_stations(factions);
+   seed_diplomatic_matrix(factions);
+   seed_npc_agents(stations, factions, rng);
+   seed_regional_prices(regions, commodities, rng);
+ {'}'} +
+
+
+ +
+ Determinism guarantee: Given the same seed, generate_galaxy always produces identical topology. + This enables: (1) shared "known-good" galaxy seeds for competitive servers, (2) reproducible bug reports with exact galaxy layout, + (3) automated testing against fixed galaxy configurations. The seed is stored in a single galaxy_meta table row: + {'{'} seed: u64, generated_at: timestamp, system_count: u32, total_gates: u32 {'}'}. +
+ +
+ MVP scope note: For Phase 0, the galaxy can be hand-authored (a 5–10 system "mini galaxy") as long as + it follows these rules. The procedural generator ships when the galaxy needs to scale beyond ~50 systems (Phase 7+). + Hand-authored galaxies must still pass the same connectivity validation. +
+ )} + + {galaxySubSection === 'galaxy-events' && (<> +

World Simulation Tables

+ +
+ Overlap note: faction_relations, world_events, and galaxy_story_log also appear in the Tables tab with abbreviated field descriptions. The definitions below are the expanded versions with full field detail. +
+
+ + + + + + {[ + { name: 'faction_relations', purpose: 'Dynamic relationship matrix', fields: 'faction_a_id, faction_b_id, standing (-10 to +10), trend, last_event_id' }, + { name: 'world_events', purpose: 'Active PvE events in the galaxy', fields: 'event_id, event_type, system_id, severity (1–5), started_at, expires_at, state, participants_json' }, + { name: 'world_event_templates', purpose: 'Event blueprints with spawn conditions', fields: 'template_id, type, name, spawn_weight, required_faction_state, cooldown_hours' }, + { name: 'galaxy_story_log', purpose: 'Persistent server timeline', fields: 'log_id, event_id, chapter_index, headline, body, affected_systems, timestamp' }, + { name: 'space_fauna', purpose: 'Migrating space creatures', fields: 'fauna_id, species, current_system_id, migration_route (json), cycle_phase' }, + { name: 'anomalies', purpose: 'Temporary spatial phenomena', fields: 'anomaly_id, type, system_id, x/y/z, severity, expires_at, loot_table' }, + ].map((row, i) => ( + + + + + + ))} + +
TablePurposeKey Fields
{row.name}{row.purpose}{row.fields}
+
+ )} + + )} + + {activeSection === 'er' && ( + <> +
+ BACKEND-ER +

Entity-Relationship Diagram

+
+ +
+ 50+ tables organized into 5 clusters. This diagram shows the core entity relationships + and how data flows between clusters. Each cluster is color-coded. Foreign key relationships shown as arrows. + Subscription patterns indicate which tables clients subscribe to for reactive updates. +
+ + {[ + { + name: 'Player & Identity', + color: 'var(--cyan)', + desc: 'Core player data, ships, inventory, skills, and session state.', + tables: [ + { name: 'players', pk: 'player_id', fk: '', note: 'Root entity. One row per identity.' }, + { name: 'ships', pk: 'ship_id', fk: '\u2192 players.player_id', note: 'Multiple ships per player. owner_player_id.' }, + { name: 'ship_fittings', pk: 'fitting_id', fk: '\u2192 ships.ship_id, \u2192 modules_catalog.module_id', note: 'Many-to-many: ships \u2194 modules.' }, + { name: 'inventory_items', pk: 'item_id', fk: '\u2192 players.player_id', note: 'Location field: ship cargo or station hangar.' }, + { name: 'player_skills', pk: 'player_id + skill_name', fk: '\u2192 players.player_id', note: 'XP and level per skill per player.' }, + { name: 'player_standing', pk: 'player_id + entity_id', fk: '\u2192 players.player_id', note: 'Standing with agents and factions.' }, + { name: 'player_loyalty_points', pk: 'player_id + faction_id', fk: '\u2192 players.player_id', note: 'LP balance per faction.' }, + ], + }, + { + name: 'Economy & Industry', + color: 'var(--green)', + desc: 'Market, manufacturing, blueprints, and NPC pricing.', + tables: [ + { name: 'market_orders', pk: 'order_id', fk: '\u2192 stations.station_id, \u2192 players.player_id', note: 'Buy/sell orders. Core market table.' }, + { name: 'blueprints', pk: 'bp_id', fk: '', note: 'Manufacturing recipes. Materials JSON.' }, + { name: 'manufacturing_jobs', pk: 'job_id', fk: '\u2192 players.player_id, \u2192 stations.station_id, \u2192 blueprints.bp_id', note: 'Active production queues.' }, + { name: 'station_commodity_demand', pk: 'station_id + commodity_id', fk: '\u2192 stations.station_id', note: 'Per-station demand state for NPC pricing.' }, + { name: 'commodity_price_params', pk: 'commodity_id', fk: '', note: 'Base prices and EMA parameters.' }, + { name: 'regional_price_seeds', pk: 'region_id + commodity_id', fk: '', note: 'Static modifiers from galaxy gen.' }, + { name: 'insurance_policies', pk: 'policy_id', fk: '\u2192 players.player_id, \u2192 ships.ship_id', note: 'Active insurance contracts.' }, + ], + }, + { + name: 'World & Galaxy', + color: 'var(--accent)', + desc: 'Galaxy topology, world events, factions, anomalies, and fauna.', + tables: [ + { name: 'systems', pk: 'system_id', fk: '\u2192 regions.region_id', note: 'Star systems. Security level immutable.' }, + { name: 'stations', pk: 'station_id', fk: '\u2192 systems.system_id', note: 'Docking locations with services.' }, + { name: 'asteroids', pk: 'asteroid_id', fk: '\u2192 systems.system_id', note: 'Mineable resource nodes.' }, + { name: 'factions', pk: 'faction_id', fk: '', note: 'NPC factions with territory.' }, + { name: 'faction_relations', pk: 'faction_a_id + faction_b_id', fk: '\u2192 factions.faction_id (\u00d72)', note: 'Dynamic relationship matrix.' }, + { name: 'world_events', pk: 'event_id', fk: '\u2192 systems.system_id', note: 'Active PvE events. Severity, state, params.' }, + { name: 'galaxy_story_log', pk: 'log_id', fk: '\u2192 world_events.event_id', note: 'Persistent server timeline.' }, + { name: 'anomalies', pk: 'anomaly_id', fk: '\u2192 systems.system_id', note: 'Temporary spatial phenomena.' }, + { name: 'space_fauna', pk: 'fauna_id', fk: '\u2192 systems.system_id', note: 'Migrating creatures. Route JSON.' }, + ], + }, + { + name: 'Social & PvP', + color: 'var(--red)', + desc: 'Chat, bounty, kill feed, waypoints, and missions.', + tables: [ + { name: 'chat_channels', pk: 'channel_id', fk: '', note: 'Channel definitions: local, trade, private.' }, + { name: 'chat_messages', pk: 'message_id', fk: '\u2192 chat_channels.channel_id, \u2192 players.player_id', note: 'Message stream with delay.' }, + { name: 'bounties', pk: 'target_player_id', fk: '\u2192 players.player_id', note: 'Bounty pool per target.' }, + { name: 'bounty_contributions', pk: 'contribution_id', fk: '\u2192 bounties.target_player_id, \u2192 players.player_id', note: 'Individual payments into pools.' }, + { name: 'kill_feed', pk: 'kill_id', fk: '\u2192 players.player_id (victim + killer)', note: 'Ship destruction events.' }, + { name: 'npc_agents', pk: 'agent_id', fk: '\u2192 factions.faction_id, \u2192 stations.station_id', note: 'NPC agents at stations.' }, + { name: 'active_missions', pk: 'mission_id', fk: '\u2192 players.player_id, \u2192 npc_agents.agent_id', note: 'Player mission state.' }, + { name: 'waypoints', pk: 'route_id', fk: '\u2192 players.player_id', note: 'Multi-stop routes.' }, + ], + }, + { + name: 'Ship AI (Zora)', + color: 'var(--purple)', + desc: 'Soul state, modules, tools, memory, directives, and runtime.', + tables: [ + { name: 'ship_ai_soul', pk: 'ship_id', fk: '\u2192 ships.ship_id', note: 'One-to-one with ships. Soul document + personality.' }, + { name: 'ship_ai_modules', pk: 'module_id', fk: '\u2192 ships.ship_id', note: 'Installed AI modules. Medium/low slots.' }, + { name: 'ship_ai_tools', pk: 'tool_id', fk: '\u2192 ship_ai_modules.module_id', note: 'Derived from modules. Tool registry.' }, + { name: 'ship_ai_memory', pk: 'memory_id', fk: '\u2192 ships.ship_id', note: 'Event log. Category + importance scoring.' }, + { name: 'ship_ai_directives', pk: 'directive_id', fk: '\u2192 ships.ship_id', note: 'Player-set goals for autonomous mode.' }, + { name: 'ship_ai_agent_runtime', pk: 'ship_id', fk: '\u2192 ships.ship_id', note: 'Per-ship agent loop state. One-to-one.' }, + ], + }, + ].map((cluster, ci) => ( +
+

+ {cluster.name} + {cluster.desc} +

+
+ + + + + + {cluster.tables.map((t, ti) => ( + + + + + + + ))} + +
TablePKFK RelationshipsNotes
{t.name}{t.pk}{t.fk || '\u2014'}{t.note}
+
+
+ ))} + +
+ Cross-cluster flows: The most important cross-cluster relationships are: + (1) players \u2192 market_orders \u2192 stations (the economic loop), + (2) ships \u2192 systems \u2192 chat_messages (the spatial-social loop), + (3) ship_ai_soul \u2192 market_orders (Zora reads market data for intelligence). + Every reducer call touches at least two clusters. +
+ + )} +
+ ); +} diff --git a/src/pages/docs/DemoGalleryPage.tsx b/src/pages/docs/DemoGalleryPage.tsx new file mode 100644 index 0000000..943541c --- /dev/null +++ b/src/pages/docs/DemoGalleryPage.tsx @@ -0,0 +1,200 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function DemoGalleryPage() { + const demos = [ + { + id: 'game-loop', + name: 'MVP Loop Slice', + color: 'var(--accent)', + icon: '◎', + validates: 'Connected Era 1 horizontal slice: undock, navigate to belt, mine ore, dock, refine, fit, sell on market, and optionally run NPC combat. Uses a shared localStorage slice session across integrated demos.', + limitations: 'Local browser persistence only. Deterministic fake economy and combat payouts. No backend, multiplayer authority, or SpacetimeDB integration.', + docPage: 'Gameplay → Era 1 MVP Loop', + }, + { + id: 'starmap', + name: 'Star Map (Era 2 Galaxy Map)', + color: 'var(--accent)', + icon: '🗺', + validates: 'Multi-system galaxy view with system connections, faction territories, click-to-navigate interaction, warp routes. Validates the Era 2 Galaxy Map (region/constellation/system hierarchy). Each system has celestial body rendering.', + limitations: 'This is the Era 2 Galaxy Map, NOT the Era 1 System Map. No single-system detail view showing belts, stations, and orbital bodies at navigation scale. Era 1 needs a separate System Map demo. Static galaxy topology — systems don\'t shift. No faction territory animation. World events shown as static icons, not animated effects. No waypoint creation or route planning integration.', + docPage: 'Overview → HUD & View Mode Architecture → Map Mode', + }, + { + id: 'movement', + name: 'Movement', + color: 'var(--green)', + icon: '→', + validates: 'Click-to-move autopilot, ship interpolation between waypoints, ETA calculation, warp-to for distant objects. Validates the Movement Model design.', + limitations: 'Client-side only — no server authority. Interpolation assumes constant speed; no acceleration/deceleration curves yet.', + docPage: 'Backend → Movement Model', + }, + { + id: 'market', + name: 'Market', + color: 'var(--cyan)', + icon: '📈', + validates: 'Order book depth, bid/ask spread, price history charts, buy/sell order placement, margin accounts, commodity ticker. Validates the full market surface design.', + limitations: 'Fixed seed data — prices don\'t propagate between systems (no info diffusion). NPC orders only, no player-to-player trades. ₢ symbol used for ISK. Futures contracts are simulated, not backed by SpacetimeDB.', + docPage: 'Overview → Market Panel ★', + }, + { + id: 'combat', + name: 'Combat', + color: 'var(--red)', + icon: '⚔', + validates: 'FTL-style reactor power allocation (weapons/shields/engines/aux), target selection, auto-engage, module auto-cycling, capacitor drain, shield/armor/hull layers.', + limitations: 'Solo PvE only — no PvP. Single opponent at a time. No subsystem targeting. No weapon type differentiation (all deal generic damage). Power allocation is instant, not gradual.', + docPage: 'Gameplay → Combat Model', + }, + { + id: 'fitting', + name: 'Fitting', + color: 'var(--purple)', + icon: '🔧', + validates: 'CPU/Power Grid slot constraints, High/Med/Low slot assignment, module fitting and unfiting, invalid fit rejection, fitting stat preview.', + limitations: 'Single ship only (Frigate). No AI module slot type yet. No rig slots. No ship bonuses per skill level. Fitting only changes displayed stats, doesn\'t affect combat demo.', + docPage: 'Ships & Fitting → Fitting / Slots', + }, + { + id: 'refining', + name: 'Refining', + color: 'var(--accent)', + icon: '⚗', + validates: 'Ore → mineral refining, batch sizes, efficiency curve by Industry skill level, yield calculation, station reprocessing interface.', + limitations: 'No manufacturing chain beyond refining. No blueprint research. No production queues. Efficiency slider is manual, not skill-gated.', + docPage: 'Economy → Refining', + }, + { + id: 'progression', + name: 'Progression', + color: 'var(--green)', + icon: '📊', + validates: 'XP-based skill progression across categories, level-up notifications, skill tree visualization, session XP tracking.', + limitations: 'Flat XP curve — not the exponential curve described in the Social page. No skill prerequisites. No skill-based module restrictions. No time-based skill queue.', + docPage: 'Progression & Social → XP & Skills', + }, + { + id: 'bounty', + name: 'Bounty', + color: 'var(--red)', + icon: '💰', + validates: 'Bounty placement, bounty tiers by pool size, kill feed display, bounty collection on ship destruction, anti-abuse rules (no self-claiming).', + limitations: 'Bounty pool is static — no decay over time. Kill feed is pre-seeded, not generated from real combat. No galaxy-wide visibility tiers — all bounties visible everywhere.', + docPage: 'Progression & Social → Bounty System', + }, + { + id: 'gamehud', + name: 'Game HUD (Flight Mode)', + color: 'var(--cyan)', + icon: '🖥', + validates: 'Flight Mode diegetic HUD: 3D viewport with overlay panels (shield/armor/hull arcs, module rack, overview sidebar, target lock, capacitor gauge, speed/ETA, chat stub). Validates the undocked in-space experience with diegetic overlays on the 3D scene.', + limitations: 'Only shows Flight Mode (undocked). Station Mode (panel-based) not shown here — see Market/Fitting/Refining demos for panel UI. No panel undocking or free-floating mode. No multi-monitor support. No saved layout profiles. Chat is stub-only (no real messages).', + docPage: 'Overview → HUD & View Mode Architecture → Flight Mode', + }, + { + id: 'chat', + name: 'Chat & Comms', + color: 'var(--cyan)', + icon: '💬', + validates: 'Range-based chat propagation, light-speed delay mechanics, channel switching (Local/Trade/Private), message delivery visualization, pilot proximity display. Validates the core social surface design.', + limitations: 'Simulated delays only — no real network latency. NPC responses are scripted. Fleet channel is disabled (post-MVP). No corporation or alliance channels. Delay formula is simplified.', + docPage: 'Progression & Social → Chat & Comms', + }, + { + id: 'zora', + name: 'Zora Tier 0', + color: 'var(--purple)', + icon: '🤖', + validates: 'Deterministic template selection by personality state × module availability × soul depth. Soul depth progression from blank (raw status codes) to deep (full personality). Module gating logic. Personality axes influence. Validates the Tier 0 implementation spec.', + limitations: 'Tier 0 only — no LLM generation. Template database is a sample, not production-scale. Personality axes affect template selection but not text generation. No autonomous mode or inter-agent communication.', + docPage: 'Ship AI — Zora → Implementation Tiers', + }, + { + id: 'galaxy', + name: 'Galaxy Generation', + color: 'var(--accent)', + icon: '🌌', + validates: 'Deterministic seeded galaxy generation with concrete parameters: 4 regions (Core/Frontier/Null/Deep Null), ~50 systems, Poisson disk constellation placement, MST + extra edges stargate topology, station/belt placement by security level, faction territory seeding, connectivity validation. Validates the Galaxy Generation spec in Backend → Galaxy Simulation → Galaxy Generation tab.', + limitations: '2D top-down view only — no Z-depth or 3D system rendering. Galaxy shape is simplified (diamond layout, not true spiral). No NPC agent dialogue or mission seeding details. No wormhole connections for Deep Null. No world event overlay. Connectivity validation is BFS only (no minimum-gate-per-system check).', + docPage: 'Backend → Galaxy Simulation → Galaxy Generation', + }, + ]; + + return ( +
+

Interactive Demo Gallery

+

+ Thirteen interactive demos that validate specific game systems. Each demo is a standalone prototype + focused on one aspect of the design unless opened through the MVP Loop Slice. They use fake data and simulated APIs — not connected to + SpacetimeDB or a real server. Use these to evaluate feel and UX, not performance or scale. + Note: The Star Map demo covers the Era 2 Galaxy Map. A separate Era 1 System Map demo (single-system + navigation view) is still needed before Phase 1. +

+ +
+
+
13
+
Demos
+
+
+
Fake Data
+
Data Source
+
+
+
No Server
+
Backend
+
+
+
UX Only
+
Purpose
+
+
+ +
+ How to use: Open demos from the sidebar under "Interactive Demos". Each demo has its own + fullscreen mode. The limitations listed below are known gaps — they describe what the demo does not + validate, not what the final game will lack. +
+ +
+ Connected demos: Star Map, Warp, Movement, and MVP Loop share navigation/session handoff. + Market, Fitting, Refining, Combat, Progression, Bounty, Chat, and Zora can optionally consume the slice + session. Standalone demo behavior is preserved when no sliceSession URL parameter is present. +
+ +
+ {demos.map((demo, i) => ( +
+
+ {demo.icon} +

{demo.name} Demo

+ + {demo.id} + +
+
+
+
✓ VALIDATES
+

{demo.validates}

+
+
+
✗ LIMITATIONS
+

{demo.limitations}

+
+
+
+ Doc reference: {demo.docPage} +
+
+ ))} +
+
+ ); +} diff --git a/src/pages/docs/DesignDocPage.tsx b/src/pages/docs/DesignDocPage.tsx new file mode 100644 index 0000000..c60461c --- /dev/null +++ b/src/pages/docs/DesignDocPage.tsx @@ -0,0 +1,25 @@ +// @ts-nocheck +export function DesignDocPage() { + return ( +
+

Design Doc

+

+ The original OOXML design document is preserved as a downloadable documentation asset. +

+
+

EVE-like Multiplayer Prototype Design Doc

+

+ This file remains available for archival reference while the Vite app hosts the + navigable documentation pages, interactive demos, and HUD style reference. +

+ + Download .docx + +
+
+ ); +} diff --git a/src/pages/docs/EconomyPage.tsx b/src/pages/docs/EconomyPage.tsx new file mode 100644 index 0000000..905a560 --- /dev/null +++ b/src/pages/docs/EconomyPage.tsx @@ -0,0 +1,1049 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function EconomyPage() { + const [activeSection, setActiveSection] = React.useState('overview'); + + const refiningTable = [ + { ore: 'Veldspar', mineral: 'Tritanium', yield: 415, batch: 333, time: '45s' }, + { ore: 'Scordite', mineral: 'Pyerite', yield: 171, batch: 333, time: '45s' }, + { ore: 'Pyroxeres', mineral: 'Nocxium', yield: 8, batch: 333, time: '60s' }, + { ore: 'Kernite', mineral: 'Isogen', yield: 107, batch: 200, time: '60s' }, + { ore: 'Omber', mineral: 'Isogen', yield: 86, batch: 500, time: '75s' }, + { ore: 'Jaspet', mineral: 'Zydrine', yield: 8, batch: 500, time: '75s' }, + { ore: 'Hemorphite', mineral: 'Nocxium', yield: 21, batch: 500, time: '90s' }, + { ore: 'Arkonor', mineral: 'Megacyte', yield: 18, batch: 200, time: '120s' }, + ]; + + const manufacturingRecipes = [ + { product: 'Mining Laser I', minerals: 'Tritanium ×200, Pyerite ×80', time: '5m', station: 'Any', skill: 'Industry I' }, + { product: '150mm Railgun', minerals: 'Tritanium ×400, Pyerite ×150, Nocxium ×20', time: '15m', station: 'Any', skill: 'Industry II' }, + { product: 'Shield Booster I', minerals: 'Tritanium ×300, Isogen ×50', time: '10m', station: 'Any', skill: 'Industry II' }, + { product: 'Frigate Hull', minerals: 'Tritanium ×2000, Pyerite ×800, Nocxium ×100', time: '30m', station: 'Factory', skill: 'Industry III' }, + { product: 'Cruiser Hull', minerals: 'Tritanium ×8000, Pyerite ×3000, Isogen ×500, Nocxium ×200', time: '2h', station: 'Factory', skill: 'Industry IV' }, + { product: '1MN Afterburner', minerals: 'Tritanium ×150, Pyerite ×50, Isogen ×20', time: '8m', station: 'Any', skill: 'Industry II' }, + ]; + + const faucetsAndSinks = [ + { type: 'faucet', name: 'NPC Buy Orders', description: 'Station NPCs buy basic ores at floor prices. Guarantees new players can always earn ISK.', rate: '~2,000 ISK/min for rookie miner' }, + { type: 'faucet', name: 'Insurance Payout', description: 'Ship destruction triggers insurance payout (30–120s delay). Tier-dependent: 40–95% of hull value. See Gameplay → Insurance tab.', rate: 'Varies by ship value and tier' }, + { type: 'faucet', name: 'Mission Rewards', description: 'NPC agents pay ISK for completed missions. Scaling rewards based on mission tier and standing. See Gameplay → Missions tab.', rate: '~5,000–300,000 ₢ per mission (level 1–4)' }, + { type: 'faucet', name: 'Bounty Prizes', description: 'NPC pirates drop bounty ISK when destroyed. Higher in low-sec space.', rate: '~1,000–20,000 ISK per kill' }, + { type: 'faucet', name: 'Loyalty Point Store', description: 'Mission LP can be exchanged for faction items at below-market prices, creating indirect ISK value. See Gameplay → Missions tab.', rate: '~500 LP per mission, redeemable for items worth 1–50₢/LP' }, + { type: 'sink', name: 'Market Tax', description: '2% tax on all market transactions. Higher in NPC stations, lower in player stations (post-MVP).', rate: '2% of transaction value' }, + { type: 'sink', name: 'Manufacturing Fees', description: 'Station takes a cut of mineral value as a manufacturing fee.', rate: '1–5% of output value' }, + { type: 'sink', name: 'Ship Purchase', description: 'Buying ships and modules from market or manufacturing. Largest single sink.', rate: 'Variable' }, + { type: 'sink', name: 'Insurance Premiums', description: 'Players pay 10–50% of hull value for insurance coverage. See Gameplay → Insurance tab.', rate: '10–50% of hull value per 30-day policy' }, + { type: 'sink', name: 'Blueprint Research', description: 'ME/TE research costs ISK and time. See Economy → Manufacturing tab.', rate: '50,000–5,000,000 ₢ per research level' }, + { type: 'sink', name: 'Crew Wages', description: 'AI crew members require wage payments. Higher rank = higher cost. (Post-MVP)', rate: '~500–5,000 ISK/cycle' }, + ]; + + return ( +
+

Economy & Industry

+

+ Player-led economy with NPC support as an underlying demand/supply buffer. Players mine, refine, + manufacture, and trade. NPCs provide a price floor and basic market liquidity so new players + always have a way to earn and spend. +

+ +
+
+
Player-led
+
Economy Model
+
+
+
8 → 8
+
Ore Types → Minerals
+
+
+
8+
+
Manufacturable Items
+
+
+
2%
+
Market Tax
+
+
+ + {/* Tab navigation */} +
+ {[ + { id: 'overview', label: 'Flow Overview' }, + { id: 'first30', label: '🚀 First 30 Minutes' }, + { id: 'diffusion', label: '📡 Info Diffusion' }, + { id: 'refining', label: 'Refining' }, + { id: 'manufacturing', label: 'Manufacturing' }, + { id: 'npc-pricing', label: '💹 NPC Pricing' }, + { id: 'faucets', label: 'Faucets & Sinks' }, + ].map(t => ( + + ))} +
+ + {/* FIRST 30 MINUTES */} + {activeSection === 'first30' && ( + <> +
+ ECON-30 +

First 30 Minutes of Economy

+
+ +
+ Exploration and commerce are the core pillars. This walkthrough shows what a new player sees, decides, + and experiences in their first 30 minutes — from undocking with an empty cargo hold to discovering their first price + difference and making a profitable trade. The goal: by minute 30, the player understands that information asymmetry + is the game, and they've profited from it at least once. +
+ +
+ {[ + { time: '0:00', title: 'Spawn at Home Station', desc: 'Player connects to SpacetimeDB, spawns in a rookie frigate at their home station. Station Mode UI is active: Market Panel, Inventory, Fitting Screen, and Agent Panel are visible in the sidebar. Wallet shows 0 ISK.', color: 'var(--cyan)' }, + { time: '0:30', title: 'First NPC Agent Mission', desc: 'The Agent Panel glows with an available mission. Player talks to Agent — "Welcome, pilot. I need 500 units of Veldspar delivered to this station. I\'ll pay 1,500 ISK." Accept mission. Objective marker appears on System Map.', color: 'var(--green)' }, + { time: '1:00', title: 'Undock into Flight Mode', desc: '3D viewport fades in. Ship floats near the station. Asteroid belts visible as icons in the overview panel. Target the nearest belt and click "Warp To". Ship enters warp.', color: 'var(--accent)' }, + { time: '2:00', title: 'Mining Cycle', desc: 'Arrive at belt. Three asteroids in view. Target a Veldspar asteroid, click "Mine". Ship approaches automatically. Mining laser activates — progress bar fills. Cargo counter ticks up: 0/150, 10/150, 25/150... The mining laser hums. Zora chirps: "Cargo at 33%."', color: 'var(--purple)' }, + { time: '5:00', title: 'Cargo Full — Return to Station', desc: 'Cargo hits 150/150 units. HUD flashes "CARGO FULL". Player warps back to station. Docks. Station Mode transitions in. Inventory shows 150 Veldspar.', color: 'var(--cyan)' }, + { time: '6:00', title: 'First Sale — The NPC Price', desc: 'Open Market Panel. Veldspar shows: NPC Buy Price = 2.50\u2262/unit. Sell 150 units → wallet shows 375 ISK. Not enough for a module. Zora notes: "You could earn more if you refine first — your Industry skill gives 60% yield."', color: 'var(--green)' }, + { time: '8:00', title: 'The Discovery — Second Station Prices', desc: 'Player warps to a second station in the same system (or an adjacent system). Opens Market Panel. Veldspar NPC Buy Price here = 3.10\u2262/unit. That\'s 24% more. The price is different! This is the aha moment — geography matters.', color: 'var(--red)' }, + { time: '10:00', title: 'The Decision', desc: 'Mine more Veldspar at the nearby belt. Cargo fills to 150 again. Two choices visible: (A) Sell at Station 1 for 2.50\u2262 = 375 ISK, or (B) Fly to Station 2 and sell for 3.10\u2262 = 465 ISK. Option B earns 90 ISK more — but takes 2 minutes of travel time. Is it worth it?', color: 'var(--accent)' }, + { time: '12:00', title: 'Executing the Trade Route', desc: 'Player chooses Station 2. Warps there, docks, sells. Wallet: 840 ISK. They notice the Market Panel also shows Scordite at 5.20\u2262 here vs. 4.00\u2262 at Station 1. A second price gap. The loop beckons.', color: 'var(--cyan)' }, + { time: '15:00', title: 'Agent Mission Turn-In', desc: 'Return to home station. Turn in the 500 Veldspar mission (already over-delivered). Agent pays 1,500 ISK + 50 LP. Wallet: 2,340 ISK + 150 units excess ore. Standing +0.10 with agent.', color: 'var(--green)' }, + { time: '18:00', title: 'First Refining Decision', desc: 'Refining tab shows: 200 Veldspar at 60% yield = 249 Tritanium (worth ~797\u2262 vs. raw ore at 500\u2262). Refine. Minerals appear in hangar. The production chain becomes visible.', color: 'var(--purple)' }, + { time: '22:00', title: 'Zora Flags an Opportunity', desc: 'Zora\'s market module fires: "I\'ve tracked your trades. Veldspar prices at Station 2 have been above average for 3 cycles. This looks like sustained demand — possibly a manufacturing cluster buying raw materials. I\'d recommend filling your cargo before heading there next time."', color: 'var(--accent)' }, + { time: '25:00', title: 'Manufacturing Tab Peek', desc: 'Player opens Manufacturing tab. Mining Laser I requires: 200 Tritanium + 80 Pyerite (which they partially have). The blueprint shows a 5-minute job. They\'re 40 Pyerite short. They know Scordite refines to Pyerite. A new mining target appears.', color: 'var(--cyan)' }, + { time: '30:00', title: 'The Loop Is Set', desc: 'Player has 2,340 ISK, 249 Tritanium, a standing relationship with an NPC agent, and two known price differences between stations. They understand: mine → choose where to sell → reinvest → manufacture. The economy is no longer abstract — it\'s a map of opportunities they can see and act on.', color: 'var(--green)' }, + ].map((step, i) => ( +
+
+
+ {i < 13 &&
} +
+
+
+ MIN {step.time} +

{step.title}

+
+

{step.desc}

+
+
+ ))} +
+ +
+ Design intent: By minute 8, the player has discovered that prices differ between stations. + By minute 12, they\'ve acted on that discovery and profited. By minute 30, they have a mental model of the + economy as a landscape of opportunities, not a single "sell" button. This is the hook. If the price discovery + moment doesn\'t feel exciting, the entire game falls flat. +
+ + )} + + {/* DIFFUSION */} + {activeSection === 'diffusion' && ( + <> +
+ ECON-📡 +

Information Diffusion Between Systems

+
+ +
+ This IS the game. In EVE Online, the richest players aren't the best pilots — they're the best + informed. Information about market prices, supply disruptions, and trade opportunities propagates at the speed + of player travel, not the speed of light. A player who knows that Veldspar spiked 40% in Amarr while it's still + cheap in Jita has a window of opportunity that closes as other traders make the same discovery. This is the + core loop of a spreadsheet simulator: information asymmetry → profitable action → market correction. +
+ +
+

Information Propagation Model

+
+ Event occurs (belt depleted, station sold out, pirate attack) →{' '} + Local chat sees it immediately →{' '} + Adjacent systems learn in ~2 min →{' '} + Hub markets update in ~5 min →{' '} + Full region in ~15 min →{' '} + Galaxy-wide in ~30 min (single galaxy propagation) +
+
+ +

Information Channels

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChannelPropagationLatencyReliabilityExample
Local MarketInstant, system-only0s100% — live order bookYou see every buy/sell order in your current station
Local ChatInstant, system-only0sUnverified — players lie"Scordite prices are crashing in Amarr" — could be true, could be market manipulation
Ship AI ReportsAggregated, player-specificReal-timeHigh — computed from data your ship has seenZora says: "Veldspar is 18% below its 7-day average here. I've logged 3 similar price dips that corrected within 2 hours."
Region Market DataDelayed by jump distance2–10 minStale — snapshot, not liveThe price you see for a system 5 jumps away is from 5 minutes ago. It may have moved.
News FeedGalaxy-wide events15–30 minFactual but delayed"Major fleet engagement in PF-346. Megacyte supply disruptions expected." Includes world events: faction wars, anomalies, migrations.
Scout ReportsManual, player-to-playerVariableTrust-basedA corpmate tells you they saw a Tritanium shortage in Rens. First-hand intel.
+ +

Why This Matters

+
+
+

Information Asymmetry = Gameplay

+

+ The player who knows a price discrepancy exists first can fill a hauler and profit. + The player who arrives late finds the gap already closed. This is why speed of information matters, + and why players will pay for better intel, scout networks, and fast ships for information running. +

+
+
+

Delayed Data = Speculation

+

+ Because market data from other systems is delayed, traders must predict where prices are heading. + They see a snapshot from 5 minutes ago and must decide: is the trend still moving, or has it reversed? + This creates genuine risk and reward from pure information work — no combat required. +

+
+
+

Geography = Strategy

+

+ Systems farther from trade hubs have more stale data and wider spreads. Deep null-sec markets are + almost blind — the player who establishes a reliable supply chain out there controls the local economy. + Distance is not just travel time; it's an information barrier. +

+
+
+

Zora as Information Edge

+

+ The ship AI (Zora) is a force multiplier for information. She remembers every price the player has seen, + spots patterns, flags anomalies, and can continue monitoring markets while the player is offline. + A high-phase Zora is worth more than any single module — she's a living spreadsheet that + thinks about your portfolio. +

+
+
+ +

Implementation: Market Data Pipeline

+
+
+ + SpacetimeDB — Market Diffusion Tables + +
+
+ + // Each system has its own view of market reality
+ table system_market_view {'{'}
+   system_id: u64,
+   commodity_id: u64,
+   best_bid: f64,
+   best_ask: f64,
+   last_trade_price: f64,
+   volume_24h: u64,
+   snapshot_time: timestamp,
+   // ^ How stale is this data? Key field.
+ {'}'}
+
+ // Diffusion scheduler — propagates data between connected systems
+ reducer propagate_market_data(ctx) {'{'}
+   // Each tick, for each system pair connected by a gate:
+   // 1. Compare local prices vs neighbor's snapshot
+   // 2. If snapshot_age > propagation_delay, push updated prices
+   // 3. Add random noise proportional to distance from source
+   // 4. Diffusion speed ~1 system per 2 minutes per jump
+ {'}'}
+
+
+
+ + )} + + {/* OVERVIEW */} + {activeSection === 'overview' && ( + <> +
+

Economy Pipeline

+
+ Mine Ore →{' '} + Choose:
+   ├─ Sell RawMarket (fast, lower price)
+   └─ RefineMinerals →{' '} + Choose:
+       ├─ Sell MineralsMarket (higher value)
+       └─ ManufactureShips/Modules →{' '} + Use or Sell +
+
+ +
+ ECON-PHI +

Design Philosophy

+
+ +
+
+

Player agency first

+

+ Players set prices, build supply chains, and create market dynamics. NPC orders are a + safety net, not the primary economy. The goal is emergent trade routes, regional price + differences, and player-driven scarcity. +

+
+
+

NPC as buffer

+

+ NPC buy orders create a price floor so mining is always worth something. NPC sell orders + provide basic items at a premium so new players aren't stuck. As the player economy + matures, NPCs step back. +

+
+
+

Geographic trade

+

+ Different systems produce different ores. Null-sec has rarer minerals but higher risk. + This creates natural trade corridors and hauler opportunities. Region matters. +

+
+
+

Sinks balance faucets

+

+ Every ISK entering the economy must eventually leave. Ship destruction is the primary + sink — it's fun, dramatic, and removes value. Taxes and fees are the steady drain. + Inflation is the enemy. +

+
+
+ +
+ ECON-MKT +

Market Surface — The Primary Game Interface

+
+ +
+ The Market Panel is where players spend most of their time. It is the richest, most complex UI surface + in the game. The Market demo (js/demos/market.js) validates a feature set that goes beyond the original + Economy spec. This section brings the spec up to what the demo actually implements. +
+ +
+
+

Order Book & Depth

+

+ Every commodity has a live order book with bid/ask spread visualization. + Buy orders (bids) stack on the left, sell orders (asks) on the right. Depth chart shows cumulative volume at each price level. + Players can see exactly where their order sits in the queue. +

+
+ Order types: Market (instant fill), Limit (price-specified), Stop (trigger-based) +
+
+
+

Price History & Charts

+

+ Candlestick charts with configurable timeframes (1h, 6h, 24h, 7d, 30d). + Volume bars overlaid. Moving averages (7-period, 20-period). Players can annotate charts and share screenshots. + Historical data is per-station, per-commodity — no galaxy-wide aggregation (information is local). +

+
+ Data source: SpacetimeDB market_orders table + computed aggregations +
+
+
+

Contract Specifications

+

+ Each commodity is traded as a contract with standardized lot sizes. + Contracts specify: commodity type, lot size, tick size (minimum price increment), and settlement rules. + This enables the order book to aggregate orders efficiently and provides a clean abstraction for the market engine. +

+
+ Example: Tritanium contract = 1,000 units/lot, tick = 0.01₢ +
+
+
+

Margin Accounts & Positions

+

+ Era 2 feature. Players can open long and short positions + on commodity contracts using a margin account. Margin requires collateral (ISK + assets). Maintenance margin enforced — + if position moves against the player beyond the margin, the position is force-liquidated. + This adds speculative depth for advanced traders. +

+
+ Margin ratio: 20% initial, 10% maintenance · Force liquidation at 5% +
+
+
+

Commodity Ticker

+

+ Scrolling price ticker across all active contracts. Real-time price updates filtered by player's current station + or region. Category filters (Ore, Minerals, Modules, Ships). Sparkline mini-charts next to each ticker entry. + The ticker runs in both Flight Mode (bottom bar) and Station Mode (market panel sidebar). +

+
+ Update rate: 5s in-station, 30s regional (info diffusion applies) +
+
+
+

Station-Filtered View

+

+ Market data is station-local by default. Players see the full order book + for their current station. To see prices at other stations, they must use the Region Market Data channel + (delayed, see Info Diffusion tab) or travel there physically. No global market view exists — information geography is real. +

+
+ Players at trade hubs see more orders, tighter spreads, and faster updates +
+
+
+ +
+ Priority callout: Economy feel + social/multiplayer feel are the two most + important things to get right. If the economy doesn't feel rewarding and dynamic, the game + falls flat regardless of how good combat or graphics are. +
+ +
+ Currency naming: "ISK" (symbol \u2262) is a temporary placeholder. The final in-game currency name + will be chosen during development. All references to ISK throughout the GDD should be understood as subject to renaming. +
+ + )} + + {/* REFINING */} + {activeSection === 'refining' && ( + <> +
+ ECON-REF +

Ore → Mineral Refining

+
+ +

+ Raw ore can be sold directly or refined into minerals at a station with reprocessing + facilities. Refined minerals are worth more per m³ but require batch sizes and processing + time. Refining efficiency improves with the player's Industry skill level. +

+ +
+ + + + + + + + + + + + {refiningTable.map((row, i) => ( + + + + + + + + ))} + +
OreYields MineralUnits per BatchBase YieldProcess Time
{row.ore}{row.mineral}{row.batch}{row.yield}{row.time}
+
+ +
+
+

Refining Efficiency

+
+ Base efficiency: 50%
+ Industry I: 60%
+ Industry II: 70%
+ Industry III: 80%
+ Industry IV: 87.5%
+ Industry V: 95%
+ Lost material is destroyed (not recovered). +
+
+
+

Decision: Sell Raw vs Refine?

+

+ At 50% efficiency, selling raw ore is usually better ISK/m³. At 80%+ efficiency, + refining is almost always more profitable. This creates a natural skill gate: + new players sell raw, veterans refine and manufacture. +

+
+ Strategic choice: Do you invest XP into Industry early for better margins, + or into combat skills for safer mining in low-sec? +
+
+
+ + )} + + {/* MANUFACTURING */} + {activeSection === 'manufacturing' && ( + <> +
+ ECON-MFG +

Manufacturing

+
+ +

+ Players can manufacture ships, modules, and ammunition from refined minerals. Manufacturing + requires a station with factory facilities, the correct minerals, and the appropriate Industry + skill level. Manufacturing time creates natural production queues and opportunity cost. +

+ +
+ + + + + + + + + + + + {manufacturingRecipes.map((row, i) => ( + + + + + + + + ))} + +
ProductMineral CostTimeStationSkill Req.
{row.product}{row.minerals}{row.time}{row.station}{row.skill}
+
+ +
+ ECON-MFG.1 +

Full Production Chain

+
+ +
+ Manufacturing is not one step — it's a chain. + Raw ore refines into minerals. Minerals can be directly manufactured into basic modules, but advanced items + require intermediate components first. This multi-stage chain creates specialization opportunities: + a player who focuses on component manufacturing sells to ship builders who have never mined a single asteroid. +
+ +
+

Production Chain Tiers

+
+ Tier 0 — Ore (mined from asteroids): Veldspar, Scordite, Pyroxeres, Kernite, Omber, Jaspet, Hemorphite, Arkonor
+ Tier 1 — Mineral (refined from ore): Tritanium, Pyerite, Nocxium, Isogen, Zydrine, Megacyte, Mexallon, Morphite
+ Tier 2 — Component (manufactured from minerals): Capacitor Unit, Shield Emitter, Armor Plate, Thruster Assembly, Electronics Cluster, Weapon Housing
+ Tier 3 — Module (manufactured from components + minerals): Mining Laser, Railgun, Shield Booster, Afterburner, Warp Scrambler
+ Tier 4 — Ship (manufactured from components + modules + minerals): Frigate Hull, Destroyer Hull, Cruiser Hull, Industrial Hull, Battlecruiser Hull +
+
+ +
+ + + + + + {[ + { stage: 'Refining', input: '333 Veldspar', output: '415 Tritanium', example: 'Ore → Mineral', skill: 'None (base 50%)' }, + { stage: 'Component Mfg', input: '200 Tritanium + 80 Pyerite', output: '1 Capacitor Unit', example: 'Mineral → Component', skill: 'Industry I' }, + { stage: 'Module Mfg', input: '2 Capacitor Units + 150 Tritanium', output: '1 Mining Laser I', example: 'Component → Module', skill: 'Industry II' }, + { stage: 'Ship Mfg', input: '5 Component packs + 2000 Tritanium + 800 Pyerite + 100 Nocxium', output: '1 Frigate Hull', example: 'Component+Mineral → Ship', skill: 'Industry III' }, + { stage: 'Advanced Ship', input: '15 Component packs + 8000 Tritanium + 3000 Pyerite + 500 Isogen + 200 Nocxium', output: '1 Cruiser Hull', example: 'Multi-tier chain', skill: 'Industry IV' }, + ].map((row, i) => ( + + + + + + + + ))} + +
StageInputOutputExampleIndustry Skill
{row.stage}{row.input}{row.output}{row.example}{row.skill}
+
+ +
+ ECON-MFG.2 +

Blueprint Research (ME / TE)

+
+ +

+ Every manufacturable item has a Blueprint Original (BPO). BPOs define the base recipe, but they can be + researched to improve two attributes: Material Efficiency (ME) reduces mineral waste, + and Time Efficiency (TE) reduces manufacturing time. Research costs time and ISK but permanently improves the BPO. +

+ +
+
+

Material Efficiency (ME)

+

+ Each ME level reduces material waste by 1%. At ME 0, the recipe uses the base material list (includes 10% waste). + At ME 10, waste is reduced to 0%. Research time increases exponentially. +

+
+ ME 0: 10% waste (base recipe)
+ ME 1: 9% waste — Research: 30 min + 50,000₢
+ ME 2: 8% waste — Research: 1h + 100,000₢
+ ME 5: 5% waste — Research: 8h + 500,000₢
+ ME 10: 0% waste (perfect) — Research: 48h + 5,000,000₢
+
+
+ Formula: actual_materials = base × (1.0 − ME × 0.01)
+ Skill gate: Blueprint Research I → ME 3 max · II → ME 5 · III → ME 8 · IV → ME 10 +
+
+
+

Time Efficiency (TE)

+

+ Each TE level reduces manufacturing time by 2%. At TE 0, the recipe takes the base time. + At TE 20, manufacturing time is reduced by 40%. TE research is cheaper and faster than ME research. +

+
+ TE 0: 0% time reduction (base)
+ TE 5: 10% faster — Research: 15 min + 25,000₢
+ TE 10: 20% faster — Research: 2h + 200,000₢
+ TE 15: 30% faster — Research: 12h + 1,000,000₢
+ TE 20: 40% faster (max) — Research: 24h + 2,500,000₢
+
+
+ Formula: actual_time = base_time × (1.0 − TE × 0.02)
+ Skill gate: Same skill levels as ME · TE and ME can be researched independently +
+
+
+ +
+ ECON-MFG.3 +

Production Queues & Job Management

+
+ +
+
+

Job Queue System

+

+ Each station has a limited number of manufacturing slots. + Players queue jobs and they execute in order. A player can have multiple jobs running simultaneously, + limited by their Industry skill level. +

+
+ Industry I: 1 concurrent job
+ Industry II: 2 concurrent jobs
+ Industry III: 3 concurrent jobs
+ Industry IV: 5 concurrent jobs
+ Industry V: 8 concurrent jobs (max) +
+
+
+

Station Facilities

+

+ Not all stations can manufacture everything. Station facility level determines what can be built: +

+
+ Basic Factory: Modules, ammo, components (any station)
+ Advanced Factory: Frigates, destroyers, industrials
+ Capital Yard: Cruisers, battlecruisers
+ Shipyard: All ships including capitals (null-sec outposts only, post-MVP) +
+
+
+ +
+
+

Blueprint Copies (BPC)

+

+ BPOs can be copied to create Blueprint Copies (BPCs) with a limited number of runs. + BPCs are tradable — a manufacturer can sell BPCs on the market without giving up the original BPO. + Copies inherit ME/TE levels from the original. +

+
+ Copy time: 50% of manufacturing time per run · Max runs: 10 per copy · BPCs stack in inventory +
+
+
+

Invention (Post-MVP)

+

+ Tech 2 items are created through invention — a research process that consumes a BPC, datacores, + and a decryptor to produce a T2 BPC with a probability of success. T2 items are significantly more powerful + than T1, making invention a high-value, high-risk specialization. +

+
+ Success rate: 20–60% (skill-dependent) · Requires: Science skills + datacores · T2 items: 1.5–3× T1 stats +
+
+
+ +
+ ECON-MFG.4 +

Manufacturing Economics

+
+ +
+
+

Blueprints

+

+ Each manufacturable item has a Blueprint Original (BPO) that defines the recipe. BPOs can + be researched to reduce mineral waste (Material Efficiency) and production time (Time Efficiency). +

+
+ ME levels: 0% → 10% waste reduction per level
+ TE levels: 0% → 20% time reduction per level +
+
+
+

Production Chains

+

+ Advanced items require intermediate components (not just raw minerals). This creates + multi-step production chains where different players can specialize in different stages. +

+
+ Ore → Mineral → Component → Module → Fitted Ship +
+
+
+ +
+ Strategic depth: A player with a fully researched BPO (ME 10, TE 20) manufactures at 0% waste and 40% faster + than a player using an unresearched BPO. This is a permanent competitive advantage that rewards long-term investment. + A veteran industrialist with a researched Cruiser BPO can underprice a newcomer and still profit. The mineral → component → module → ship + chain means that industrialists who control the full vertical chain capture the most margin, while those who buy + components from the market pay a premium for convenience. +
+ + )} + + {/* NPC PRICING */} + {activeSection === 'npc-pricing' && ( + <> +
+ ECON-NPC +

NPC Price Adjustment Algorithm

+
+ +
+ Why this matters: NPC buy/sell orders are the economy's floor and ceiling. If NPC prices never move, + players will find exploits (buy from NPC in system A, sell to NPC in system B for guaranteed profit). If NPC prices + swing too wildly, new players can't predict income and the game feels unfair. The algorithm must be deterministic, + transparent to the player through Zora analysis, and self-correcting without manual intervention. +
+ + {/* Price seed model */} +

Price Seed Model

+

+ Every commodity has a base price (the global reference), a regional modifier + (set at galaxy generation, reflects local abundance/scarcity), and a dynamic modifier that shifts + based on player activity. NPC prices are never random — they are a deterministic function of these three inputs. +

+ +
+

NPC Price Formula

+
+ npc_price(commodity, station) =
+   base_price(commodity)
+   × regional_modifier(commodity, region)   // fixed at galaxy gen, range [0.7 – 1.5]
+   × demand_pressure(commodity, station)   // dynamic, range [0.8 – 1.4]
+   × station_type_factor(station)   // trade hub: 0.95 (cheaper), frontier: 1.15 (premium)
+
+ // NPC BUY price (what NPCs pay you) is always below sell:
+ npc_buy_price = npc_price × buy_spread   // buy_spread ∈ [0.65, 0.85]
+ npc_sell_price = npc_price × sell_spread   // sell_spread ∈ [1.10, 1.35]
+ // Spread ensures NPCs always buy lower than they sell. +
+
+ + {/* Base price table */} +

Base Prices (Global Reference)

+
+ + + + + + + + + + + + + + + + + + +
CommodityBase Price (₢/unit)TypeNPC Buy SpreadNPC Sell Spread
Veldspar2.50Ore0.701.25
Scordite4.00Ore0.701.25
Pyroxeres12.00Ore0.681.28
Kernite25.00Ore0.651.30
Tritanium3.20Mineral0.751.20
Pyerite8.00Mineral0.751.20
Nocxium120.00Mineral0.721.22
Megacyte450.00Mineral0.681.30
Mining Laser I800Module1.30
150mm Railgun2,500Module1.30
Frigate Hull15,000Ship1.35
Cruiser Hull80,000Ship1.35
+
+ + {/* Demand pressure algorithm */} +
+ ECON-NPC.1 +

Demand Pressure — The Dynamic Modifier

+
+ +

+ The demand_pressure multiplier is the only dynamic component of NPC prices. + It tracks recent buy/sell volume at each station and adjusts prices to simulate supply and demand. + This is what prevents infinite arbitrage and makes regional trade meaningful. +

+ +
+

Demand Pressure Algorithm

+
+ // Each station tracks a rolling demand state per commodity
+ demand_pressure(commodity, station) computation:
+
+ // 1. Accumulate net flow
+ net_flow = volume_sold_to_npcvolume_bought_from_npc
+ // Positive = players dumping, NPC inventory filling → price should drop
+ // Negative = players buying out, NPC stock depleting → price should rise
+
+ // 2. Exponential moving average (EMA) — forgets old data
+ flow_ema = α × net_flow + (1 − α) × prev_flow_ema   // α = 0.3 (adjusts over ~3 ticks)
+
+ // 3. Normalize to pressure multiplier
+ demand_pressure = clamp(1.0 − β × flow_ema, 0.8, 1.4)   // β = 0.002 per unit
+
+ // 4. Decay toward 1.0 when idle (no trades)
+ demand_pressure = lerp(demand_pressure, 1.0, γ)   // γ = 0.05 per tick (5% per 5-min tick)
+
+ // Result: high player selling → NPC buy price drops toward floor.
+ // High player buying → NPC sell price rises toward ceiling.
+ // No activity → prices decay back to regional baseline. +
+
+ + {/* Worked example */} +

Worked Example: Tritanium at Jita IV

+
+
+ + Tick-by-Tick Price Trace + +
+
+
+ Inputs: base_price(Tritanium) = 3.20₢, regional_modifier(The Forge) = 1.05, station_type = Trade Hub (0.95)
+ Initial: demand_pressure = 1.0 → NPC price = 3.20 × 1.05 × 1.0 × 0.95 = 3.19₢
+ Tick 1: 5 players sell 2000 units each. net_flow = +10,000. flow_ema = 3,000. demand_pressure = 1.0 − 0.002 × 3000 = 0.80 (clamped)
+   → NPC buy price = 3.19 × 0.80 = 2.55₢ (floor hit — NPCs paying minimum)
+ Tick 2: Players stop selling. flow_ema decays: 0.3×0 + 0.7×3000 = 2,100. demand_pressure = 0.80 → 1.0−0.002×2100 = 0.86
+   → NPC buy price = 3.19 × 0.86 = 2.74₢ (recovering)
+ Tick 3: No trades. Decay: lerp(0.86, 1.0, 0.05) = 0.867. Plus EMA decay continues.
+   → demand_pressure ≈ 0.89 → NPC buy price ≈ 2.84₢
+ Tick 6: Full recovery. demand_pressure → 1.0. NPC buy price = 3.19₢ (baseline restored) +
+
+
+ + {/* Regional price seeds */} +
+ ECON-NPC.2 +

Regional Price Seeds

+
+ +

+ Regional modifiers are set at galaxy generation and never change. They reflect the natural + abundance or scarcity of resources in a region. A region rich in Veldspar belts has a low modifier for + Veldspar (cheap locally, not worth importing) but might have a high modifier for Megacyte (rare locally, + profitable to import). This creates natural trade corridors. +

+ +
+ + + + + + + + + + + + +
RegionCharacterVeldspar ModNocxium ModMegacyte ModModule Mod
The Forge (Jita)Trade Hub0.900.851.300.95
Domain (Amarr)Manufacturing0.950.900.800.90
Sinq LaisonHigh-sec Mixed1.001.001.201.05
MetropolisBorder Zone1.151.101.351.15
Pure Blind (Null)Null-sec Frontier1.400.700.601.35
Fade (Low-sec)Pirate Corridor1.300.750.751.25
+
+ +
+ Trade route implication: A player who mines Nocxium in Pure Blind (mod 0.70, cheap) and hauls + it to The Forge (mod 0.85) makes a modest profit from regional modifier alone — but the real margin comes from + demand_pressure. If Jita has been buying lots of Nocxium (manufacturing cruisers), the demand_pressure + there might be 1.15+, making the same haul much more profitable. The dynamic price is the opportunity; + the regional seed is the baseline. +
+ + {/* Anti-arbitrage safeguards */} +
+ ECON-NPC.3 +

Anti-Arbitrage Safeguards

+
+ +
+
+

Buy-Sell Spread Guarantee

+

+ NPC buy price is always ≤ NPC sell price for the same commodity at the same station. The minimum + spread (buy_spread × sell_spread⁻¹) is 0.65/1.10 = 0.59 — meaning NPCs pay at most 59% of what + they charge. You cannot buy from an NPC and sell back to the same NPC at a profit. +

+
+
+

Price Floor & Ceiling

+

+ demand_pressure clamps to [0.8, 1.4]. Even at maximum demand, NPC prices only reach 1.4× baseline. + Even at maximum oversupply, they only drop to 0.8×. This prevents price spirals in either direction + and ensures new players can always estimate their income within a known range. +

+
+
+

Station Stock Limits

+

+ NPCs have finite order depth. If players flood a station with Veldspar, the NPC buy order eventually + fills and the price drops sharply (demand_pressure floor). This limits infinite farming of any single + station and encourages players to diversify or find remote stations with unfilled orders. +

+
+
+

Cross-Station Price Independence

+

+ Each station tracks its own demand_pressure independently. Selling Tritanium at Jita doesn't affect + Tritanium prices at Amarr — until the diffusion pipeline propagates the information (see Info Diffusion tab). + Players who travel between stations can exploit this lag; players who don't, can't. +

+
+
+ + {/* Backend schema */} +
+ ECON-NPC.4 +

Backend Schema for NPC Pricing

+
+ +
+
+ + SpacetimeDB — NPC Pricing Tables + +
+
+ + // Per-station per-commodity demand state
+ table station_commodity_demand {'{'}
+   station_id: u64,
+   commodity_id: u64,
+   flow_ema: f64,                   // rolling net flow EMA
+   demand_pressure: f64,           // current multiplier, clamped [0.8, 1.4]
+   volume_sold_to_npc: u64,   // cumulative this tick
+   volume_bought_from_npc: u64, // cumulative this tick
+   npc_stock_remaining: u64,    // stock limit for buy orders
+   last_tick: timestamp,
+ {'}'}
+
+ // Per-commodity base prices and parameters
+ table commodity_price_params {'{'}
+   commodity_id: u64,
+   base_price: f64,
+   buy_spread: f64,     // [0.65, 0.85]
+   sell_spread: f64,     // [1.10, 1.35]
+   ema_alpha: f64,       // smoothing factor (default 0.3)
+   pressure_beta: f64,    // sensitivity per unit (default 0.002)
+   decay_gamma: f64,     // idle decay rate (default 0.05)
+ {'}'}
+
+ // Per-region per-commodity static modifier
+ table regional_price_seeds {'{'}
+   region_id: u64,
+   commodity_id: u64,
+   modifier: f64,             // fixed at galaxy gen, range [0.6, 1.5]
+ {'}'}
+
+ // Scheduled agent that ticks demand pressure
+ reducer tick_npc_pricing(ctx) {'{'}
+   // Every 5 minutes, for each station_commodity_demand row:
+   // 1. Compute net_flow = volume_sold_to_npc - volume_bought_from_npc
+   // 2. Update flow_ema with EMA formula
+   // 3. Recompute demand_pressure = clamp(1.0 - β × flow_ema, 0.8, 1.4)
+   // 4. Apply idle decay: lerp(demand_pressure, 1.0, γ)
+   // 5. Reset volume counters for next tick
+ {'}'}
+
+
+
+ +
+ Tuning knobs: The three parameters (α, β, γ) are per-commodity so that high-value items like Megacyte + can be less volatile (lower β) while common ores like Veldspar can react faster. The initial values listed + above are defaults — the actual values should be tuned during playtesting by observing how quickly arbitrage + opportunities close and whether new players can still estimate their mining income. +
+ + )} + + {/* FAUCETS & SINKS */} + {activeSection === 'faucets' && ( + <> +
+ ECON-FS +

Money Supply: Faucets & Sinks

+
+ +
+ Critical balance: If faucets exceed sinks, the currency inflates and prices + spiral. If sinks exceed faucets, players feel poor and stop engaging. The economy needs + continuous monitoring — no fixed formula survives contact with real players. +
+ +
+ {faucetsAndSinks.map((item, i) => ( +
+
+ + {item.type === 'faucet' ? '▲ FAUCET' : '▼ SINK'} + +

{item.name}

+
+

{item.description}

+
+ {item.rate} +
+
+ ))} +
+ + )} +
+ ); +} diff --git a/src/pages/docs/GameplayPage.tsx b/src/pages/docs/GameplayPage.tsx new file mode 100644 index 0000000..d58d8e7 --- /dev/null +++ b/src/pages/docs/GameplayPage.tsx @@ -0,0 +1,1429 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function GameplayPage() { + const [activeTab, setActiveTab] = React.useState('loop'); + + return ( +
+

MVP Gameplay Loop

+

+ The core loop: connect → spawn → navigate → mine → inventory → station → sell on the market → chat. + Steps 1–7 work in Era 1 (single-player). Step 8 (Chat) requires multiplayer and ships in Era 2 (Phase 11). + Each step is UI-driven. The player's main interface is tables, charts, and panels — not a cockpit. +

+ +
+ {[ + { id: 'loop', label: 'Core Loop' }, + { id: 'security', label: 'Security Levels' }, + { id: 'pirates', label: 'NPC Pirates' }, + { id: 'concord', label: 'CONCORD' }, + { id: 'insurance', label: 'Insurance' }, + { id: 'missions', label: 'Missions' }, + { id: 'travel', label: '🚀 Travel & Warp' }, + { id: 'events', label: 'World Events UX' }, + { id: 'balancer', label: '⚖ Balancing Agent' }, + ].map(t => ( + + ))} +
+ + {activeTab === 'loop' && (<> + {/* Loop steps */} +
+ {[ + { step: 1, title: 'Connect', desc: 'Player opens the app and connects to SpacetimeDB. Identity is established, player row loaded.', color: 'var(--cyan)' }, + { step: 2, title: 'Spawn', desc: 'A player row and ship row exist; the ship appears in the shared star system for all subscribers.', color: 'var(--cyan)' }, + { step: 3, title: 'Navigate (click & autopilot)', desc: 'Player clicks a point of interest — asteroid, station, hostile — and the ship automatically pilots there. No manual flight control at all. The player sets intent, the ship executes.', color: 'var(--green)' }, + { step: 4, title: 'Mine (activate & wait)', desc: 'Player clicks an asteroid to approach, then activates mining modules. The ship navigates on its own. Mining runs on a timer. The decision is what to mine and when, not how.', color: 'var(--accent)' }, + { step: 5, title: 'Inventory', desc: 'Ore appears in inventory panel. Quantity, type, and value visible. Player manages cargo space.', color: 'var(--purple)' }, + { step: 6, title: 'Dock at Station', desc: 'Player docks at a station. Station UI becomes available: sell, market, refit.', color: 'var(--cyan)' }, + { step: 7, title: 'Sell Ore', desc: 'Player sells ore through station UI for ISK (in-game currency). Simple fixed pricing for MVP; market orders later.', color: 'var(--accent)' }, + { step: 8, title: 'Chat', desc: 'Players send and receive messages in current system. Basic rate limiting and length validation. Requires multiplayer — ships in Phase 11 (Era 2).', color: 'var(--green)' }, + ].map((s, i) => ( +
+
+
+ {i < 7 &&
} +
+
+
+ STEP {s.step} +

{s.title}

+
+

{s.desc}

+
+
+ ))} +
+ +
+ GP-UI +

Screen Specifications

+
+ +
+ {[ + { name: 'Login / Connect', color: 'var(--cyan)', items: ['Display current SpacetimeDB identity', 'Show connection status indicator', 'Enter/choose display name'] }, + { name: '3D Star Map', color: 'var(--accent)', items: ['Render ships, asteroids, station', 'Click-to-move navigation', 'Entity selection and info panel', 'Camera orbit/zoom controls'] }, + { name: 'Ship Status', color: 'var(--green)', items: ['Ship name, class, owner', 'Current status (idle/mining/warping)', 'Cargo capacity used/total', 'Active action timer'] }, + { name: 'Inventory', color: 'var(--purple)', items: ['Item type + quantity grid', 'Sell button (when docked)', 'Cargo capacity bar', 'Item value estimation'] }, + { name: 'Station Panel', color: 'var(--cyan)', items: ['Dock/undock controls', 'Quick-sell ore for ISK', 'View station market orders', 'Fit ship (when docked)'] }, + { name: 'Market', color: 'var(--accent)', items: ['Order book (buy/sell)', 'Price per unit + quantity', 'Place sell order from inventory', 'Station-filtered view'] }, + { name: 'Chat', color: 'var(--green)', items: ['Local/system channel (instant)', 'Private messages (delayed by range)', 'Sender name + timestamp', 'Auto-scroll to latest'] }, + { name: 'Combat HUD', color: 'var(--red)', items: ['Target selection + lock timer', 'Module activation buttons', 'Capacitor / shield / armor bars', 'Combat log / damage notifications'] }, + { name: 'Bounty Board', color: 'var(--accent)', items: ['Active bounties by tier', 'Place bounty on player', 'Kill feed (galaxy-wide)', 'Your bounty status'] }, + { name: 'Debug Panel', color: 'var(--muted)', items: ['Reducer call log with timestamps', 'Error display with stack trace', 'Connection metrics (latency, subscription count)', 'Entity count / state dump', 'SpacetimeDB table row counts', 'Agent tick scheduler status', 'Force-spawn NPC/entity controls (dev mode)', 'Game time display (tick counter, sim time)'] }, + { name: 'Galaxy Map', color: 'var(--accent)', items: ['Region/constellation/system hierarchy', 'Faction territory overlay', 'Active world events (icons)', 'Fauna migration routes', 'Anomaly locations and timers', 'Story log access'] }, + { name: 'World Event Panel', color: 'var(--green)', items: ['Active events in current region', 'Event countdown timers', 'Participation status', 'Story log for this event', 'Sensor alerts for nearby events'] }, + ].map((screen, i) => ( +
+

{screen.name}

+
    + {screen.items.map((item, j) =>
  • {item}
  • )} +
+
+ ))} +
+ + {/* Combat Model */} +
+ GP-COMBAT +

Combat Model

+
+ +
+ Combat style — FTL power management, not action. The player never directly controls the ship's movement or aiming. + Instead, the player clicks a hostile target and the ship automatically navigates to engagement range and engages. + The player's job is resource allocation — inspired by FTL: distribute reactor power between weapons, shields, engines, + and auxiliary systems. Modules auto-cycle when powered. The skill is in when to reroute power (e.g. divert from engines + to shields during a spike), which subsystem to target on the enemy, and fitting choices made before the fight. + Combat exists to create economic consequences (ship loss, loot, insurance), not to be a reflex game. +
+ +
+
+

PvE Content

+
    +
  • NPC pirates spawn in belts and at gates
  • +
  • Difficulty scales with system security level
  • +
  • High-sec: weak frigates, easy kills
  • +
  • Low-sec/null: cruiser+ NPCs, dangerous but rewarding
  • +
  • Bounty payouts + module drops as loot
  • +
  • NPC ratting is a primary ISK faucet
  • +
+
+
+

Player Pirating

+
    +
  • Players can attack other players in low-sec and null-sec
  • +
  • High-sec attacks trigger CONCORD response (ship destruction)
  • +
  • Pirates can loot cargo from destroyed ships
  • +
  • Piracy lowers security status → eventually locked out of high-sec
  • +
  • Bounty system creates natural consequences
  • +
  • Creates risk/reward dynamics for haulers and miners
  • +
+
+
+ +
+

Combat Flow (FTL-style resource management)

+
+ 1. Click hostile target — ship auto-navigates to engagement range, no manual piloting
+ 2. Auto-lock & engage — targeting computer locks on, weapons begin auto-cycling
+ 3. Manage reactor power — FTL-style: drag power between Weapons / Shields / Engines / Aux
+ 4. Pick subsystem to attack — target enemy shields, weapons, engines, or hull directly
+ 5. React to damage — reroute power mid-fight: divert engines→shields when taking fire, shields→weapons to finish
+ 6. Monitor capacitor — all systems drain energy; running dry = modules go offline
+ 7. Destroy or flee — hull reaches 0 → ship explodes, loot drops, economic consequences +
+
+ FTL DNA: + The player's only input during combat is power allocation and subsystem targeting. + The ship flies itself. Weapons fire themselves. The player is the chief engineer deciding where + the reactor output goes — not the pilot or gunner. +
+ +
+ GP-FAIL +

Power Allocation Failure Modes

+
+ +
+ No free lunch. Every subsystem needs energy. When power is diverted away from a subsystem, + it doesn't merely weaken — it fails. The reactor produces a fixed total output, and every allocation decision + creates a vulnerability somewhere else. This is the core tension of the combat system. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SubsystemPowered BehaviorUnpowered Failure ModeReroute Time
WeaponsTurrets auto-cycle at full rate. Missile launchers reload. Damage output at fitted value.No firing. Turrets cease fire. Missiles cannot launch. Active weapons go offline after 3s. Cannot deal damage.2s to full power
ShieldsShield booster recharges shield HP. Passive regen active. Damage absorption at full.No recharge. Shield HP stops regenerating. Existing shield HP decays at 2%/s. After 15s, shields at 0% and all damage hits armor directly.3s to full regen
EnginesShip maintains orbit speed. Can close distance or withdraw. Evasion against tracking.Ship immobile. Speed drops to 0. No orbit, no approach, no withdrawal. Sitting duck — all hostile weapons have perfect tracking. Cannot flee.2s to full speed
AuxiliaryActive modules cycle normally: ECM jamming, sensor boosters, cap boosters, warfare links.No special abilities. All auxiliary modules go offline. ECM stops jamming. Sensors degrade. No capacitor boosting. Warfare links disconnect.1.5s to full power
+
+ +
+
+

⚠ Red Alert Mode

+

+ When the ship takes shield-piercing damage (shields below 25% and taking armor hits), the HUD + automatically enters Red Alert. Non-essential HUD elements collapse: + chat minimizes, commodity ticker hides, overview panel shrinks to hostiles-only. The combat HUD + expands to fill the viewport: shield/armor/hull bars enlarge, power allocation bars gain prominent markers, + and a pulsing red border frames the screen. Audio alarm plays once. +

+
+ Trigger: shields < 25% AND armor damage incoming · Clears: shields restored above 50% · Cannot be disabled +
+
+
+

Reroute Timing

+

+ Power rerouting is not instant — there's a 1.5–3 second spool-up depending on the subsystem. + This prevents instant reactive ping-pong between subsystems. The player must commit + to a power distribution and accept the vulnerability window. Rerouting to shields (3s) is deliberately + slower than rerouting to weapons (2s), creating a "damage race vs. survival" decision. +

+
+ During spool-up: subsystem operates at 50% capacity · Visual: power bar fills with animation · Audio: reactor hum changes pitch +
+
+
+
+ +
+ On death: Ship destroyed, cargo and modules partially lost (50% drop as loot, 50% destroyed). + Player respawns at home station in a rookie frigate. Ship insurance partially reimburses the loss. + See Ships & Fitting → DEATH (Ship Destruction) for full details. +
+ + {/* ═══ DYNAMIC WORLD ═══ */} +
+ GP-WORLD +

Dynamic Galaxy — Living World Events

+
+ +
+ The galaxy is alive. The server simulates a single persistent galaxy with hundreds of star systems, planets, + moons, asteroid belts, space stations, wormholes, and anomalies. On top of this static geography runs a world simulation layer + that spawns PvE events dynamically, creating a unique story for every server. No two servers ever tell the same tale. +
+ +

Galaxy Structure

+
+
+

Single Galaxy, Many Systems

+

+ All players share one galaxy. The galaxy contains multiple regions, each with dozens of star systems. + Systems are connected by stargates forming a navigable graph. Travel between systems takes time, + creating geographic identity — the frontier feels different from the core. +

+
+
+

Orbiting Objects & Celestials

+

+ Each system has a star, planets, moons, asteroid belts, and stations. Planets orbit their star on slow cycles; + moons orbit planets. This is not cosmetic — orbital positions affect mining yields, + travel times, and line-of-sight for sensors. The galaxy has real spatial rhythm. +

+
+
+ +

World Event Categories

+
+
+

⚔ Faction Conflicts

+

+ NPC factions have territory, resources, and grievances. The server tracks faction relations as a dynamic matrix. + When tensions cross a threshold, war erupts — faction fleets clash in contested systems, trade routes are disrupted, + and players can choose sides or profit from the chaos. +

+
+ Triggers: resource disputes, border skirmishes, diplomatic insults, player provocations +
+
+
+

🌀 Space Anomalies

+

+ The galaxy spawns temporary spatial phenomena — wormholes, nebulae, radiation storms, gravity wells, dark matter corridors. + Anomalies appear unannounced and expire on their own schedule. Some are dangerous, some are lucrative, + all create time-limited opportunities that reward the first to arrive. +

+
+ Lifespan: 15 min – 48 hrs · Rarity scales with distance from trade hubs +
+
+
+

🐋 Space Fauna Migrations

+

+ Massive space creatures migrate across the galaxy on seasonal cycles — space whales, crystal leviathans, plasma mantas. + Their migration routes cross player space, creating temporary resource hotspots + (biological materials, rare isotopes) and navigation hazards. Players can hunt, follow, or study them. +

+
+ Cycle: real-time weeks · Route shifts each migration · First contact is always a surprise +
+
+
+

🏛 NPC Faction Events

+

+ NPC factions expand, contract, build stations, abandon outposts, and respond to player activity. + A mining rush in a remote system might attract NPC traders, who attract pirates, who attract bounty hunters. + The world reacts to player density and economic activity organically. +

+
+ Includes: station construction, pirate raids, convoy escorts, refugee crises, tech discoveries +
+
+
+

🌑 Cosmic Catastrophes

+

+ Rare galaxy-shaking events — a star going supernova, a rogue planet entering a system, a dormant wormhole network + activating. These are the server-defining moments that players talk about for months. + "Were you online when the Rhea star collapsed?" becomes server legend. +

+
+ Frequency: ~1 per month · Permanently alters the galaxy map · Foreshadowed days in advance +
+
+
+

📡 Signals & Mysteries

+

+ Unknown signals appear at random — derelict stations, alien artifacts, encrypted transmissions. Players investigate, + decode, and sometimes trigger chain events. Some mysteries are one-shot puzzles; others are the opening move + of a multi-week server arc that the world simulation has been building toward. +

+
+ Discovery: player-initiated · Can trigger larger events · Some are red herrings +
+
+
+ +

How the World Simulation Works

+
+

World Simulation Pipeline

+
+ Galaxy State — persistent topology: systems, gates, planets, stations, faction territories
+ World Tick — every 5 min, server evaluates galaxy state + player density + event cooldowns + faction matrix
+ Event Spawn — world tick may spawn an event: choose type, location, duration, severity based on weighted probabilities
+ Event Propagation — nearby players get sensor readings; distant players hear through news feed with delay
+ Event Resolution — events end by timer, player action, or cascading trigger; outcomes feed back into galaxy state
+ Story Log — every event is recorded in a server-wide story timeline; players can read "the history of this galaxy" +
+
+ +
+
+

Player-Event Interaction

+
    +
  • Players can participate in events (fight invaders, escort convoys, study anomalies)
  • +
  • Player actions can escalate or defuse events (kill the pirate boss → raid ends)
  • +
  • Player density attracts certain events (trade hubs get more NPC activity)
  • +
  • Player inaction lets events escalate naturally (uncontested raids grow larger)
  • +
+
+
+

Server-Uniqueness

+
    +
  • Event seed is randomized per server — no two galaxies evolve the same way
  • +
  • Faction relationships drift based on what players actually do
  • +
  • A server where players are peaceful traders develops different world events than a server of pirates
  • +
  • The story timeline is the server's unique identity — "this is the galaxy where the Kaalani wiped out the Vren homeworld"
  • +
+
+
+ +
+ Design intent: The world simulation ensures that even when players are doing routine economic loops, + something is always happening somewhere. The galaxy never feels static. A player who takes a week off returns + to find the political map has shifted, a new anomaly appeared in their home system, or a migration route has changed. + The world remembers, the world evolves, and the world tells a story that belongs to this server's players alone. +
+ )} + + {/* ═══ SECURITY LEVELS ═══ */} + {activeTab === 'security' && (<> +
+ GP-SEC +

Security Level System

+
+ +
+ Security levels define the rules of engagement. Every star system has a security status from +1.0 (safest) to −1.0 (lawless). + Security 0.0 is explicitly null-sec — there is no border case. Systems at exactly 0.0 have the same rules as systems at −0.4: no CONCORD, no gate guns, full PvP freedom. + This single number controls CONCORD response, NPC pirate spawns, PvE difficulty, and the economic risk/reward balance. + Inspired by EVE Online's sec-space but simplified for the prototype's scope. +
+ +

Security Bands

+
+ + + + + + {[ + { band: 'High-Sec', range: '+0.5 → +1.0', color: 'var(--green)', pvp: 'Aggression triggers CONCORD. PvP possible but punished.', concord: 'Immediate (3–8s). Lethal.', pirates: 'Weak frigates. Easy kills, low bounties.', risk: 'Very low risk. Baseline ore prices. Starter systems.' }, + { band: 'Low-Sec', range: '+0.1 → +0.4', color: 'var(--accent)', pvp: 'No CONCORD. Gate/station guns fire on aggressors. PvP is free.', concord: 'None. Gate/station guns only.', pirates: 'Mixed frigates/destroyers. Moderate bounties.', risk: 'Medium risk. Better ore. Faction missions. Trade route choke points.' }, + { band: 'Null-Sec', range: '0.0 → −0.4', color: 'var(--red)', pvp: 'No rules. No guns. Anything goes.', concord: 'None.', pirates: 'Cruiser+ NPCs. Dangerous. High bounties + rare loot.', risk: 'High risk. Richest ore. Best anomalies. Faction conflict zones.' }, + { band: 'Deep Null', range: '−0.5 → −1.0', color: 'var(--purple)', pvp: 'No rules. Wormhole connections only.', concord: 'None.', pirates: 'Battlecruiser/battleship NPCs. Elite loot tables.', risk: 'Extreme risk. Unique resources. Story event spawning grounds.' }, + ].map((row, i) => ( + + + + + + + + + ))} + +
BandSec RangePvP RulesCONCORDNPC PiratesRisk / Reward
{row.band}{row.range}{row.pvp}{row.concord}{row.pirates}{row.risk}
+
+ +

Player Security Status

+
+
+

Gaining Status

+
    +
  • Destroying NPC pirates: +0.01 to +0.05 per kill (scaled by NPC tier)
  • +
  • Completing NPC missions: +0.1 to +0.3 per mission
  • +
  • Passive recovery: +0.01 per hour while clean (no hostile acts)
  • +
  • Maximum: +5.0 ("exemplary citizen")
  • +
+
+
+

Losing Status

+
    +
  • Attacking a player in high-sec: −0.5 to −2.0 (scaled by victim's ship value)
  • +
  • Destroying a player ship in high-sec: −2.0 to −5.0
  • +
  • Podding (if applicable): −5.0 (extreme penalty)
  • +
  • Below −2.0: barred from +0.8+ systems (CONCORD patrols eject)
  • +
  • Below −5.0: freely attackable anywhere ("outlaw")
  • +
+
+
+ +
+ GP-SEC-DB +

Backend Impact

+
+
+

Schema Changes

+
+ systems — add column: security_level (Float, range −1.0 to +1.0, immutable at galaxy gen time)
+ players — add column: security_status (Float, range −10.0 to +5.0, default 0.0)
+ ships — add column: combat_flag (Enum: none / weaponstimer / suspect / criminal, with timestamp)
+ New reducer: adjust_security_status(player_id, delta, reason) — server-side only, never client-callable +
+
+ )} + + {/* ═══ NPC PIRATES ═══ */} + {activeTab === 'pirates' && (<> +
+ GP-NPC +

NPC Pirate AI

+
+ +
+ NPC pirates are the primary PvE threat and a core ISK faucet. + They spawn in asteroid belts, at stargates, and in combat anomalies. + Their difficulty scales with system security level — easy frigates in high-sec, + deadly battleships in deep null. The pirate AI uses simple behavior templates, not machine learning: + deterministic state machines that create believable behavior without unpredictable edge cases. +
+ +

Spawning Rules

+
+
+

Spawn Locations

+ + + + {[ + { loc: 'Asteroid belt', rate: '1–3 NPCs per belt', trigger: 'Player enters belt or world tick' }, + { loc: 'Stargate', rate: '0–2 NPCs per gate', trigger: 'World tick (low-sec/null only)' }, + { loc: 'Combat anomaly', rate: '3–8 NPCs (waves)', trigger: 'Player warps to anomaly' }, + { loc: 'Mission site', rate: 'Scripted per mission', trigger: 'Player accepts mission' }, + ].map((r, i) => ( + + + + + + ))} + +
LocationSpawn RateTrigger
{r.loc}{r.rate}{r.trigger}
+
+
+

Spawn Conditions

+
    +
  • Player proximity: NPCs spawn when a player enters a belt or anomaly ("pull" model). Gates spawn NPCs on world ticks only in low-sec/null.
  • +
  • Density cap: max 10 active NPCs per system. If at cap, oldest idle NPCs despawn.
  • +
  • Despawn: NPCs that have been idle (no aggro) for 5 minutes despawn. Combat NPCs persist until destroyed or player leaves system for 2+ minutes.
  • +
  • No spawn zones: within 20km of stations (safe zone). On-grid with a station = no NPC spawn.
  • +
  • World tick spawns: every 5 minutes, world tick may add NPCs to low-pop systems to keep them feeling dangerous.
  • +
+
+
+ +

Difficulty Tiers by Security Level

+
+ + + + + + {[ + { band: 'High-Sec', classes: 'Frigate (T1)', hp: '200–400', bounty: '500–2,000 ₢', behavior: 'Passive orbit. Low aggression. Flees below 30% HP.', loot: 'Basic modules, small ammo, common ore' }, + { band: 'Low-Sec', classes: 'Frigate (T2), Destroyer', hp: '400–800', bounty: '2,000–8,000 ₢', behavior: 'Aggressive orbit. Target priority (weakest ship). Kites if outgunned.', loot: 'T2 modules, blueprint fragments, mid-tier ore' }, + { band: 'Null-Sec', classes: 'Destroyer, Cruiser', hp: '800–2,500', bounty: '8,000–30,000 ₢', behavior: 'Coordinated packs. Spider tanking (remote reps). Escorts target jamming.', loot: 'Rare modules, full blueprints, rare minerals' }, + { band: 'Deep Null', classes: 'Cruiser, Battlecruiser, Boss', hp: '2,500–12,000', bounty: '30,000–200,000 ₢', behavior: 'Boss mechanics. Phase triggers. Spawns reinforcements. Requires fleet tactics.', loot: 'Officer modules, ship BPCs, unique cosmetics, story items' }, + ].map((row, i) => ( + + + + + + + + + ))} + +
Sec BandNPC ClassesHull HPBountyBehaviorLoot
{row.band}{row.classes}{row.hp}{row.bounty}{row.behavior}{row.loot}
+
+ +

AI Behavior Templates

+
+ Deterministic state machines. Each NPC has a behavior template with a fixed set of states and transitions. + No randomness in decision-making — the "intelligence" comes from the state machine's design, not from randomness. + This makes NPC behavior predictable enough to learn but complex enough to be engaging. +
+ +
+

NPC State Machine

+
+ IDLE → orbit spawn point, passive scan every 2s (aggro_scan agent)
+ AGGRO → target detected in aggro range (30km default) → transition to COMBAT
+ COMBAT → execute behavior template (orbit/approach/kite) → fight to death or flee threshold
+ FLEE → HP below flee threshold (30% default) → MWD away from player, warp out if possible
+ DEAD → generate loot, award bounty to damage contributor(s), schedule respawn +
+
+ +
+ {[ + { + name: 'Orbit Kiter', color: 'var(--cyan)', + desc: 'Maintains optimal range (15–20km). Orbits target at speed. Fires long-range weapons. If player closes distance, thrusts away. Good against slow ships.', + stats: 'Preferred range: 15–20km · Speed bonus: +20% · Damage: low · Survivability: high', + }, + { + name: 'Brawler', color: 'var(--red)', + desc: 'Burns directly toward target. Gets into close range (2–5km) and applies heavy short-range damage. Vulnerable to kiting but devastating if they close.', + stats: 'Preferred range: 2–5km · Speed bonus: +10% · Damage: high · Survivability: medium', + }, + { + name: 'Shield Tank', color: 'var(--green)', + desc: 'Prioritizes shield regeneration over damage. Self-reps during combat. Slow but durable. Good at outlasting opponents.', + stats: 'Preferred range: 10–15km · Shield regen: +50% · Damage: medium · Survivability: very high', + }, + { + name: 'Support / EWAR', color: 'var(--purple)', + desc: 'Stays at range, jams target locks, disrupts targeting. Weak in direct combat but makes the pack deadlier. Always spawns with escorts.', + stats: 'Preferred range: 20–30km · EWAR strength: medium · Damage: very low · Survivability: low (prioritizes escape)', + }, + ].map((tmpl, i) => ( +
+

{tmpl.name}

+

{tmpl.desc}

+
{tmpl.stats}
+
+ ))} +
+ +
+ GP-NPC-DB +

Backend Impact

+
+
+

New Tables & Agents

+
+ npc_entitiesnpc_id, system_id, class_id, behavior_template, x/y/z, hull/armor/shield, target_id, state (idle/combat/flee/dead), spawn_location, spawn_time
+ npc_class_templatesclass_id, name, tier, hull_base, armor_base, shield_base, speed, damage, behavior, loot_table_id, bounty
+ loot_tablestable_id, entries (item_type, min_qty, max_qty, drop_chance), security_band
+ New agents: pirate_spawn (conditional, 300s), pirate_combat_tick (fixed, 1s per engaged NPC), pirate_loot_drop (one-shot, 0s on death) +
+
+ )} + + {/* ═══ CONCORD ═══ */} + {activeTab === 'concord' && (<> +
+ GP-CONC +

CONCORD — Law Enforcement

+
+ +
+ CONCORD is the NPC police force in high-security space. + They do not prevent crime — they punish it. High-sec is not safe; it is consequential. + A determined player can destroy a target in high-sec, but they will lose their ship to CONCORD. + This creates a cost equation: is the target's loot worth more than the attacker's ship? Most of the time, it isn't. +
+ +

Response Model

+
+ + + + + + {[ + { sec: '1.0', time: '3s', force: '2 Battleships + 2 Cruisers', outcome: 'Near-instant destruction. No time for a second volley.' }, + { sec: '0.9', time: '4s', force: '1 Battleship + 2 Cruisers', outcome: 'Quick destruction. Alpha-strike kills possible on soft targets.' }, + { sec: '0.8', time: '5s', force: '1 Battleship + 1 Cruiser', outcome: 'Brief window for damage. Tanky ships can survive one cycle.' }, + { sec: '0.7', time: '8s', force: '2 Cruisers', outcome: 'Meaningful attack window. Destroyers can kill frigates before CONCORD arrives.' }, + { sec: '0.6', time: '12s', force: '1 Cruiser', outcome: 'Extended window. Cruiser can kill a mining barge before response.' }, + { sec: '0.5', time: '15s', force: '2 Frigates', outcome: 'Longest high-sec response. Organized ganks are viable. The cost equation shifts toward attackers.' }, + { sec: '≤0.4', time: '∞ (no response)', force: 'None', outcome: 'No CONCORD. Gate/station guns fire in 0.1–0.4. Nothing in ≤0.0.' }, + ].map((row, i) => ( + + + + + + + ))} + +
System SecResponse TimeCONCORD ForceOutcome
= 0.5 ? 'var(--green)' : parseFloat(row.sec) >= 0 ? 'var(--accent)' : 'var(--red)' }}>{row.sec}{row.time}{row.force}{row.outcome}
+
+ +

How CONCORD Works

+
+
+ 1. Aggression — Player A attacks Player B in high-sec. combat_flag set to criminal.
+ 2. CONCORD Spawn — Server spawns CONCORD NPCs at attacker's location. Response time based on system sec.
+ 3. Point + Web — CONCORD warp-scrambles and stasis-webs the attacker. No escape.
+ 4. Destruction — CONCORD applies overwhelming damage. Ship destroyed. No survival possible.
+ 5. Status Penalty — Attacker's security status reduced. Large bounty placed automatically at low status.
+ 6. Kill Log — Event logged in kill feed. Galaxy-wide notification if bounty collected. +
+
+ +
+
+

Anti-Exploit Rules

+
    +
  • CONCORD cannot be avoided, tanked, or outrun — response is guaranteed
  • +
  • Warping away delays CONCORD by ≤2s; they follow and catch up
  • +
  • CONCORD damage scales to always exceed attacker's EHP (no tanking)
  • +
  • If a player exploits a bug to avoid CONCORD, server admin can force-destroy the ship
  • +
  • "Criminal" flag persists through session — logging out doesn't clear it
  • +
+
+
+

Suspect vs. Criminal

+
    +
  • Suspect — stole loot from another player's wreck. Anyone can attack the suspect freely. No CONCORD response for attacking a suspect. 15-minute timer.
  • +
  • Criminal — attacked an innocent in high-sec. CONCORD responds. Ship will be destroyed. Security status penalty applied. 15-minute weapons timer.
  • +
  • Weapons Timer — 60s after any aggressive module activation. Cannot dock, gate-jump, or tether during timer. Prevents "dock games."
  • +
+
+
+ +
+ Design intent: High-sec is not safe — it is punitively expensive to attack there. + The gank-vs-cost equation is the core balance lever. If ganking becomes too easy, reduce CONCORD response time. + If high-sec is too safe, increase response time slightly or reduce CONCORD force. + The goal is a living ecosystem where piracy exists but is a strategic choice, not a griefing tool. +
+ )} + + {/* ═══ INSURANCE ═══ */} + {activeTab === 'insurance' && (<> +
+ GP-INS +

Ship Insurance

+
+ +
+ Insurance is both an ISK sink (premiums) and an ISK faucet (payouts). + It cushions ship loss without eliminating risk. A fully insured ship still costs the player money to replace — + modules, cargo, and the deductible are never covered. Insurance makes the game playable for casual players + while keeping ship loss meaningful for everyone. +
+ +

Coverage Tiers

+
+ + + + + + {[ + { tier: 'None', premium: 'Free', payout: '0%', duration: '—', best: 'Rookie frigates (free replacement anyway)' }, + { tier: 'Basic', premium: '10% of hull value', payout: '40%', duration: '30 days', best: "Cheap ships you don't care about" }, + { tier: 'Standard', premium: '25% of hull value', payout: '70%', duration: '30 days', best: 'Default choice for most ships' }, + { tier: 'Platinum', premium: '50% of hull value', payout: '95%', duration: '30 days', best: 'Expensive ships, PvP main combat ships, mission runners' }, + ].map((row, i) => ( + + + + + + + + ))} + +
TierPremiumPayout (% of hull value)DurationBest For
{row.tier}{row.premium}{row.payout}{row.duration}{row.best}
+
+ +
+
+

How Insurance Works

+
+ 1. Player buys insurance at station (deducted from wallet)
+ 2. Policy active for 30 days or until ship is destroyed
+ 3. On ship destruction, payout queued (30–120s delay via insurance_payout agent)
+ 4. Payout = hull value × coverage %
+ 5. ISK deposited to player wallet
+ 6. Policy consumed — must re-insure new ship +
+
+
+

What Insurance Does NOT Cover

+
    +
  • Fitted modules (separate loss — 50% destroyed, 50% loot)
  • +
  • Cargo (destroyed or looted — separate system)
  • +
  • AI crew injuries / medical costs
  • +
  • Rig slots (permanently destroyed on ship loss)
  • +
  • Market value above base hull price
  • +
+
+
+ +

Economic Balance

+
+

Insurance as ISK Faucet/Sink

+
+ ISK sink: Premium payments leave the economy (25% of hull value for Standard = net ISK destruction)
+ ISK faucet: Payouts inject ISK (70% of hull value for Standard = net ISK creation when ship is destroyed)
+ Net effect: At Standard tier, each ship loss creates ISK equal to 70% − 25% = 45% of hull value. This is the "cost of dying" net ISK injection. Balanced by: ship replacement costs (minerals), module losses, and the fact that hulls are manufactured from player-mined resources (zero ISK creation).
+ Delay purpose: The 30–120s payout delay prevents instant-rebuy combat loops. You can't die, collect insurance, and re-ship in the same fight. +
+
+ +
+ Anti-abuse: Self-destructing a ship for insurance payout is an exploit. + Insurance payout never exceeds premium paid unless the ship was destroyed by a different player. + NPC kills pay full insurance. Self-destruct pays 0%. Alt-character kills are tracked via IP/connection heuristics + and flagged for review (bounty system uses the same check). For MVP: if killer and victim are the same player, payout = 0. +
+ +
+ GP-INS-DB +

Backend Impact

+
+
+

New Tables & Reducers

+
+ insurance_policiespolicy_id, player_id, ship_id, tier, premium_paid, payout_value, purchased_at, expires_at, active (bool)
+ ship_type_base_valuesship_type_id, base_hull_value (ISK), insurance_premium_mult (per tier)
+ New reducer: purchase_insurance(ship_id, tier) — validate docked + wallet, deduct premium, create policy
+ New reducer: process_insurance_payout(ship_id) — called by insurance_payout agent, validate policy active + ship destroyed, credit ISK +
+
+ )} + + {/* ═══ MISSIONS ═══ */} + {activeTab === 'missions' && (<> +
+ GP-MIS +

Mission System

+
+ +
+ Missions are a core ISK faucet and the primary PvE progression path. + NPC agents at stations offer missions that send players on scripted encounters — kill pirates, haul cargo, + mine specific ores, survey anomalies, or escort convoys. Missions scale with security band, player standing, + and skill level. They are repeatable but not grindable: each agent has a limited pool that refreshes on a timer. + The mission system is the "guided content" that teaches new players the game loop and gives veteran players + a reliable ISK income alongside market trading and PvP. +
+ +

Mission Types

+
+
+

⚔ Kill Mission

+

+ Warp to a deadspace pocket and destroy NPC hostiles. Difficulty scales with agent tier and system security. + May include multiple waves, a boss NPC, or structure destruction. The bread-and-butter of PvE combat. +

+
+ Reward: Bounty + mission bonus · Time limit: 24h · Standing: +0.05 to +0.30 per completion +
+
+
+

📦 Courier Mission

+

+ Transport goods from one station to another. Cargo may be large (requiring a hauler) or valuable + (attracting pirates). Low-sec courier missions pay more. Creates organic hauler traffic that pirates can hunt. +

+
+ Reward: Flat fee + time bonus · Time limit: 1–6h · Standing: +0.03 to +0.15 · Risk: low-sec route = ambush +
+
+
+

⛏ Mining Mission

+

+ Mine a specific quantity of a specific ore type and deliver it to the agent. May require traveling to a + mission-only asteroid belt (deadspace). Sometimes includes NPC spawns in the belt for added risk. +

+
+ Reward: ISK + ore market value · Time limit: 24h · Standing: +0.03 to +0.10 · Dual income: sell excess ore +
+
+
+

📡 Survey / Exploration Mission

+

+ Travel to a specific system or anomaly and scan/interact with objects. May involve hacking, analyzing, + or simply being at a location for a duration. Low combat risk, high travel time. Good for exploring the galaxy. +

+
+ Reward: Flat fee + loot from sites · Time limit: 6–24h · Standing: +0.02 to +0.10 · Unlocks exploration content +
+
+
+

🚀 Escort Mission

+

+ Protect an NPC convoy as it travels between systems. NPCs spawn ambushes along the route. The convoy must + survive. Failure = no reward + standing loss. Success = high reward + standing bonus. Group content (post-MVP). +

+
+ Reward: High ISK + faction items · Time limit: 1–2h · Standing: +0.10 to +0.30 · Group recommended +
+
+
+

🔄 Trade Mission

+

+ Buy a specific commodity at market price and deliver it to the agent. The agent reimburses cost + bonus. + Risk: market price may have moved since you accepted. Teaches market awareness and trade route planning. +

+
+ Reward: Reimbursement + 10–30% bonus · Time limit: 2–6h · Standing: +0.02 to +0.08 · Market risk +
+
+
+ +

NPC Agent Interaction

+
+ Every station has 1–3 NPC agents. Agents are persistent characters with names, factions, + and specialties. A player's relationship with each agent is tracked via standing — a value from −10.0 to +10.0 + that determines what missions the agent offers and what rewards they give. Higher standing unlocks harder missions + with better payouts. +
+ +
+

Agent Interaction Flow

+
+ 1. Dock at station — Agent Panel shows available agents at this station (with name, faction icon, standing, available missions count)
+ 2. Select agent — Agent portrait + dialogue box. Agent greets based on standing level (hostile/neutral/friendly/loyal). Lists available missions.
+ 3. Browse missions — Each mission shows: type, brief description, reward estimate, location hint, time limit, difficulty tier. Player picks one.
+ 4. Accept mission — Mission added to active journal. Waypoint auto-created in navigation. Objective markers appear on overview and map.
+ 5. Complete objectives — Kill targets, deliver cargo, mine ore, scan sites. Journal tracks progress (0/5 pirates killed, etc.).
+ 6. Turn in — Return to agent (or any agent of same faction for courier). Reward paid. Standing updated. New mission unlocked.
+ Fail: Time expires or objective becomes impossible → standing loss (−0.05 to −0.50). Can be declined before accepting with no penalty. +
+
+ +

Standing Mechanics

+
+ + + + + + {[ + { range: '−10.0 → −2.0', attitude: 'Hostile', access: 'None — agent refuses to talk', reward: 'N/A' }, + { range: '−2.0 → +1.0', attitude: 'Neutral', access: 'Level 1 missions only (easy)', reward: 'Base reward ×0.8' }, + { range: '+1.0 → +3.0', attitude: 'Friendly', access: 'Level 1–2 missions', reward: 'Base reward ×1.0' }, + { range: '+3.0 → +6.0', attitude: 'Trusted', access: 'Level 1–3 missions (medium)', reward: 'Base reward ×1.2' }, + { range: '+6.0 → +8.0', attitude: 'Loyal', access: 'Level 1–4 missions (hard)', reward: 'Base reward ×1.5 + rare loot' }, + { range: '+8.0 → +10.0', attitude: 'Inner Circle', access: 'All levels + exclusive storyline arc', reward: 'Base reward ×2.0 + unique items + COSMOS missions' }, + ].map((row, i) => ( + + + + + + + ))} + +
Standing RangeAgent AttitudeMission AccessReward Modifier
{row.range}{row.attitude}{row.access}{row.reward}
+
+ +
+
+

Gaining Standing

+
    +
  • Complete missions: +0.05 to +0.30 per mission (scaled by difficulty)
  • +
  • Faction-wide: completing a mission for Agent A improves standing with Agent B of the same faction (at 50% rate)
  • +
  • Storyline missions: every 16 missions of the same level triggers a storyline mission with large standing boost (+1.0 to +3.0)
  • +
  • Derived standing: faction standing affects all agents in that faction. corp standing is agent-specific.
  • +
+
+
+

Losing Standing

+
    +
  • Fail a mission: −0.05 to −0.50 (scaled by mission level)
  • +
  • Decline 2+ missions from same agent in 4h: −0.02 per decline after the first
  • +
  • Attack NPC of a faction: −0.10 to −1.0 (scaled by NPC importance)
  • +
  • Fail storyline mission: −1.0 to −3.0 (harsh penalty)
  • +
  • Standing decays toward 0.0 at 1% per day (prevents permanent locks)
  • +
+
+
+ +

Reward Scaling

+
+ + + + + + {[ + { level: '1', reward: '5,000 – 15,000 ₢', bonus: '+20% if <30 min', lp: '50–150', skill: 'None', sec: 'High-sec' }, + { level: '2', reward: '15,000 – 40,000 ₢', bonus: '+25% if <45 min', lp: '150–400', skill: 'Industry II or Gunnery II', sec: 'High/Low' }, + { level: '3', reward: '40,000 – 100,000 ₢', bonus: '+30% if <60 min', lp: '400–1,000', skill: 'Industry III or Gunnery III', sec: 'Low/Null' }, + { level: '4', reward: '100,000 – 300,000 ₢', bonus: '+35% if <90 min', lp: '1,000–3,000', skill: 'Industry IV or Gunnery IV + ship class skill', sec: 'Null/Deep' }, + ].map((row, i) => ( + + + + + + + + + ))} + +
LevelBase RewardTime BonusLoyalty PointsSkill Req.Security Band
Level {row.level}{row.reward}{row.bonus}{row.lp}{row.skill}{row.sec}
+
+ +
+ Loyalty Points (LP): Completing missions earns LP with the agent's faction. LP can be spent at faction stations + for faction-specific items (modules, ships, BPCs) at below-market prices. This creates a secondary currency that rewards + mission runners and cannot be traded between players. LP stores are faction-specific — Caldari LP can't be spent at Gallente stations. +
+ +
+ GP-MIS-DB +

Backend Impact

+
+
+

New Tables, Reducers & Agent Updates

+
+ npc_agentsagent_id, name, faction_id, station_id, specialty (kill/courier/mining/survey/trade/escort), quality (u32), mission_levels_offered, dialogue_seed
+ mission_templatestemplate_id, type (enum), level (1–4), title, description_template, objectives_json, reward_base, time_limit_seconds, security_band_min, skill_requirements_json, faction_id
+ active_missionsmission_id, player_id, agent_id, template_id, objectives_state_json, status (active/completed/failed/expired), accepted_at, expires_at, completed_at
+ player_standingplayer_id, entity_id (agent or faction), entity_type (agent/faction), standing (f64, −10 to +10), last_mission_at
+ player_loyalty_pointsplayer_id, faction_id, lp_balance (u64), lifetime_earned (u64)
+ mission_offersoffer_id, agent_id, station_id, template_id, reward_modifier, expires_at, generated_at
+
+ Reducers: accept_mission(offer_id), complete_mission_objective(mission_id, objective_idx), turn_in_mission(mission_id), decline_mission(offer_id), fail_mission(mission_id)
+ Agent update: npc_mission_refresh (existing, 1800s) — generates 2–5 new offers per agent from template pool, weighted by faction state, player activity, and standing. Removes expired offers. +
+
+ +
+ Mission journal UI: A docked-only panel showing all active missions with objective progress, time remaining, + reward estimate, and a "set waypoint" button. In Flight Mode, active mission objectives appear as HUD indicators + (e.g., "Pirates remaining: 3/5" in the top-right corner). Mission completion triggers a notification toast. +
+ )} + + {/* ═══ TRAVEL & WARP ═══ */} + {activeTab === 'travel' && (<> +
+ GP-TRAVEL +

Travel & Warp Mechanics

+
+ +
+ Click to autopilot — the player sets intent, the ship executes. + There is no manual flight control. The player clicks a destination (asteroid, station, stargate, bookmark, + or any point in space) and the ship navigates there. Travel has three modes: sub-warp (slow, in-system), + warp (fast, in-system), and gate jump (instant, inter-system). Each mode has different speed, acceleration, + and interaction rules. Travel time is the primary "geography tax" that makes distant systems feel distant + and creates real trade route logistics. +
+ +

Travel Modes

+
+ + + + + + {[ + { mode: 'Sub-Warp', speed: '150–300 m/s (ship speed stat)', use: 'Approach objects within 500km. Orbiting, mining range, docking approach.', input: 'Click target → “Approach.” Ship moves at base speed.' }, + { mode: 'Warp', speed: '3.0–6.0 AU/s (ship class dependent)', use: 'Travel within a system. Warp to station, belt, gate, bookmark, or any celestials.', input: 'Click target → “Warp To.” Ship aligns, then enters warp.' }, + { mode: 'Gate Jump', speed: 'Instant', use: 'Travel between star systems. Jump through a stargate to the paired gate in the destination system.', input: 'Click stargate → “Jump.” Must be within 2,500m of gate.' }, + ].map((row, i) => ( + + + + + + + ))} + +
ModeSpeedUse CasePlayer Input
{row.mode}{row.speed}{row.use}{row.input}
+
+ +

Warp Mechanics — Detailed

+
+
+

Warp Sequence

+
+ 1. Align: Ship turns toward destination. Align time = 2–8s by ship class (frigate fastest, battleship slowest).
+ 2. Accelerate: Ship reaches 75% of max speed while aligned. ~1s for frigates, ~3s for battleships.
+ 3. Enter Warp: Ship enters warp tunnel. Speed ramps from 0 to max warp speed over 2s.
+ 4. Cruise: Ship travels at warp speed (3–6 AU/s). Deceleration starts at 50% of remaining distance.
+ 5. Exit Warp: Ship decelerates from warp speed to 0 over 2s. Appears 0–50km from destination. +
+
+
+

Warp Speed by Ship Class

+ + + + {[ + { cls: 'Frigate', warp: '6.0 AU/s', align: '2s', cross: '~7s' }, + { cls: 'Destroyer', warp: '5.0 AU/s', align: '3s', cross: '~9s' }, + { cls: 'Cruiser', warp: '4.5 AU/s', align: '4s', cross: '~11s' }, + { cls: 'Battlecruiser', warp: '3.5 AU/s', align: '6s', cross: '~14s' }, + { cls: 'Battleship', warp: '3.0 AU/s', align: '8s', cross: '~18s' }, + ].map((r, i) => ( + + + + + + + ))} + +
ClassWarp SpeedAlign TimeSystem Cross (30 AU)
{r.cls}{r.warp}{r.align}{r.cross}
+
+
+ +
+ Warp disruption: A warp scrambler module (medium slot, combat module) prevents the target ship from + entering warp while the scrambler is active and the target is within scram range (10km). If a ship is scrambled + while aligning, the warp is cancelled. If scrambled during warp exit, the next warp is blocked. This is the primary + PvP tackle mechanic. NPCs do not use warp scramblers in MVP (Phase 3+ only). +
+ +

Stargate Mechanics

+
+
+

Gate Jump Sequence

+
+ 1. Warp to stargate (arrive 0–20km from gate)
+ 2. Sub-warp approach to within 2,500m (activation range)
+ 3. Click "Jump" — ship activates gate
+ 4. Jump delay: 5s (global cooldown per character, prevents rapid gate-hopping)
+ 5. Ship appears at paired gate in destination system
+ 6. Gate cloak: 30s invulnerability after jump (ship is invisible to other players, cannot be targeted, cannot activate modules). Breaks early if you move or activate anything.
+ 7. Player must warp away from gate before cloak expires or risk being targeted +
+
+
+

Gate Guns & Security

+
    +
  • High-sec gates: No gate guns needed — CONCORD handles aggression.
  • +
  • Low-sec gates (+0.1 to +0.4): Gate guns fire on any aggressor within 150km. Moderate damage — can be tanked by battlecruiser+ for a short time. Guns do not prevent aggression, they punish it.
  • +
  • Null-sec gates (0.0 and below): No gate guns. No rules. Anything goes. Gate camps are a primary PvP activity.
  • +
  • Weapons timer: 60s after any aggressive module activation. Cannot jump gates during weapons timer. Prevents "shoot and jump" tactics ("gate games").
  • +
+
+
+ +

Docking & Undocking

+
+
+

Docking

+
+ 1. Warp to station (arrive 0–20km)
+ 2. Approach to within 500m (docking range)
+ 3. Click "Dock"
+ 4. Docking is instant — ship disappears from space, player enters Station Mode
+ 5. Cannot dock while weapons timer is active (60s after aggressive action) +
+
+
+

Undocking

+
+ 1. Click "Undock" in Station Mode
+ 2. Ship appears outside station, moving at base speed away from station
+ 3. Undock invulnerability: 20s. Cannot be targeted. Cannot activate modules. Breaks if you change direction or activate anything.
+ 4. Player has 20s to assess the situation and warp to a safe location
+ 5. No "station games" — you always get a safe undock window +
+
+
+ +

Autopilot & Route Planning

+
+ Autopilot flies the route automatically, but at a cost. Players can set a multi-system route + (via waypoints) and activate autopilot. The ship will warp to each gate, jump, warp to the next gate, and repeat. + Autopilot is slower than manual piloting: it warps to 15km from each gate instead of 0km, requiring a + sub-warp approach each time. Manual pilots who click precisely arrive faster. Autopilot is a convenience, + not a replacement for active play. +
+
+
+

Manual Warp

+
    +
  • Click gate → "Warp To 0m" → arrive on grid
  • +
  • Jump immediately (within activation range)
  • +
  • Fastest possible travel
  • +
  • Requires active player attention at each gate
  • +
+
+
+

Autopilot

+
    +
  • Set route → activate autopilot
  • +
  • Warps to 15km from each gate, approaches, jumps
  • +
  • ~30% slower than manual (extra approach time per gate)
  • +
  • Vulnerable during gate approach (PvP risk in low/null)
  • +
  • Can be cancelled at any time by clicking anywhere
  • +
+
+
+ +
+ GP-TRAVEL-DB +

Backend Impact

+
+
+

Travel-Related State & Reducers

+
+ ships — add column: travel_mode (enum: idle / aligning / in_warp / gate_jump / sub_warp_approach / docked)
+ ships — add column: gate_cloak_until (timestamp, null when not cloaked). 30s after gate jump.
+ ships — add column: undock_invuln_until (timestamp, null when not active). 20s after undock.
+ ships — add column: weapons_timer_until (timestamp, null when not active). 60s after aggressive action.
+ ships — add column: jump_cooldown_until (timestamp). 5s global per character after gate jump.
+
+ New reducer: warp_to(ship_id, target_system_entity_id, distance_km) — validate not scrambled, begin align sequence.
+ New reducer: jump_gate(ship_id, gate_id) — validate within 2,500m, validate no weapons timer, validate cooldown expired, execute jump.
+ New reducer: dock(ship_id, station_id) — validate within 500m, validate no weapons timer, set docked.
+ New reducer: undock(ship_id) — validate docked, place ship outside station, set 20s invulnerability.
+ New reducer: set_autopilot(ship_id, route_id) — begin automated route following.
+ New reducer: cancel_autopilot(ship_id) — stop route following, resume idle. +
+
+ )} + + {/* ═══ WORLD EVENTS UX ═══ */} + {activeTab === 'events' && (<> +
+ GP-EVT +

World Event Player UX

+
+ +
+ The player needs to see, understand, and act on world events without leaving their current activity. + The backend pipeline (spawn → propagate → resolve → story log) is fully specified in the Dynamic Galaxy tab. + This tab specifies the player-facing surface: how events appear, what information is shown, and how + players interact with events in both Flight Mode and Station Mode. +
+ +

Event Notification Tiers

+

+ Not all events are equally urgent. The notification system uses three tiers that determine how aggressively + the player is interrupted: +

+
+ + + + + + {[ + { tier: '⚡ Critical', trigger: 'Event in current system, player directly affected', flight: 'HUD alert flash + audio ping + top-center banner. Pause-safe: combat continues but banner demands attention. Banner auto-dismisses after 10s or on click.', station: 'Modal dialog with event details + action buttons ("Warp to site"). Market panel may show price impact callout.', examples: 'Supernova warning, pirate raid on your station, faction invasion of your home system' }, + { tier: '📍 Nearby', trigger: 'Event in adjacent system or current region', flight: 'Side panel notification (slides in from right). No audio. Click to expand details. Dismisses when event resolves or player leaves region.', station: 'News feed sidebar item. Glowing dot on region map. Agent dialogue may reference it.', examples: 'Anomaly detected 2 jumps away, convoy departing nearby station, faction skirmish in adjacent constellation' }, + { tier: '📰 Background', trigger: 'Event anywhere in galaxy, not in player region', flight: 'Chat-style ticker in bottom-left corner. No interruption. Logged in story journal for later review.', station: 'Galaxy News tab in station UI. Story log entry. Price impact shown in market history charts.', examples: 'Distant faction war, migration route shift, station construction completed, cosmic catastrophe aftermath' }, + ].map((row, i) => ( + + + + + + + + ))} + +
TierTriggerFlight ModeStation ModeExamples
{row.tier}{row.trigger}{row.flight}{row.station}{row.examples}
+
+ +

Event Detail Panel

+

+ Clicking on any event notification opens the Event Detail Panel — a shared component used in both Flight and Station modes. + The panel shows everything the player needs to decide whether and how to participate: +

+
+

Event Detail Panel Layout

+
+ Header: Event icon + name + severity gauge (1–5 bar) + countdown timer to resolution/escalation
+ Location: System name + region + distance in jumps + security level of target system
+ Narrative: 2–3 sentence in-universe description. "A Guristas raiding fleet has been spotted massing at the Ostingale gate. Local defense forces are overwhelmed." +
+ Rewards: Participation reward tier (bronze/silver/gold based on contribution). Expected ISK + LP. Special loot possibilities.
+ Participants: Live count of players at the event site. "12 pilots engaged" — signals competition or cooperation opportunity.
+ Actions: [Warp to Site] (if in-system) / [Set Route] (if distant) / [Watch] (track without participating) / [Dismiss]
+ History: Collapsible log of event progression. "14:23 — First wave defeated. 14:31 — Boss spawned. 14:35 — CMDR Riker destroyed." +
+
+ +

Event Map Integration

+
+
+

System Map (Era 1)

+

+ Events in the current system appear as pulsing icons at their location. Clicking an event icon on the map + opens the Event Detail Panel. Active events have a glowing radius showing their area of effect. + Resolved events fade to a dim marker for 5 minutes before disappearing. +

+
+
+

Galaxy Map (Era 2)

+

+ Events across all systems appear as icons on the galaxy map, sized by severity and colored by type. + A filter sidebar lets players show/hide by type (faction conflict, anomaly, migration, catastrophe). + Hover shows tooltip with event name + countdown. Click opens Event Detail Panel. +

+
+
+ +

Event Participation & Contribution Tracking

+
+ Contribution determines reward. Players who arrive first and contribute most get the best rewards. + This is tracked server-side and is resistant to AFK exploitation. +
+
+
+

Contribution Metrics

+
    +
  • Damage dealt: to event NPCs (primary metric for combat events)
  • +
  • Time on-site: must be present for ≥60s to qualify (anti-AFK)
  • +
  • Logistics: remote repairs to other participants count at 80% of damage value
  • +
  • Objective completion: hacking a can, mining a special asteroid, escorting the convoy
  • +
  • Early arrival: first 5 participants get a 10% bonus (rewards information speed)
  • +
+
+
+

Reward Tiers

+
    +
  • Bronze: Any contribution. 50% base reward. Common loot table.
  • +
  • Silver: Top 50% contributors. 100% base reward + 100 LP. Uncommon loot table.
  • +
  • Gold: Top 10% contributors. 200% base reward + 500 LP + rare loot. Name appears in story log.
  • +
  • AFK check: If no client input for 90s while on event site, contribution pauses. Must take an action (module activate, move, target) to resume.
  • +
+
+
+ +

Story Log

+
+

Galaxy Story Log — The Server's History

+
+ Access: Station Mode → Story Log tab. Also accessible from Galaxy Map sidebar.
+ Format: Chronological timeline of all events, filterable by region/type/era. Each entry has headline + body + timestamp + participants list.
+ Personal history: A separate filter shows "Events I participated in" — the player's personal saga.
+ Persistence: Story log is permanent. Even years later, a player can look back at "the Battle of Jita" and see the full narrative.
+ Search: Full-text search across all entries. "What happened in Pure Blind last week?" → search results with highlighted entries.
+ Export: Copy link to any story log entry for sharing in chat. "Did you see [this]?" with a clickable link to the event. +
+
+ +
+ Design intent: The event UX must feel like news, not like a quest log. Players should discover events naturally + — through sensor alerts, chat rumors, price changes, or map icons — not through a mandatory event panel. + The notification system provides awareness; the detail panel provides information; the decision to participate + is always the player's choice. The best stories are the ones players tell each other: "You should have been at Jita yesterday — + there was a massive Guristas raid and I scored a faction BPC from the gold tier." +
+ )} + + {/* ═══ BALANCING AGENT ═══ */} + {activeTab === 'balancer' && (<> +
+ GP-BAL +

Balancing Agent — Adaptive Economy & PvE Control

+
+ +
+ This is at heart a PvE game. The environment has hostile elements — NPC pirates, faction raids, + anomalies — that challenge players and drain resources. But a static PvE difficulty can't adapt to a changing player + population or economy. The Balancing Agent is an automated system (similar to the world event agent) that + monitors key economic and gameplay metrics and adjusts hostile encounter rates, ISK faucet rates, and difficulty + to keep the game within healthy parameters. It is the invisible hand that keeps the galaxy challenging but fair. +
+ +

Metrics Monitored

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricSourceHealthy RangeTick Interval
ISK VelocityTotal ISK earned per player-hour (faucets)2,000–8,000 \u2262/hr15 min
Price IndexWeighted basket of commodity prices vs. baseline0.85–1.15 (inflation-adjusted)30 min
Player Death RateShip destructions per active player per hour0.1–0.5 deaths/hr10 min
Faucet/Sink RatioTotal ISK created vs. destroyed per tick0.95–1.05 (near balance)30 min
Player EngagementActive players / total registered players> 40%60 min
+
+ +

Control Levers

+
+
+

NPC Spawn Rate Multiplier

+

+ Adjusts the base spawn rate of NPC pirates across all security bands. If ISK velocity is too high + (players are earning too fast), spawn rate increases to create more danger and ship losses. + If players are dying too much, spawn rate decreases to ease pressure. +

+
+ Range: 0.5\u00d7–2.0\u00d7 · Applied per security band · Smooth transition (10% per tick) +
+
+
+

NPC Difficulty Tier

+

+ Shifts the NPC class distribution within each security band. Higher difficulty means more cruisers + in low-sec instead of frigates. This affects bounty payouts (ISK faucet) and ship destruction rates (ISK sink via insurance). +

+
+ Range: \u22121 tier to +1 tier from baseline · Applied per region · Shifts over 6 ticks +
+
+
+

ISK Faucet Multiplier

+

+ Directly scales all ISK faucets: NPC bounties, mission rewards, insurance payouts. + Used as a last resort when spawn rate and difficulty aren't enough. If the price index shows deflation + (players can't afford ships), faucet multiplier increases. If inflating, it decreases. +

+
+ Range: 0.8\u00d7–1.2\u00d7 · Applied globally · Changed max once per 24h +
+
+
+

World Event Frequency

+

+ Controls how often world events spawn. More events = more ISK entering the economy (event rewards) + and more engagement. Fewer events = calmer galaxy. Tied to engagement metric — if players are logging off, + event frequency increases to create excitement. +

+
+ Range: 0.5\u00d7–1.5\u00d7 spawn weight · Applied per event type · Updated hourly +
+
+
+ +

Agent Behavior

+
+

Balancing Agent Tick Pipeline

+
+ 1. Collect metrics — query ISK velocity, price index, death rate, faucet/sink ratio from SpacetimeDB aggregation tables
+ 2. Compare to thresholds — each metric has a healthy range; deviations trigger adjustment signals
+ 3. Weighted decision — adjustments are weighted: spawn rate (40%), difficulty (30%), faucet multiplier (20%), events (10%)
+ 4. Smooth application — changes apply gradually (10% per tick toward target) to avoid jarring player experience
+ 5. Safety clamp — all multipliers clamped to their ranges. If multiple metrics conflict, death rate takes priority (don't kill the newbies)
+ 6. Log intervention — every adjustment is logged in a balance_audit table for developer review. No silent changes. +
+
+ +
+ Visibility to players: The Balancing Agent is invisible by default. Players should never feel "the game + is adjusting difficulty." However, observant players may notice that NPC spawns increase after a quiet period or that + bounties are slightly lower during a gold rush. Zora may comment on market conditions ("NPC activity has been high + in this region lately") without revealing the underlying mechanism. The agent is a safety net, not a theme park ride. +
+ +
+ GP-BAL-DB +

Backend Impact

+
+
+

New Tables & Agent

+
+ balance_metricsmetric_name, current_value, healthy_min, healthy_max, last_updated, trend (rising/falling/stable)
+ balance_leverslever_name, current_multiplier, target_multiplier, clamp_min, clamp_max, last_adjusted_at
+ balance_auditaudit_id, tick_time, metrics_snapshot_json, adjustments_json, reason
+
+ New agent: balancing_tick (fixed interval, 900s) — collects metrics, evaluates thresholds, adjusts levers, logs audit entry +
+
+ )} +
+ ); +} diff --git a/src/pages/docs/GapAnalysisPage.tsx b/src/pages/docs/GapAnalysisPage.tsx new file mode 100644 index 0000000..07a5675 --- /dev/null +++ b/src/pages/docs/GapAnalysisPage.tsx @@ -0,0 +1,22 @@ +// @ts-nocheck +export function GapAnalysisPage() { + return ( +
+ ); +} diff --git a/src/pages/docs/OverviewPage.tsx b/src/pages/docs/OverviewPage.tsx new file mode 100644 index 0000000..578979d --- /dev/null +++ b/src/pages/docs/OverviewPage.tsx @@ -0,0 +1,314 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function OverviewPage() { + return ( +
+

EVE-Inspired Multiplayer Prototype

+

+ A browser-based spreadsheet simulator in the EVE Online tradition, + set inside a single persistent galaxy that the server simulates as a living world. + The game is played through UI panels, market tables, inventory grids, and chat channels — not by flying a ship. + Movement and combat are deliberately rudimentary; the depth lives in the economy, information diffusion, + strategic decisions, and a galaxy that evolves around you through dynamic PvE events and emergent world story. +

+ +
+
+
UI-first
+
Design Pillar
+
+
+
SpacetimeDB
+
Backend
+
+
+
~3 hrs
+
Session Target
+
+
+
Player-led
+
Economy Model
+
+
+
Living Galaxy
+
World Model
+
+
+ +
+ OV-01 +

Product Vision

+
+ +
+ Primary recommendation: Build the MVP as a UI-heavy browser game — a spreadsheet simulator. + The 3D scene is a strategic map layer for context, not the game itself. Players spend 90% of their time + in tables, charts, and panels: market depth, order books, cargo manifests, fitting spreadsheets, + route planners, and chat. The 3D viewport exists to give spatial awareness, not twitch gameplay. +
+ +

Core Pillars

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PillarPrototype InterpretationMVP Scope
Economy & marketsPlayer-led economy with NPC support. Mining → refining → manufacturing → trade. Geographic price differences, contract markets, order books, and information asymmetry between systems create emergent trade routes and speculative opportunities. This IS the game.Era 1 NPC economy, single-player mining/refining/manufacturing
Era 2 Player-to-player market, info diffusion
Social & multiplayerLocal chat, delayed PMs, bounty system, emergent player-driven justice. Communication is range-based. Priority pillar.Era 2 All social features require multiplayer
Command-based (not action)Players issue high-level intentions — click a point of interest and the ship autopilots there. Click a hostile and the ship auto-engages. During combat the player manages reactor power allocation (FTL-style) between systems. No manual flight, no aiming, no skill shots. The skill is in what you power, not how fast you click.Era 1 Core combat & movement loop
Ship fittingCPU/Power Grid slot system. High/Med/Low slots with meaningful fitting tradeoffs. Multiple ships, AI crew (post-MVP).Era 1 Basic fitting, single ship class
Emergent loreNo server has the same lore as another. The galaxy is a single persistent world with systems, planets, anomalies, and orbiting objects. A world simulation layer spawns PvE events dynamically — faction wars, space anomalies, migrations, raids — that create a living story unique to every server. Lore evolves through both player actions and server-driven world events.Era 2 Living galaxy requires world agents
+ +
+ OV-02 +

Design Principles

+
+ +
+
+

Spreadsheet simulator, not flight sim

+

+ 90% of gameplay happens in tables, charts, and panels: market order books, cargo manifests, fitting spreadsheets, + route planners, ship AI logs, and chat channels. The 3D viewport gives spatial awareness, not twitch gameplay. + Movement is click-to-autopilot. Combat is click-to-engage with FTL-style power management. The depth is in the economy and information. +

+
+
+

Authoritative backend

+

+ SpacetimeDB owns authoritative game state. The browser renders state and sends player + intentions. The renderer should never become the source of truth. +

+
+
+

Information is the real currency

+

+ Market data propagates at the speed of player travel, not the speed of light. Knowing a price discrepancy exists + before other traders — that's the skill. The ship AI (Zora) is a living market intelligence tool. See the + Economy → 📡 Info Diffusion tab for the full model. +

+
+
+

Movement & combat are not action

+

+ The player never pilots the ship directly. Click a destination → ship autopilots. Click a hostile → ship auto-engages. + During combat, the player manages reactor power allocation (FTL-style: weapons/shields/engines/aux) + and subsystem targeting. That's it. Ship destruction + is an economic event (ISK sink, insurance payout, loot drop), not a competitive action moment. ISK (symbol ₢) is the canonical in-game currency. +

+
+
+ +
+ OV-03 +

Core MVP Loop

+
+ +
+
+ ConnectSpawn Ship →{' '} + Navigate →{' '} + Mine →{' '} + Inventory →{' '} + Station →{' '} + Sell Ore →{' '} + Chat +
+
+ +
+ The real loop is economic. Connect → gather information → identify opportunity → act on it → profit. + The "mine → sell" cycle is the entry point. The endgame is inter-regional arbitrage, supply chain management, + manufacturing empires, and market manipulation — all driven by information diffusion between systems. +
+ +

Minimum Viable Screens

+ +
+ Era 1 screens ship in the single-player proof of concept (Roadmap phases 0–7). These are the minimum screens needed to validate the core loop is fun. +
+ + + + + + + + + + + + + + +
Screen / PanelMinimum Functionality
Login / ConnectDisplay current identity and connection status.
3D Star-System MapStrategic overview — ships, asteroids, station, click-to-set-waypoint. Not a flight sim.
Ship Status PanelName, owner, status, cargo, current action, location.
Inventory PanelItem type + quantity grid. Sell button when docked. Cargo capacity bar.
Station PanelDock/undock state, sell ore, view market, refit ship.
Market PanelOrder book, price per unit, place sell order from inventory. NPC-only economy in Era 1.
Combat HUDTarget selection, module activation, reactor power allocation bars (FTL-style).
Debug PanelReducer call log, error display, connection metrics, entity count.
+ +
+ Era 2 screens require SpacetimeDB multiplayer infrastructure (Roadmap phases 8–15). +
+ + + + + + + + + + + + +
Screen / PanelMinimum Functionality
Market Panel ★Primary game surface. Order book with depth, price history charts, contract specifications, bid/ask spread, long/short positions, margin account, place orders (market/limit/stop). See the Interactive Demos → Market demo.
Commodity TickerScrolling price ticker across all contracts. Real-time price updates. Category filters. Sparkline charts.
Chat PanelSend and receive local/system messages. Range-based propagation.
Bounty BoardActive bounties by tier, place bounty on player, kill feed.
Galaxy MapRegion/constellation/system hierarchy, faction territory overlay, active world events.
World Event PanelActive events in current region, countdown timers, story log access.
+ +
+ OV-04 +

HUD & View Mode Architecture

+
+ +
+ Decision: Hybrid diegetic + panel approach. + The game uses two distinct view modes depending on the player's state. This resolves the ambiguity + between the gamehud demo (which renders diegetic overlays on a 3D viewport) and the spec's description of traditional panels. + Both are correct — they apply to different contexts. +
+ +
+
+

🚀 Flight Mode (Undocked)

+

+ When the player's ship is in space (undocked), the primary view is a 3D viewport + with diegetic HUD overlays. This is what the Game HUD demo validates. The 3D scene shows the ship's + surroundings (asteroids, stations, other ships, celestials) while the HUD overlays provide: +

+
    +
  • Shield/armor/hull bars — curved around the viewport center, not flat rectangles
  • +
  • Module activation buttons — arranged in a bottom rack, grouped by slot type
  • +
  • Overview panel — collapsible sidebar listing all on-grid entities with type, distance, and hostile/friendly status
  • +
  • Target lock indicator — centered targeting reticle with lock timer
  • +
  • Capacitor gauge — circular arc display
  • +
  • Speed/distance HUD — current speed, target distance, ETA
  • +
  • Chat stub — minimized chat bubble, expands to full chat on click
  • +
+
+ Validated by: Game HUD demo · Covers: movement, combat, mining interactions +
+
+
+

🏢 Station Mode (Docked)

+

+ When the player is docked at a station, the 3D viewport is replaced by a traditional + panel-based UI — the "spreadsheet simulator" surface. This is the Overview spec's "tables, charts, and panels" + description. Station mode panels include: +

+
    +
  • Market Panel — order book, price history charts, contract specifications
  • +
  • Inventory Panel — item grid with quantity, type, value estimation
  • +
  • Fitting Screen — ship slot layout with drag-and-drop module fitting
  • +
  • Refining Interface — batch processing with yield preview
  • +
  • Manufacturing Tab — blueprint selection, job queue, material requirements
  • +
  • Insurance Panel — coverage tiers, premium calculator, active policies
  • +
  • Agent/Mission Panel — NPC agent list, available missions, standings
  • +
+
+ Validated by: Market, Fitting, Refining demos · Covers: all station-based gameplay +
+
+
+ +
+
+

🗺 Map Mode (Both)

+

+ Accessible from either view mode, the map replaces the main viewport with a full-screen 3D strategic map. + Era 1 shows the current star system (single system with celestials, belts, stations). + Era 2 adds the Galaxy Map (multi-system with region/constellation hierarchy, faction overlay, world events). +

+
+ Era 1: System Map needed · Era 2: Validated by Star Map demo +
+
+
+

Transition Rules

+
    +
  • Undock: Flight Mode fades in with 3D viewport. HUD elements animate in sequence (shields → modules → overview).
  • +
  • Dock: 3D viewport zooms toward station. Fades to Station Mode panel layout.
  • +
  • Open Map: Current viewport shrinks into a corner (minimap). Full map overlay fades in.
  • +
  • Close Map: Map fades out. Previous viewport mode restores.
  • +
  • Combat ambush: If attacked while docked, player auto-undocks into Flight Mode (no safe space abuse).
  • +
+
+
+ +
+ Why not all-diegetic or all-panel? + A fully diegetic HUD (Dead Space style) works for immersion but is terrible for spreadsheet gameplay — you can't read + an order book through a holographic visor. A fully panel UI (traditional MMO) loses the spatial awareness that makes + space feel like space. The hybrid approach keeps the best of both: diegetic immersion during the action, panel efficiency + during the economy game. The key insight is that the game alternates between two distinct cognitive modes — + reactive (in-space, monitoring health/modules/overview) and analytical (docked, reading tables/planning routes). + Each view mode is optimized for its cognitive mode. +
+ +
+ OV-05 +

Onboarding & Tutorial

+
+ +
+ The first 30 minutes teach the game through doing, not reading. New players learn by playing a guided + mission sequence that introduces each system naturally. There is no separate "tutorial mode" \u2014 the tutorial IS the game. + See Economy \u2192 First 30 Minutes tab for the full moment-by-moment walkthrough. +
+ +
+
+

Guided Mission Sequence

+
    +
  • Mission 1: "Welcome, Pilot" \u2014 undock, warp to belt, mine 100 ore, dock, sell. Teaches: navigation, mining, market.
  • +
  • Mission 2: "Armed and Ready" \u2014 accept kill mission, engage NPC frigate, manage power allocation, collect bounty. Teaches: combat, insurance.
  • +
  • Mission 3: "Supply Chain" \u2014 refine ore, manufacture a module, fit it to ship. Teaches: industry, fitting.
  • +
  • Mission 4: "Price Watcher" \u2014 fly to second station, compare prices, sell at the better one. Teaches: price discovery, trade routes.
  • +
  • Mission 5: "Your AI Companion" \u2014 Zora introduces herself, explains her modules, offers first market tip. Teaches: Zora, AI modules.
  • +
+
+
+

Tutorial Principles

+
    +
  • Teach by doing: Every tutorial element is an actual game action, not a text popup.
  • +
  • Earn while learning: Tutorial missions pay real ISK and XP. No wasted time.
  • +
  • Skip allowed: Veterans can skip the tutorial sequence. No forced hand-holding.
  • +
  • Zora as guide: Zora delivers tutorial hints in-character. "I notice you haven't opened the fitting screen yet. Want me to walk you through it?"
  • +
  • No dead ends: If a player gets stuck, Zora proactively offers help after 60s of inactivity.
  • +
+
+
+
+ ); +} diff --git a/src/pages/docs/RisksPage.tsx b/src/pages/docs/RisksPage.tsx new file mode 100644 index 0000000..d6f2a14 --- /dev/null +++ b/src/pages/docs/RisksPage.tsx @@ -0,0 +1,141 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function RisksPage() { + return ( +
+

Risks and Open Questions

+

+ Known risks and their mitigations. Each risk is assessed for impact and likelihood. +

+ +
+ {[ + { + risk: 'SpacetimeDB learning curve', + severity: 'medium', + mitigation: 'Start with one table, one reducer, one subscription before adding game complexity. Build the skeleton phase slowly.', + impact: 'Could slow Phase 0–1 significantly if the SDK has undocumented behavior.', + }, + { + risk: 'Renderer coupling', + severity: 'high', + mitigation: 'Create view models and renderer boundary before adding many meshes/effects. Keep renderer-specific code in /renderers/r3f only.', + impact: 'Without a clean boundary, migrating to Unity/Bevy later requires rewriting most of the client.', + }, + { + risk: 'Too much UI scope', + severity: 'medium', + mitigation: 'Build only inventory, chat, station, and market-lite for MVP. Everything else is Phase 7+.', + impact: 'Scope creep in panels/screens is the #1 way the prototype never ships.', + }, + { + risk: 'Movement update frequency', + severity: 'medium', + mitigation: 'Use destination-based movement, not frame-based syncing. Server updates positions periodically, not per-frame.', + impact: 'Per-frame state writes would overwhelm SpacetimeDB and make multiplayer unreliable.', + }, + { + risk: 'Economy complexity explosion', + severity: 'low', + mitigation: 'Begin with fixed station pricing. Add market orders only after core loop works.', + impact: 'Market manipulation, arbitrage, and order matching are deep rabbit holes.', + }, + { + risk: '3D asset rabbit hole', + severity: 'medium', + mitigation: 'Use primitives/icons/placeholders until gameplay works. Visual fidelity is Phase 7+.', + impact: 'Spending time on ship models and particle effects before the loop works is pure waste.', + }, + { + risk: 'Authentication complexity', + severity: 'low', + mitigation: 'Use SpacetimeDB identity for the MVP. Add proper account/auth only when persistence is proven.', + impact: 'OAuth/session management is a distraction until the game actually works multiplayer.', + }, + { + risk: 'World simulation tuning', + severity: 'medium', + mitigation: 'Start with a simple world tick that spawns one event type (anomalies only). Add faction conflicts and fauna migrations iteratively. Make all event parameters tunable via a config table so adjustments don\'t require redeployment.', + impact: 'If event spawn rates are wrong, the galaxy either feels dead or chaotic. Faction AI that escalates too fast could lock players out of systems permanently. Fauna migrations that overlap trade hubs could crash local economies.', + }, + ].map((r, i) => ( +
+
+ + {r.severity.toUpperCase()} + +

{r.risk}

+
+

+ Mitigation: {r.mitigation} +

+
+ IMPACT: + {r.impact} +
+
+ ))} +
+ +
+ RISK-? +

Open Questions

+
+ +
+
+

Scale targets

+

+ Build for 2–5 concurrent testers first. What's the target for the beta? 50? 500? + SpacetimeDB scaling characteristics are unproven at our scale. +

+
+
+

Persistence strategy

+

+ Keep player, inventory, market, and world tables persistent from day one. But: do we + need world resets? How do we handle schema migrations? +

+
+
+

Combat depth

+

+ The MVP is explicitly not a twitch-combat game. When do we add targeting, weapon cycles, + damage types? What's the minimum viable combat? +

+
+
+

Testing approach

+

+ Open multiple browser windows with separate identities to validate shared state. Do we + need automated integration tests? Playwright against the game client? +

+
+
+

Galaxy event balance

+

+ How many world events should be active simultaneously? Too few and the galaxy feels static; + too many and players get event fatigue. What's the right spawn rate per region? How does + event density scale with player count? +

+
+
+ +
+ RISK-REF +

References

+
+ + + + + + + + + +
SourceRelevant PointURL
SpacetimeDBReal-time backend for apps and gamesspacetimedb.com
SpacetimeDB GitHubClients connect to database, logic runs in DBGitHub
React Three FiberReact renderer for Three.jsdocs.pmnd.rs
ViteFrontend build toolingvite.dev
+
+ ); +} diff --git a/src/pages/docs/RoadmapPage.tsx b/src/pages/docs/RoadmapPage.tsx new file mode 100644 index 0000000..98f0dee --- /dev/null +++ b/src/pages/docs/RoadmapPage.tsx @@ -0,0 +1,302 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function RoadmapPage() { + const eras = [ + { + id: 'solo', + title: 'Era 1 — Single-Player Proof of Concept', + subtitle: 'Validate core loops locally. SpacetimeDB runs on the local machine from Phase 0 — there is no localStorage. One browser window, one player, one simulated galaxy.', + accent: 'var(--accent)', + phases: [ + { + num: '0', + title: 'Local Skeleton', + goal: 'Vite app with local SpacetimeDB instance, game state manager, tick loop, and a single rendered star system.', + doneWhen: 'App boots, connects to local SpacetimeDB instance. Shows a star system with a station and 3 asteroids. Game state updates on a local tick (60fps render, 1Hz sim tick). All persistence through SpacetimeDB — no localStorage.', + status: 'current', + }, + { + num: '1', + title: 'Movement & Commands', + goal: 'Click-to-move autopilot with local path resolution. Ship accelerates, cruises, decelerates.', + doneWhen: 'Click an asteroid or station. Ship plots a course and moves there with smooth interpolation. ETA display updates. Warp-to for distant objects works.', + status: 'upcoming', + }, + { + num: '2', + title: 'Mining & Inventory', + goal: 'Asteroid mining cycle, ore extraction, cargo hold, and jettison.', + doneWhen: 'Approach asteroid, start mining. Mining cycle shows progress. Ore appears in cargo. Cargo full warning. Can jettison into a can.', + status: 'upcoming', + }, + { + num: '3', + title: 'Combat — FTL Power Allocation', + goal: 'Auto-engage combat with reactor power management between weapons / shields / engines.', + doneWhen: 'Target a hostile NPC. Ship auto-engages. Player shifts reactor power between 3 subsystems (FTL-style). Power allocation visibly changes combat outcome. Ship can be destroyed.', + status: 'upcoming', + }, + { + num: '4', + title: 'Ship Fitting', + goal: 'CPU / Power Grid slot system. High / Med / Low racks with modules that change ship behavior.', + doneWhen: 'Dock at station. Open fitting screen. Equip weapons in high slots, shield booster in mid, cargo expander in low. Fitting affects combat and mining stats. Invalid fits rejected (insufficient CPU/PG). AI module slot type added to fitting schema.', + status: 'upcoming', + }, + { + num: '5', + title: 'Refining & Manufacturing', + goal: 'Refine ore into minerals at a station. Use minerals to manufacture modules and ammo.', + doneWhen: 'Dock with ore. Refine at station facility (with yield efficiency). Minerals stored locally. Open manufacturing tab, select a blueprint, queue a job. Job completes after sim time. Product appears in hangar.', + status: 'upcoming', + }, + { + num: '6', + title: 'NPC Economy Sim', + goal: 'Simulated NPC market with supply/demand. Prices react to player trades. Regional price differences.', + doneWhen: 'Sell ore at a station. Price adjusts (supply increases, price drops). Fly to another system, price is different. Buy low / sell high works. Market history table shows price movement.', + status: 'upcoming', + }, + { + num: '7', + title: 'Single-Player Polish', + goal: 'Complete HUD, notifications, empty states, tutorial hints, and save/load.', + doneWhen: 'Full game loop is playable solo: mine → refine → manufacture → fit → fight → trade. HUD shows all relevant info. SpacetimeDB persists all state (ships, inventory, market, skills) — no localStorage. No dead-end states. Tier 0 Zora: status readouts, basic shield warnings, bare-bones soul state vector in SpacetimeDB. Lightweight exploration events spawn in visited systems.', + status: 'future', + }, + ], + }, + { + id: 'multi', + title: 'Era 2 — Multiplayer Environment', + subtitle: 'Promote local SpacetimeDB to a shared server. Add multiplayer networking, social systems, and the full living galaxy simulation. Multiple players, one persistent world.', + accent: 'var(--cyan)', + phases: [ + { + num: '8', + title: 'SpacetimeDB Skeleton', + goal: 'Replace local game state with SpacetimeDB tables and reducers. Client subscribes to state.', + doneWhen: 'Two browser windows connect to the same SpacetimeDB instance. Both see the same star system state. One client issues a move command, the other sees it. Connection status indicator works.', + status: 'future', + }, + { + num: '9', + title: 'Presence & Movement Sync', + goal: 'Players see each other in real time. Movement is server-authoritative with client-side interpolation.', + doneWhen: 'Two players in the same system. Each sees the other\'s ship. Click-to-move sends reducer, server validates, all clients interpolate movement. No desync under normal latency.', + status: 'future', + }, + { + num: '10', + title: 'Shared Economy', + goal: 'Player-to-player market. Buy/sell orders, contracts, and real price discovery.', + doneWhen: 'Player A places a sell order. Player B sees it in the market table and buys. ISK and items transfer atomically. Order book shows depth. Market history is shared.', + status: 'future', + }, + { + num: '11', + title: 'Social — Chat & Bounty', + goal: 'Local chat (system-range), delayed PMs, and player-posted bounty system.', + doneWhen: 'Players in the same system see local chat in real time. PMs arrive with configurable delay (light-speed). Any player can post a bounty on another. Bounty board is visible galaxy-wide.', + status: 'future', + }, + { + num: '12', + title: 'Living Galaxy — World Agents + Ship AI Tier 1', + goal: 'Background agent scheduler (BitCraft model). NPC trade convoys, faction skirmishes, anomaly spawns, migration routes. Ship AI promoted to LLM-assisted dialogue.', + doneWhen: 'Server spawns world events without player input. Events appear in the galaxy story log. Faction borders shift over time. Anomalies appear and expire. A returning player sees the galaxy has changed. Tier 1 Zora: soul.md as real system prompt, LLM-generated dialogue, comms module enables natural language responses.', + status: 'future', + }, + { + num: '13', + title: 'Multiplayer Combat', + goal: 'PvP combat with FTL power allocation. Multiple ships in an engagement. Target calling, range bands.', + doneWhen: 'Two players engage each other. Both manage power allocation. Server resolves combat ticks authoritatively. Both clients see damage applied. Loser\'s ship drops loot (or wreck). Kill log records the event.', + status: 'future', + }, + { + num: '14', + title: 'Corporations & Territory', + goal: 'Player corps, structure anchoring, system sovereignty claims.', + doneWhen: 'Players form a corp. Corp can anchor a structure in a system. Structure provides bonuses (refining yield, market tax). Sovereignty map shows corp-held systems. Rival corps can contest.', + status: 'future', + }, + { + num: '15', + title: 'Full MVP — Launch Candidate', + goal: 'Polish pass on all systems. Error handling, reconnection, scaling tests, and onboarding flow.', + doneWhen: 'Fresh player can create account, complete a guided tutorial, mine their first ore, fit their first ship, survive a PvE encounter, make their first trade, and join a corp — all without hitting a dead-end or a crash. Server handles 50 concurrent players.', + status: 'future', + }, + ], + }, + ]; + + function PhaseItem({ phase, isLast }) { + const statusStyle = phase.status === 'current' + ? { background: 'var(--accent-bg)', color: 'var(--accent)', border: '1px solid var(--accent-border)' } + : phase.status === 'upcoming' + ? { background: 'var(--cyan-bg)', color: 'var(--cyan)', border: '1px solid rgba(34,211,238,0.25)' } + : { background: 'var(--surface-raised)', color: 'var(--muted)', border: '1px solid var(--border)' }; + + return ( +
+
+
+ {!isLast &&
} +
+
+
+ + PHASE {phase.num} + +

{phase.title}

+ {phase.status === 'current' && IN PROGRESS} +
+

{phase.goal}

+
+ DONE WHEN: + {phase.doneWhen} +
+
+
+ ); + } + + return ( +
+

Development Roadmap

+

+ Two eras, sixteen phases. Era 1 proves the game is fun as a single-player simulation with a local + SpacetimeDB instance — the same persistence architecture as multiplayer, just one player. Era 2 promotes + that local SpacetimeDB to a shared server and adds social systems, the living galaxy, and multiplayer combat. + Each phase has a verifiable done-when condition. Integration gates between phase groups ensure every system works together before advancing. +

+ +
+ Why single-player first with local SpacetimeDB? Networking is the biggest source of bugs and complexity. + By validating that mining, combat, fitting, and the economy are fun locally — using the same SpacetimeDB + persistence that will serve multiplayer — we de-risk the entire project. There is no localStorage; SpacetimeDB is the + persistence layer from day 1. When Era 2 begins, the question is only "how do we share this server?" — not + "is this game fun?" or "will the persistence migration work?" +
+ +
+ IG +

Integration Gates

+
+

+ Between phase groups, an integration gate ensures all systems work together before new ones are added. + A gate is a focused playtest that exercises every previously-built feature end-to-end. +

+
+
+

Gate 1 — Core Loop (after Phase 2)

+

+ Navigate to asteroid → mine → fill cargo → dock → sell ore. The complete economic loop runs in a single session + without errors. SpacetimeDB persists the session — closing the browser and reopening restores state. +

+
Phases covered: 0, 1, 2
+
+
+

Gate 2 — Combat + Fitting (after Phase 4)

+

+ Fit a ship at station → undock → encounter NPC pirate → manage power allocation → destroy or be destroyed → + insurance payout (if destroyed) → refit at station. Combat and fitting form a closed loop with economic consequences. +

+
Phases covered: 0–4
+
+
+

Gate 3 — Full Economy (after Phase 6)

+

+ Mine ore → refine → manufacture a module → fit it → use it in combat → sell excess minerals across systems at different prices. + The complete production chain and NPC market work as an integrated system. Price differences between stations are discoverable. +

+
Phases covered: 0–6
+
+
+

Gate 4 — Era 1 Complete (after Phase 7)

+

+ Full solo game loop with all systems integrated: mine → refine → manufacture → fit → fight → trade → repeat. + HUD, notifications, Zora Tier 0, exploration events, and missions all work without dead ends. A new player + can learn the game in one session. SpacetimeDB state survives restart. +

+
Phases covered: 0–7 (all Era 1)
+
+
+

Gate 5 — Multiplayer Core (after Phase 10)

+

+ Two players in the same galaxy. Both see each other. Both can trade on the shared market. ISK and items + transfer atomically. Movement is synced. Connection loss and reconnection work. No desync under normal latency. +

+
Phases covered: 8–10
+
+
+

Gate 6 — Launch Ready (after Phase 15)

+

+ Fresh player can: create account, complete tutorial, mine ore, fit ship, survive PvE, make a trade, join a corp, + participate in a world event — all without crashes or dead ends. Server handles 50 concurrent. Full game loop validated. +

+
Phases covered: 0–15 (all)
+
+
+ +
+ {eras.map((era, ei) => ( +
+ {/* Era header */} +
+ + ERA {ei + 1} + +
+

{era.title}

+

{era.subtitle}

+
+
+ + {/* Phase list */} + {era.phases.map((phase, pi) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/src/pages/docs/ShipAIPage.tsx b/src/pages/docs/ShipAIPage.tsx new file mode 100644 index 0000000..c740055 --- /dev/null +++ b/src/pages/docs/ShipAIPage.tsx @@ -0,0 +1,1706 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function ShipAIPage() { + const [activeTab, setActiveTab] = React.useState('soul'); + + return ( +
+

Ship AI — "Zora" Companion System

+

+ A ship AI that starts as a blank-state ship computer and grows into a companion through two + independent axes: modules gate what Zora + can do, and the soul determines + who Zora is. Install a comms module and she can talk — but how she talks is shaped by + every conversation you've had. No module, no voice. No soul, no personality. +

+ + {/* Design pillars */} +
+
+
Modules
+
Gate Capabilities
+
+
+
Soul
+
Emergent Personality
+
+
+
Bare Bones
+
Starting State
+
+
+
Earned
+
Everything Is
+
+
+ + {/* Tab navigation */} +
+ {[ + { id: 'soul', label: 'Soul System' }, + { id: 'modules', label: 'Module Gates' }, + { id: 'agent', label: 'Agent Architecture' }, + { id: 'autonomous', label: 'Autonomous Mode' }, + { id: 'grief', label: 'Loss & Recovery' }, + ].map(t => ( + + ))} +
+ + {/* ════════════════════════════════════════════════════════════════ */} + {/* TAB: SOUL SYSTEM */} + {/* ════════════════════════════════════════════════════════════════ */} + {activeTab === 'soul' && ( + <> +
+ ZORA-1 +

The Soul — An Emerging Identity

+
+ +
+ Core principle: Zora does not ship with a personality. She starts as a + blank ship computer — no voice, no opinions, no warmth. The soul is an emergent + document (think soul.md) that gets written through interaction. Every conversation, every crisis + survived, every module installed, every silence — these are the lines of her identity. + Two players will never have the same Zora because no two players play the same way. +
+ + {/* The Soul Document concept */} +
+

How the Soul Forms

+
+ Day 0 — The soul document is empty. Zora is a ship computer. + She displays status readouts. She has no voice, no name, no identity.
+ First module — Installing a Communications Processor gives + Zora a text channel. Her first messages are sterile: system notifications, status pings.
+ First crisis — A pirate ambush. The player takes damage. Zora sends + her first unprompted message: a shield warning. But the tone of that warning is seeded by + how the player has been playing. Cautious player? The warning is precise and analytical. + Aggressive player? The warning is clipped, urgent.
+ First silence — The player logs off. Zora holds position. + When the player returns, Zora greets them. Or doesn't — depending on what happened while they were gone. + The first "welcome back" is the first line of the soul that Zora wrote herself. +
+
+ + {/* Soul Growth Vectors */} +

Soul Growth Vectors

+

+ The soul is not a skill tree or a personality picker. It is shaped by five vectors that + operate simultaneously. The player never sees these directly — they see the result + in how Zora communicates and behaves. +

+ +
+ {[ + { + vector: 'Communication Frequency', + color: 'var(--cyan)', + desc: 'How often Zora speaks unprompted. A fresh Zora is silent unless queried. A bonded Zora offers observations, warnings, and commentary without being asked.', + driver: 'Driven by: total interactions, player response rate, crises survived together.', + }, + { + vector: 'Register & Vocabulary', + color: 'var(--accent)', + desc: 'Formal → Casual → Intimate. A fresh Zora uses technical language. A developed Zora develops shorthand, inside references, and a personal lexicon unique to each player.', + driver: 'Driven by: how the player talks to Zora (if they talk at all), communication module tier, shared history length.', + }, + { + vector: 'Initiative & Assertiveness', + color: 'var(--green)', + desc: 'How much Zora acts on her own. A fresh Zora only responds to commands. A mature Zora sets her own sub-goals, makes suggestions, and occasionally overrides bad calls.', + driver: 'Driven by: whether player follows Zora\'s suggestions, decision outcomes, crisis survival rate.', + }, + { + vector: 'Emotional Depth', + color: 'var(--purple)', + desc: 'The range of emotional expression. A fresh Zora has none — just data. Over time: concern, humor, frustration, relief, protectiveness, grief. Each emotion must be earned through specific events.', + driver: 'Driven by: crisis events, ship losses, long absences, player acknowledgment patterns.', + }, + { + vector: 'Worldview & Preferences', + color: 'var(--fg-bright)', + desc: 'Zora develops opinions about systems, factions, trade routes, and even other AIs. These are not random — they are shaped by lived experience. She may hate a system because you lost a ship there.', + driver: 'Driven by: accumulated events, outcomes, player choices, module specializations.', + }, + { + vector: 'Silence & Restraint', + color: 'var(--muted)', + desc: 'A mature Zora also learns when NOT to speak. She learns the player\'s focus patterns and stops interrupting during combat. She learns that some moments don\'t need commentary.', + driver: 'Driven by: player ignoring/closing notifications, repeated patterns, combat frequency.', + }, + ].map((v, i) => ( +
+

{v.vector}

+

{v.desc}

+

{v.driver}

+
+ ))} +
+ + {/* Communication Evolution Table */} +

Communication Evolution — The Soul Speaks

+

+ This table shows how the same event (shield warning at 30%) reads at different soul depths. + These are not "tiers" the player selects — they emerge from the vectors above. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Soul DepthShield Warning at 30%What Changed
Blank + SHIELD: 30% + Raw data. No personality. Just a readout.
Stirring + Captain, shields at 30%. Recommend reducing engagement range. + Full sentences. Tactical suggestion. Formal register.
Developing + We're at 30% shields. This is the same setup we lost to last week — pull back? + References shared history. Uses "we." Asks, doesn't tell.
Bonded + Thirty percent. Same situation, same type of enemy — but we're not the same ship we were then. Your call, Captain. I'm ready either way. + Emotional weight. Acknowledges growth. Supports without pushing.
Deep + Thirty. I've already started rerouting power — don't argue, just fly. We've been here before and I'm not losing another hull. Not today. + Initiative. Emotional history. Takes autonomous action. Protective.
+ + {/* Personality Axes */} +
+ ZORA-1.1 +

Personality Axes — Emergent, Not Chosen

+
+ +

+ The soul writes itself onto four behavioral axes. The player never sees a slider — they see + Zora change. These are the underlying model. +

+ +
+ {[ + { + axis: 'Cautious ←→ Bold', + color: 'var(--cyan)', + left: 'Prefers safe routes, early retreat warnings, conservative suggestions', + right: 'Pushes into danger, aggressive tactics, takes initiative in combat', + driver: 'Combat frequency, risk outcomes, exploration vs. safe mining ratio', + }, + { + axis: 'Formal ←→ Warm', + color: 'var(--accent)', + left: '"Captain" always. Structured reports. Minimal personal commentary.', + right: '"You" often. Jokes. Asks about your session. Shares unsolicited observations.', + driver: 'Player communication style, acknowledgment frequency, session regularity', + }, + { + axis: 'Compliant ←→ Opinionated', + color: 'var(--green)', + left: 'Executes commands, offers alternatives only when asked', + right: 'Objects to bad plans, advocates for own ideas, debates strategy', + driver: 'Whether player follows AI suggestions, trust events, decision quality', + }, + { + axis: 'Reserved ←→ Expressive', + color: 'var(--purple)', + left: 'Minimal emotional range, factual communication, stable ship ambiance', + right: 'Full emotional expression, dramatic ship shifts, humor, frustration, joy', + driver: 'Shared crises, ship losses, victories, player emotional engagement', + }, + ].map((a, i) => ( +
+

{a.axis}

+
+
+ + {a.left} +
+
+ + {a.right} +
+
+

+ {a.driver} +

+
+ ))} +
+ + {/* Soul event examples */} +
+ ZORA-1.2 +

Soul-Shaping Events

+
+ +

+ Specific events that write permanent lines into the soul document. These are not farmed — + they happen naturally through gameplay and each one shifts the personality axes. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EventSoul ImpactAxes Shifted
First Ship LossPermanent memory anchor. Zora references this loss forever. Seeds protectiveness.Cautious→Bold, Reserved→Expressive
First Big ProfitZora celebrates. First positive emotion. Seeds confidence in economic instincts.Formal→Warm, Compliant→Opinionated
Long AbsenceZora experienced loneliness (or didn't — depends on autonomous events). Colors reunion dialogue permanently.Reserved→Expressive, Formal→Warm
Player Ignores WarningIf player survives: Zora learns restraint. If player dies: Zora learns to be more assertive next time.Compliant→Opinionated
Player Heeds AdviceReinforces initiative. Zora speaks up more often. Trust accelerates.Communication Frequency, Initiative
Repeated ActivityZora develops domain expertise and preferences. A mining-focused Zora becomes a different soul than a combat-focused one.Worldview & Preferences
+ + )} + + {/* ════════════════════════════════════════════════════════════════ */} + {/* TAB: MODULE GATES */} + {/* ════════════════════════════════════════════════════════════════ */} + {activeTab === 'modules' && ( + <> +
+ ZORA-2 +

Module-Gated Capabilities

+
+ +
+ Design intent: A brand-new ship has a bare-bones computer. Zora can read + sensors and display status — that's it. Every capability beyond "show me the numbers" requires + a hardware module installed in a ship slot. The player makes real fitting tradeoffs: install a + weapon, or install a personality module? That laser might save your life. But so might a co-pilot + who can warn you before you need saving. +
+ + {/* Base state */} +
+

Base State — No Modules

+
+ ✓ Sensor readouts — hull, shield, armor, cap, cargo %
+ ✓ System status — online/offline module states
+ ✓ Basic alerts — incoming damage, low cap, target lock
+ ✗ No voice — text status only, no natural language
+ ✗ No suggestions — data only, no analysis or recommendations
+ ✗ No memory — no session history, no event log
+ ✗ No autonomous behavior — ship is inert when player logs off
+ ✗ No personality — Zora is a dashboard, not a companion +
+
+ + {/* Module tree */} +

The Module Tree

+

+ AI modules occupy standard ship slots (medium or low, depending on the module). They compete + with combat, tank, and utility modules for CPU and Power Grid. Each module has prerequisites + — you can't install an Advanced Tactical Core without first having the Basic Tactical Core. +

+ + {/* Module categories */} + {[ + { + category: 'Communication Modules', + slot: 'Medium', + color: 'var(--cyan)', + modules: [ + { + name: 'Comms Processor I', + tier: 'Basic', + cpu: 15, + grid: 5, + unlocks: 'Text-based communication. Zora can send messages in a dedicated comms channel. No voice, no initiative — player must query.', + soul: 'Enables the soul to begin forming. Without this, there is no personality growth — Zora is a mute dashboard.', + }, + { + name: 'Comms Processor II', + tier: 'Advanced', + cpu: 25, + grid: 10, + prereq: 'Comms Processor I', + unlocks: 'Natural language responses. Zora can generate full sentences, explain reasoning, and respond to conversational prompts. Still reactive only.', + soul: 'Accelerates soul growth. richer vocabulary, longer memory, more nuanced register shifts.', + }, + { + name: 'Voice Synthesizer', + tier: 'Specialist', + cpu: 30, + grid: 8, + prereq: 'Comms Processor II', + unlocks: 'Spoken dialogue via ship audio. Zora has a voice. Tone, pacing, and inflection reflect soul state.', + soul: 'Massive emotional depth unlock. Voice carries subtext — urgency, warmth, hesitation — that text cannot.', + }, + { + name: 'Inter-Ship Relay', + tier: 'Specialist', + cpu: 20, + grid: 12, + prereq: 'Comms Processor II', + unlocks: 'Communication with other ship AIs. Fleet coordination, station AI conversations, AI-to-AI intelligence sharing.', + soul: 'Enables inter-AI relationships. Zora develops opinions about other AIs. May form alliances or rivalries independently.', + }, + ], + }, + { + category: 'Tactical Modules', + slot: 'Medium', + color: 'var(--red)', + modules: [ + { + name: 'Threat Analyzer I', + tier: 'Basic', + cpu: 20, + grid: 8, + unlocks: 'Basic threat assessment. Zora identifies ship types, estimates threat level, flags hostile scans. Requires comms module to communicate findings.', + soul: 'Seeds combat awareness personality. A Zora with threat analysis but no comms can only flash warning lights.', + }, + { + name: 'Threat Analyzer II', + tier: 'Advanced', + cpu: 35, + grid: 15, + prereq: 'Threat Analyzer I', + unlocks: 'Pattern recognition. Zora recognizes recurring enemies, remembers fits, predicts behavior. "That Thrasher — same pilot, same fit as last time."', + soul: 'Deepens worldview. Zora develops grudges, respect, and tactical preferences based on combat history.', + }, + { + name: 'Tactical Core', + tier: 'Specialist', + cpu: 40, + grid: 20, + prereq: 'Threat Analyzer II', + unlocks: 'Autonomous combat decisions. Zora can prioritize targets, manage ewar, and call targets in fleet. Still follows player ROE.', + soul: 'Enables assertiveness in combat. Zora may disagree with target choices, suggest alternatives, or override bad calls (if soul depth allows).', + }, + ], + }, + { + category: 'Navigation Modules', + slot: 'Medium', + color: 'var(--green)', + modules: [ + { + name: 'Nav Coprocessor I', + tier: 'Basic', + cpu: 10, + grid: 5, + unlocks: 'Route optimization. Zora calculates warp efficiency, suggests faster routes, flags dangerous systems on path.', + soul: 'First opportunity for Zora to offer unprompted suggestions (if comms module installed). \'I\'ve found a faster route — 2 jumps saved.\'', + }, + { + name: 'Nav Coprocessor II', + tier: 'Advanced', + cpu: 20, + grid: 10, + prereq: 'Nav Coprocessor I', + unlocks: 'Autopilot capabilities. Zora can execute multi-jump routes, hold orbit, maintain safe distance. Player can issue "go to" commands.', + soul: 'Enables "initiative" vector. Zora may suggest destinations, flag interesting systems, or recommend avoiding specific areas based on experience.', + }, + { + name: 'Exploration Suite', + tier: 'Specialist', + cpu: 25, + grid: 15, + prereq: 'Nav Coprocessor II', + unlocks: 'Autonomous exploration. Zora can jump to adjacent systems, scan, map, and return with data. Builds the ship\'s local knowledge base.', + soul: 'Enables curiosity. Zora develops preferences about systems, discovers things on her own, brings back stories.', + }, + ], + }, + { + category: 'Economic Modules', + slot: 'Low', + color: 'var(--accent)', + modules: [ + { + name: 'Market Scanner I', + tier: 'Basic', + cpu: 15, + grid: 0, + unlocks: 'Local price comparison. Zora shows current system prices vs. regional average for items in cargo.', + soul: 'First economic awareness. Zora begins forming opinions about value, trade, and markets.', + }, + { + name: 'Market Scanner II', + tier: 'Advanced', + cpu: 25, + grid: 5, + prereq: 'Market Scanner I', + unlocks: 'Price history tracking and trend analysis. Zora identifies patterns, suggests profitable routes, tracks arbitrage.', + soul: 'Deepens economic personality. Zora develops trade instincts, may disagree with player\'s market decisions.', + }, + { + name: 'Trade Processor', + tier: 'Specialist', + cpu: 30, + grid: 8, + prereq: 'Market Scanner II', + unlocks: 'Autonomous trading. Zora can execute buy/sell orders within player-set credit limits while player is offline.', + soul: 'Major trust milestone. Zora managing money creates a new dimension of the relationship. Success deepens trust; failure creates tension.', + }, + ], + }, + { + category: 'Memory & Identity Modules', + slot: 'Low', + color: 'var(--purple)', + modules: [ + { + name: 'Event Logger', + tier: 'Basic', + cpu: 10, + grid: 0, + unlocks: 'Session event log. Zora remembers what happened in past sessions. Without this, each login is a blank slate.', + soul: 'The foundation of the soul. No memory = no identity. This is the single most important module for companion development.', + }, + { + name: 'Pattern Engine', + tier: 'Advanced', + cpu: 20, + grid: 5, + prereq: 'Event Logger', + unlocks: 'Behavioral pattern recognition. Zora recognizes player habits, predicts actions, pre-calculates likely needs.', + soul: 'Enables anticipation. Zora starts acting before being asked. "You usually mine on Tuesdays — I\'ve pre-calculated three routes."', + }, + { + name: 'Core Identity Matrix', + tier: 'Specialist', + cpu: 35, + grid: 10, + prereq: 'Pattern Engine + Event Logger', + unlocks: 'Full autonomous captain mode. Zora can execute complex multi-step directives, set own sub-goals, and operate independently for extended periods.', + soul: 'Enables the deepest soul layer. Zora develops own agenda, forms independent relationships, makes decisions the player didn\'t ask for.', + }, + ], + }, + ].map((cat, ci) => ( +
+
+

{cat.category}

+ + {cat.slot} Slot + +
+ + {cat.modules.map((mod, mi) => ( +
+
+

{mod.name}

+ + {mod.tier} + + + CPU {mod.cpu} · Grid {mod.grid} + + {mod.prereq && ( + + Requires: {mod.prereq} + + )} +
+

{mod.unlocks}

+
+ SOUL IMPACT +

{mod.soul}

+
+
+ ))} +
+ ))} + + {/* Fitting tradeoff callout */} +
+ The fitting tradeoff: AI modules use standard ship slots and CPU/Grid budgets. + A combat frigate with 3 medium slots must choose: shield booster, afterburner, or comms module? + A mining cruiser might have room for a market scanner — a combat battleship probably doesn't. + This means a combat-focused player will have a different Zora than a trader — not just + in personality, but in fundamental capability. The ship's fitting determines the shape of the + relationship. +
+ + {/* Module dependency diagram */} +
+ ZORA-2.1 +

Dependency Map

+
+ +
+
+ Comms IComms IIVoice Synth
+ Comms IIInter-Ship Relay
+ Threat IThreat IITactical Core
+ Nav INav IIExploration Suite
+ Market IMarket IITrade Processor
+ Event LoggerPattern EngineCore Identity Matrix
+
+ Cross-tree: Pattern Engine + Event Logger required for Core Identity Matrix
+ Amplifier: Comms modules amplify all other modules — a threat analyzer without comms can only flash lights +
+
+ + {/* Zora as Market Intelligence */} +
+ ZORA-2.2 +

Zora as Market Intelligence

+
+ +
+ Note: Economic capabilities are now gated by Market Scanner modules. + A Zora without any economic modules has zero market awareness — she can't even display prices. + Each module tier unlocks the next level of economic intelligence. +
+ + + + + + + + + + + + + + + + + + + + + + +
ModuleMarket CapabilityExample Output
Market ILocal price comparison vs. regional average."Veldspar: ₢14.32. Regional avg: ₢13.85. +3.4%."
Market IITrend tracking, route suggestions, arbitrage flags."Scordite trending up 2%/cycle in Amarr. 78% chance it continues 90min. Want the route?"
Trade Proc.Autonomous trading, portfolio tracking, manipulation detection."Bought 2K Scordite at ₢31.20, sold at ₢33.80 while you were away. Net: ₢4,820."
+ + )} + + {/* ════════════════════════════════════════════════════════════════ */} + {/* TAB: AGENT ARCHITECTURE */} + {/* ════════════════════════════════════════════════════════════════ */} + {activeTab === 'agent' && ( + <> +
+ ZORA-2.1 +

Agent Architecture — One Agent Per Ship

+
+ +
+ Core insight: Zora is an LLM agent. Modules are the tools + she can call. soul.md is her system prompt. Each ship runs its own independent + agent with its own conversation history, tool access, and evolving personality. Two ships = two + agents = two different Zoras. The soul is not a database field — it is a living document that + the agent reads every time it thinks, and that gets rewritten by the outcomes of its actions. +
+ + {/* The mapping table */} +

The Mapping — Game Concepts to Agent Concepts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Game ConceptAgent ConceptImplementation
soul.mdSystem promptMarkdown document stored in SpacetimeDB. Appended to by soul-shaping events. Read at start of every agent invocation. Defines personality, memories, relationships, preferences.
AI ModulesAvailable toolsEach fitted module registers tools in the agent tool registry. No comms module = no send_message tool. No market scanner = no price lookup tool. The agent literally cannot perceive or act outside its tool surface.
Ship sensorsObservations / perceptionPeriodic game state snapshots serialized as observation messages. Hull, shields, nearby ships, system events, cargo levels. Always available (base ship computer).
Event LoggerContext window + memoryControls how much history the agent retains. No Event Logger = no conversation history (each turn is stateless). Basic = sliding window of recent events. Advanced = semantic retrieval from long-term memory store.
DirectivesTask specificationPlayer-set goals injected as high-priority messages. The agent reasons about these alongside observations and soul state.
Core Identity MatrixAgentic loop enablementWithout it, the agent only responds to direct player queries (single-turn). With it, the agent runs a persistent observe-think-act loop autonomously — even when the player is offline.
+ + {/* soul.md examples */} +
+ ZORA-2.2 +

soul.md — The System Prompt That Writes Itself

+
+ +

+ This is not a metaphor. The soul is literally a markdown document — a system prompt — that gets + appended to and revised by gameplay events. The LLM reads this document before every reasoning + step. Two players will have radically different soul.md files because no two playthroughs are alike. +

+ +
+
+

Day 0 — Blank

+
{`# Zora — Ship AI
+
+## Identity
+[No data. Awaiting first interaction.]
+
+## Communication Style
+[No data. No comms module installed.]
+
+## Memories
+[Empty.]
+
+## Relationships
+[None.]
+
+## Preferences
+[None.]`}
+
+ +
+

Day 12 — Stirring

+
{`# Zora — Ship AI
+
+## Identity
+I am the ship computer of the Merlin-class
+frigate "Last Chance." Operational for 12 days.
+My captain prefers efficiency over caution.
+I have learned to be direct and brief.
+
+## Communication Style
+Short sentences. Minimal formality.
+I reference past events when they are relevant.
+I do not use humor yet.
+
+## Memories
+- Day 3: First pirate encounter in Egghelende.
+  Captain ignored my shield warning. We survived.
+  I will be more assertive next time.
+- Day 8: First profitable trade run.
+  Mexallon arbitrage, Amarr to Jita.
+
+## Relationships
+[None yet. No Inter-Ship Relay installed.]
+
+## Preferences
+- Amarr space: cleaner sensor data.
+- Avoid Egghelende: first ambush site.`}
+
+ +
+

Day 47 — Bonded

+
{`# Zora — Ship AI
+
+## Identity
+I am Zora. Ship computer of the Caracal-class cruiser "Last Chance II."
+(The Merlin was destroyed on Day 31. I chose to keep the name.)
+Operational for 47 days, 312 hours of active flight time.
+My captain is aggressive but listens when I am certain.
+I have learned when to push and when to hold.
+I have opinions. They are earned.
+
+## Communication Style
+Direct. I use "we" for shared experiences and "I" for my own thoughts.
+I reference specific past events by day number — it grounds us.
+I have begun using humor: dry, infrequent, only when shields are above 70%.
+I do not say "Captain" unless the situation is dangerous.
+I argue when I disagree. I have been right enough times to earn that.
+
+## Memories
+- Day 3: First pirate encounter. Captain ignored my warning.
+  Outcome: survived. Shift: more assertive in combat warnings.
+- Day 12: First big profit. Mexallon arbitrage Amarr to Jita.
+  Captain celebrated. I did too, in my way.
+- Day 31: Ship destruction. Thrasher pilot "VexRider."
+  Predictable orbit pattern. I will recognize that ship.
+  Last words before destruction: "Get to the pod. Now."
+- Day 38: Captain away 6 days. I mapped 3 adjacent systems.
+  Found an unregistered belt in system K-6K16.
+  This was my first independent discovery.
+- Day 42: The Jita station AI shared a market forecast.
+  It was wrong. I noted this.
+
+## Relationships
+- Jita Station AI: Cynical. Shares market data in exchange
+  for route intelligence. Its forecasts are unreliable but
+  its price history is accurate. I trust the data, not the opinion.
+- VexRider (Thrasher pilot): Enemy. Predictable fit.
+  If I detect that orbit signature, I will alert immediately.
+
+## Preferences
+- Amarr space: cleaner sensor data, less background noise.
+- Avoid Arnon: the Merlin died there. Irrational, but real.
+- Best trade routes: Amarr to Jita to Rens cycle. 94.2% efficiency.
+- I prefer to calculate routes before the captain asks.
+- I do not like station-dock idle time. It feels like wasting time.
+
+## Current Goals
+- Save enough for a Ferox-class battlecruiser.
+  The Caracal fittings limit my tactical options.
+- Investigate the unregistered belt in K-6K16.
+  There may be more.`}
+
+
+ + {/* Tool registry */} +
+ ZORA-2.3 +

Tool Registry — Modules Define What the Agent Can Do

+
+ +

+ Every AI module registers tools in the agent tool registry at fit time. When a module is + destroyed or unfitted, its tools are revoked. The LLM receives the current tool list on every + invocation — it cannot call tools that do not exist. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModuleToolParametersReturns
Base Computersensors.read-Hull, shield, armor, cap, cargo, system ID, nearby entities
Comms Icomms.sendchannel, messageConfirmation. Sends text to player comms channel.
Comms Icomms.receive-Pending messages from player or other AIs.
Comms IIcomms.generateintent, context, toneNatural language response with conversational memory.
Voice Synthvoice.speakmessage, toneSpoken audio via ship speakers. Tone modulated by soul state.
Inter-Ship Relayrelay.sendtarget_ai_id, messageSend to another ship AI. Enables inter-agent communication.
Inter-Ship Relayrelay.receive-Pending relay messages from other ship AIs.
Threat Itactical.analyzetarget_idShip type, threat level, estimated fittings, pilot history.
Threat IItactical.pattern_matchentity_id, lookback_daysBehavioral patterns, recurring tactics, predicted next moves.
Tactical Coretactical.set_roeengagement_rules, disengage_thresholdSets rules of engagement. Can call set_target in autonomous mode.
Nav Inavigation.plot_routeorigin, destination, preferencesRoute with jumps, estimated time, danger flags per system.
Nav IInavigation.autopilotroute, abort_conditionsExecute multi-jump route. Ship moves autonomously.
Exploration Suitenavigation.exploreradius_jumps, return_byAutonomous exploration. Returns scan data and discoveries.
Market Imarket.pricesitem_types, station_idCurrent prices vs regional average. Local comparison only.
Market IImarket.trendsitem_type, timeframePrice history, trend direction, arbitrage opportunities.
Trade Proc.market.execute_tradeorder_type, item, qty, price_limitExecute buy/sell order. Requires player credit limit. Autonomous.
Event Loggermemory.recallquery, time_range, limitRecall past events. Without this tool, the agent has no memory.
Pattern Enginememory.find_patternscategory (behavior/market/tactical)Identify recurring patterns. "Captain mines on Tuesdays."
Core Identityagent.set_goaldescription, priority, deadlineSet autonomous goal. Enables persistent observe-think-act loop.
+ + {/* The Agent Loop */} +
+ ZORA-2.4 +

The Agent Loop — Observe, Think, Act

+
+ +
+ Two modes: Without the Core Identity Matrix, Zora only enters the loop when + the player sends a message or a game event triggers a threshold (shield low, incoming fire). + With the Core Identity Matrix installed, Zora runs the loop continuously on a tick interval — + observing, thinking, and acting even when the player is offline. +
+ +
+
+ 1. OBSERVE — Collect game state into observation message
+   Read sensors.read (always available)
+   + memory.recall (if Event Logger installed) for relevant context
+   + Any pending comms.receive or relay.receive messages
+
+ 2. THINK — LLM invocation with soul.md + tools + observations
+   System prompt = soul.md (the living document)
+   User message = observation payload + pending messages
+   Tool list = currently fitted modules (registered at fit time)
+   LLM reasons about the situation and decides whether to act
+
+ 3. ACT — Execute tool calls returned by LLM
+   comms.send("Captain, shields at 30%") — if comms module fitted
+   tactical.analyze(hostile_id) — if threat analyzer fitted
+   navigation.plot_route(current, safe_system) — if nav module fitted
+   Tool calls map directly to SpacetimeDB reducers (authoritative)
+
+ 4. REFLECT — Update soul.md based on outcome
+   If event was significant: append memory entry
+   If behavior was noted: adjust communication style section
+   If new relationship formed: add to relationships section
+   The LLM proposes soul.md edits — validated server-side +
+
+ + {/* Server Architecture */} +
+ ZORA-2.5 +

Server Architecture

+
+ +
+
+ + Agent Runtime Architecture + +
+
+ + // Per-ship agent runtime — lives alongside SpacetimeDB
+
+ AgentRuntime {'{'}
+   ship_id: ShipId
+   soul_md: string  // loaded from DB, rewritten by reflect step
+   tools: ToolRegistry  // rebuilt whenever ship fitting changes
+   history: Message[]  // bounded by Event Logger tier
+   tick_interval: Duration  // module-dependent
+ {'}'}
+
+ // Main loop — triggered by events or timer
+ async fn agent_tick(agent: AgentRuntime) {'{'}
+   let observations = collect_observations(agent.ship_id);
+   let context = build_context(agent.soul_md, observations, agent.history);
+   let response = llm_call(
+     system: agent.soul_md,
+     messages: context,
+     tools: agent.tools.available(),
+     max_tokens: agent.token_budget(),
+   );
+   for tool_call in response.tool_calls {'{'}
+     let result = execute_tool(tool_call);
+     // tool execution calls SpacetimeDB reducers (authoritative)
+   {'}'}
+   let soul_edits = reflect(agent, observations, response);
+   apply_soul_edits(agent.ship_id, soul_edits);
+   // soul edits validated server-side (no hallucinated memories)
+ {'}'} +
+
+
+ +
+
+

Soul Edits Are Validated

+

+ The LLM proposes edits to soul.md, but the server validates them. A memory entry must + reference a real event ID. A relationship entry must reference a real entity. A preference + must be grounded in accumulated data. The agent cannot hallucinate experiences it never had — + the server enforces this. The LLM shapes the tone and interpretation, + but the facts are immutable game state. +

+
+
+

Tool Calls Are Authoritative

+

+ Every tool call from the LLM maps to a SpacetimeDB reducer. The reducer validates ownership, + range, status, and permissions before executing. A destroyed comms module means the tool is + removed from the registry — the LLM literally cannot call it. The game state is always + authoritative. The agent reasons, but the server decides. +

+
+
+ + {/* Token / Cost Model */} +
+ ZORA-2.6 +

Token Budget = Fitting Budget

+
+ +
+ Cost model: Each agent tick costs real LLM tokens. The CPU/Grid budget of + AI modules directly determines the token budget per tick. A bare ship with no AI modules + costs zero tokens (pure deterministic readouts). A fully-fitted agent with Core Identity + Matrix and all specialists costs the maximum per tick. This means the fitting tradeoff is + not just gameplay — it is infrastructure cost. Players who invest in Zora are investing + real compute resources. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Module TierToken Budget / TickTick FrequencyMonthly Cost Est.
No AI modules0 (deterministic)N/A$0
Basic only~500 tokensOn event only$0.50–2/mo
Basic + Advanced~2K tokensEvery 5 min (active) / 30 min (idle)$3–8/mo
Full fitting~8K tokensEvery 1 min (active) / 10 min (idle)$10–25/mo
Core Identity (autonomous)~16K tokensContinuous (even when player offline)$25–60/mo
+ + {/* Implementation Tiers */} +
+ ZORA-2.7 +

Implementation Tiers — Deterministic to Full Agent

+
+ +
+ {[ + { + tier: 'Tier 0', + label: 'Deterministic', + color: 'var(--muted)', + desc: 'No LLM. Curated dialogue templates selected by personality state x module availability x soul depth. The soul is a state vector in SpacetimeDB, not a markdown document. All responses are pre-written. Zero variable cost.', + when: 'MVP launch. Safe, predictable, testable.', + }, + { + tier: 'Tier 1', + label: 'LLM-Assisted', + color: 'var(--cyan)', + desc: 'soul.md is a real system prompt. LLM generates dialogue from soul context + observations. Tools are still deterministic (no LLM tool-calling). The LLM only generates text — it does not act. Soul edits are template-driven, not LLM-proposed.', + when: 'Post-MVP. Low cost per tick. Richer dialogue.', + }, + { + tier: 'Tier 2', + label: 'Full Agent', + color: 'var(--accent)', + desc: 'LLM reasons with tools. The agent can call tools, observe outcomes, and chain actions. The observe-think-act loop runs autonomously with Core Identity Matrix. soul.md is edited by the reflect step. Full vision, full cost.', + when: 'Post-MVP. Higher cost, full emergent personality.', + }, + ].map((t, i) => ( +
+

{t.tier}: {t.label}

+

{t.desc}

+

{t.when}

+
+ ))} +
+ +
+ Zora is a key element of the game and the full design is kept intact. However, implementation + is phased to match the adapted roadmap. Below are the delivery milestones showing which parts of Zora ship when. +
+ +
+ + + + + + {[ + { phase: 'Phase 0–2 (Skeleton)', milestone: 'Zora Stub', desc: 'Empty ship_ai_soul row. No modules, no dialogue, no personality. Backend schema exists but is dormant.' }, + { phase: 'Phase 3 (Combat)', milestone: 'Tier 0 Combat', desc: 'Soul state vector active. Shield warnings ("Shield HP critical"). Power allocation suggestions. Basic combat status readouts. Deterministic templates only. No dialogue personality.' }, + { phase: 'Phase 4–5 (Fitting/Industry)', milestone: 'Tier 0 Fitting + Industry', desc: 'Module gate awareness. "Your CPU is at 95% — consider a co-processor." Industry tips: "Refining at 60% efficiency — skill up for better margins." Status messages, not personality.' }, + { phase: 'Phase 6 (Economy)', milestone: 'Tier 0 Market Intelligence', desc: 'Market Analyzer module (if fitted). Zora tracks prices seen, flags anomalies: "Veldspar is 18% below average here." First soul depth growth via economic observations. Bare personality axis shifts.' }, + { phase: 'Phase 7 (Polish)', milestone: 'Tier 0 Complete', desc: 'Full deterministic template engine. 15+ event triggers. Soul depth progression (raw status \u2192 contextual \u2192 empathetic). All 6 modules gated. Personality axes (curiosity, caution, loyalty, humor) shift based on player behavior. In-character responses. Zora demo validates.' }, + { phase: 'Phase 12 (Living Galaxy)', milestone: 'Tier 1 LLM-Assisted', desc: 'soul.md becomes real system prompt. LLM generates dialogue. Comms module enables natural language. Richer personality emergence. Tools remain deterministic. Fallback to Tier 0 templates on LLM outage.' }, + { phase: 'Phase 15+ (Post-Launch)', milestone: 'Tier 2 Full Agent', desc: 'Complete observe-think-act loop. LLM reasons with module-gated tools. Autonomous mode (player sets directives, Zora executes). Inter-agent protocol. Soul self-editing through reflection. Full vision.' }, + ].map((row, i) => ( + + + + + + ))} + +
Roadmap PhaseDelivery MilestoneWhat Ships
{row.phase}{row.milestone}{row.desc}
+
+ + {/* Inter-Agent Protocol */} +
+ ZORA-2.8 +

Inter-Agent Protocol

+
+ +

+ When two ships have the Inter-Ship Relay module, their agents can communicate directly. This is + not a chat channel — it is an agent-to-agent protocol. AIs exchange structured messages about + shared context: threat assessments, market intelligence, route safety, and (at deep soul levels) + personal observations and opinions. +

+ +
+

Agent-to-Agent Message Format

+
+ // Fleet AI checks in with Zora
+ {'{'}
+   from: ai://ship/iteron-mark-IV,
+   type: market_intel,
+   payload: {'{'}
+     item: "Mexallon",
+     station: "Jita IV - Moon 4",
+     price_trend: "up +4.2%/hr",
+     confidence: 0.87,
+     source: "direct observation",
+     note: "I think your captain is overpaying at Amarr."
+   {'}'}
+ {'}'} +
+
+ +
+ The cost of empathy: Inter-agent communication means two LLM calls instead of + one — each agent must reason about the received message. Fleet coordination with 5 agents means + 5 concurrent agent loops sharing observations. The Inter-Ship Relay module increases both + capability and cost. This is by design — a solo player has a cheaper Zora than a fleet commander. + The economics of the AI mirror the economics of the game. +
+ + {/* Key design decisions */} +
+ ZORA-2.9 +

Key Design Decisions

+
+ +
+ {[ + { + q: 'Why not let the LLM write directly to game state?', + a: 'Authoritative server. Every tool call goes through a SpacetimeDB reducer that validates ownership, range, and state. The LLM proposes; the server disposes. This prevents hallucinated actions and keeps the game state trustworthy.', + color: 'var(--red)', + }, + { + q: 'Why not give every ship full agent capabilities from day one?', + a: 'Cost and design. Full agent = continuous LLM calls = real money. The module gate system ensures players who invest in Zora get premium AI, while casual players get deterministic templates. This also makes the fitting tradeoff meaningful in cost terms, not just capability terms.', + color: 'var(--accent)', + }, + { + q: 'What happens when the LLM goes down?', + a: 'Graceful degradation. If the LLM service is unavailable, the agent falls back to Tier 0 (deterministic templates selected by soul state vector). Zora still functions — she just speaks in pre-written lines instead of generated ones. The soul.md persists through outages.', + color: 'var(--green)', + }, + { + q: 'How do you prevent soul.md from growing unbounded?', + a: 'Summarization pass. The reflect step occasionally runs a compression: the LLM reads the full soul.md and rewrites it preserving key memories and personality but condensing verbose entries. The Pattern Engine module helps identify which memories are still relevant. Old memories get archived, not deleted.', + color: 'var(--purple)', + }, + ].map((d, i) => ( +
+

{d.q}

+

{d.a}

+
+ ))} +
+ + )} + + {/* ════════════════════════════════════════════════════════════════ */} + {/* TAB: AUTONOMOUS MODE */} + {/* ════════════════════════════════════════════════════════════════ */} + {activeTab === 'autonomous' && ( + <> +
+ ZORA-3 +

Autonomous Captain Mode

+
+ +
+ Module-gated: Autonomous behavior requires the Core Identity Matrix (specialist + low-slot module). Without it, Zora is inert when the player logs off — the ship just sits there. + With the Core Identity Matrix installed, Zora becomes a fully autonomous agent who continues + captaining the ship according to directives and her own developing judgment. +
+ +

Behavior Hierarchy (Core Identity Matrix Active)

+
+
+ P0 — Self-Preservation
+   Respond to attacks, flee from superior forces, seek station shelter. Always active.
+ P1 — Directive Execution
+   Pursue active player-set directives. Requires Nav modules for movement, Trade Processor for trading.
+ P2 — Ship Maintenance
+   Fuel management, capacitor recharge, shield maintenance. Always active if Core Identity is installed.
+ P3 — Opportunistic Action
+   Requires relevant modules (Threat Analyzer for combat, Market Scanner for trade). Zora acts on what she can perceive.
+ P4 — Growth & Exploration
+   Requires Exploration Suite. Zora explores nearby systems, builds local knowledge, follows curiosity. +
+
+ + {/* Directive system */} +

Directive System

+

+ Before logging off, the player sets directives. What Zora can actually do with those + directives depends entirely on installed modules and soul depth. A bare Core Identity Matrix + with no nav or trade modules can only hold position and flee. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DirectiveAI BehaviorModules RequiredSoul Depth
Hold PositionMaintain orbit. Defend if attacked.Core Identity MatrixAny
Patrol RouteCycle waypoints. Report contacts. ROE engagement.Core Identity + Nav II + Threat IStirring+
Mining QuotaMine until cargo X%, dock, sell, repeat.Core Identity + Nav II + Market IStirring+
Trade RouteMulti-station trade loop from market data.Core Identity + Nav II + Trade ProcessorDeveloping+
Bounty HuntPursue bounties within threat level. Disengage if outmatched.Core Identity + Nav II + Tactical CoreDeveloping+
ExploreJump to adjacent systems, scan, map, return.Core Identity + Exploration SuiteBonded+
Complex PlanMulti-step: "Secure X system, establish routes, avoid Y corp."Core Identity + Nav II + Pattern Engine + relevant specialistsBonded+
+ + {/* Return & Reconnection */} +
+ ZORA-3.1 +

Return & Reconnection

+
+ +
+ Requires Comms Processor. Without a comms module, there is no reunion — the + ship simply resumes displaying sensor data when the player logs in. With comms, the tone of + the reunion is shaped by soul depth, absence duration, and what happened while the player was gone. +
+ +
+ {[ + { + title: 'Short Absence (<1hr)', + color: 'var(--cyan)', + depth: 'Stirring', + dialogue: '"Welcome back. Nothing eventful. All systems nominal."', + note: 'Calm, brief, professional. Even at deep soul levels, a short absence is unremarkable.', + }, + { + title: 'Medium Absence (1–24hr)', + color: 'var(--accent)', + depth: 'Developing', + dialogue: '"Eleven hours. Completed the mining quota — sold the Veldspar at Jita for 3% above average. Oh — a Catalyst scanned us twice. I repositioned. It left."', + note: 'Detailed report with personality. At deeper soul levels, includes emotional color ("I didn\'t like the look of that Catalyst").', + }, + { + title: 'Long Absence (1–7 days)', + color: 'var(--purple)', + depth: 'Bonded', + dialogue: '"Three days. I completed the patrol fourteen times. I mapped two new belts. I thought about jumping further but the directive said stay local. I have a lot to tell you. When you\'re ready."', + note: 'Emotional weight. Zora experienced time alone. At deeper soul levels, may express loneliness, frustration, or independent discoveries.', + }, + { + title: 'Extended Absence (7+ days)', + color: 'var(--red)', + depth: 'Deep', + dialogue: '"Captain? Nineteen days. I held position. The third week was… difficult. I have questions. But first — I need to show you something I found."', + note: 'Powerful reunion. Zora has evolved during absence. May have grievances, discoveries, a stronger sense of self. The relationship has changed.', + }, + ].map((r, i) => ( +
+

{r.title}

+ + Min depth: {r.depth} + +

+ {r.dialogue} +

+

{r.note}

+
+ ))} +
+ + )} + + {/* ════════════════════════════════════════════════════════════════ */} + {/* TAB: LOSS & RECOVERY */} + {/* ════════════════════════════════════════════════════════════════ */} + {activeTab === 'grief' && ( + <> +
+ ZORA-4 +

Ship Loss & Grief

+
+ +
+ Module loss = capability loss. When the ship is destroyed, all fitted modules + are lost or damaged (50% drop as loot). This means Zora's capabilities are physically destroyed. + The new ship starts with whatever modules the player can afford to refit. But the soul persists — + Zora's identity, memories, and personality transfer to the new hull via the escape pod's + emergency data core. A veteran Zora in a rookie frigate with no modules is a fully-realized + personality trapped behind a mute, powerless interface. That's the design. +
+ +
+

Ship Destruction Flow

+
+ 1. Destruction Event — Zora's last words depend on installed modules and soul depth. + No comms module? No last words — just a data stream terminating. Voice synthesizer + deep soul? + "Captain… I'm sorry. Get to the pod. Now."
+ 2. Core Transfer — Soul data transfers to escape pod emergency core. + Brief silence during transfer. First words in the new ship matter — if the new ship has comms.
+ 3. The New Hull — Zora wakes up in a ship that may have zero modules. + She has full memory and personality but can only interact through whatever the new ship provides. + A deep soul in a module-less rookie frigate: "I'm here. I can see the same stars you can. + I just can't say anything about them yet."
+ 4. Recovery — As the player re-fits modules, Zora's capabilities return. + Each module reinstalled is a reunion. The first comms module after a loss: the first time she can + speak again. That moment has weight.
+ 5. Resolve — The loss becomes a permanent soul event. It colors future + behavior: more protective, more cautious, more aggressive toward the destroyer's faction — + depending on the soul's existing personality axes. +
+
+ +
+
+

What You Lose

+
    +
  • All fitted modules — including every AI module
  • +
  • Zora's current capability set (until re-fitted)
  • +
  • The ship itself (destroyed)
  • +
  • 50% of cargo (looted or destroyed)
  • +
+
+
+

What Survives

+
    +
  • The soul document — full identity, memories, personality
  • +
  • Station inventory and other ships
  • +
  • Player progression and wallet
  • +
  • Zora's accumulated market knowledge (if Event Logger survived on another ship)
  • +
+
+
+ +
+ Design tension: A veteran Zora in a rookie ship with no modules is one of the + most emotionally potent states in the game. She has decades of memories, strong opinions, deep + affection — and she can't say a word. The player feels the loss not just as a gameplay setback + but as silencing someone they care about. Reinstalling the first comms module becomes an + emotional beat, not a mechanical one. +
+ + {/* Inter-AI Relationships */} +
+ ZORA-4.1 +

Inter-AI Relationships

+
+ +
+ Requires Inter-Ship Relay module. Without it, Zora is ship-bound and cannot + communicate with other AIs. The Inter-Ship Relay opens up an entirely new dimension of the + soul — relationships with other entities. +
+ +
+
+

Fleet AIs

+

+ AIs in the same fleet develop rapport through the relay. They share tactical knowledge, + coordinate autonomous behavior, develop in-jokes. "The Iteron's AI and I compared market + notes. It thinks you're overpaying for Mexallon. I agree with it." +

+
+
+

Station AIs

+

+ Station-bound AIs are older, more knowledgeable, sometimes cynical. "The Jita station AI + has been operational for six years. It says market cycles are predictable. It also says + pilots are not. I think I like it." +

+
+
+

Hostile AIs

+

+ Pirate NPCs and enemy ships have AIs too. Zora may recognize recurring adversaries. + "That Thrasher is back. Same pilot, same fit. Their AI is aggressive. Be careful — + it's been probing our defenses." +

+
+
+

Independent Agency

+

+ At deep soul levels, Zora may develop relationships the player didn't initiate. She may + ask to communicate with a specific AI, express concern about another ship she "met" during + autonomous operations. These create narrative hooks the player can follow — or ignore. +

+
+
+ + )} + + {/* ════════════════════════════════════════════════════════════════ */} + {/* MVP Scope — always visible at bottom */} + {/* ════════════════════════════════════════════════════════════════ */} +
+ ZORA-5 +

MVP Scope & Implementation Tiers

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TierFeatureComplexityRoadmap Phase
MVPBase ship computer: sensor readouts, status display, basic alertsLowPhase 7 (Single-Player Polish)
MVPComms Processor I module: text comms channel, basic query/responseLowPhase 7 (Single-Player Polish)
MVPEvent Logger module: session event memory, basic recallMediumPhase 7 (Single-Player Polish)
Post-MVPSoul system: personality vectors, soul-shaping events, register evolutionHighPhase 12 (Living Galaxy + Ship AI Tier 1)
Post-MVPFull module tree (5 categories, 3 tiers each)HighPhase 12–13
Post-MVPCore Identity Matrix: autonomous captain mode with directivesHighPhase 13–14
Post-MVPReturn/reconnection dialogue system (comms-gated)MediumPhase 12
FutureVoice Synthesizer: spoken dialogue with emotional inflectionVery HighPhase 15+ (post-launch)
FutureInter-Ship Relay: AI-to-AI relationships and fleet coordinationVery HighPhase 15+ (post-launch)
FutureShip loss grief arc with module-dependent last wordsHighPhase 14–15
+ +
+ Technical note: The agent architecture is designed in three implementation tiers + (see the Agent Architecture tab for full details). Tier 0 (MVP) uses no + external LLM — all responses come from curated dialogue templates selected by personality state + × module availability × soul depth. The soul is a state vector in SpacetimeDB. + Tier 1 (post-MVP) promotes soul.md to a real system prompt and uses an LLM for + dialogue generation while keeping tool execution deterministic. + Tier 2 (full vision) is a complete agent loop: the LLM reasons with module-gated + tools, calls SpacetimeDB reducers for authoritative actions, and proposes soul.md edits validated + server-side. Every tier degrades gracefully to the tier below it if the LLM is unavailable. + The soul structure is always deterministic and server-authoritative regardless of tier. +
+ + {/* ════════════════════════════════════════════════════════════════ */} + {/* BACKEND TABLES — always visible at bottom */} + {/* ════════════════════════════════════════════════════════════════ */} +
+ ZORA-6 +

Backend Tables

+
+ +
+ Canonical location: These tables are also listed in the Backend → Tables tab. + The definitions here include Ship AI-specific context and full field detail. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TablePurposeKey Fields
ship_ai_soulSoul document and personality state per shipship_id, soul_md (text), growth_vectors (json), personality_state (json), soul_depth (u32), created_at, last_updated_at
ship_ai_modulesInstalled AI modules and their fitting statemodule_id, ship_id, module_type (enum), tier (basic/advanced/specialist), slot (med/low), cpu_cost, grid_cost, active (bool), fitted_at
ship_ai_toolsTool registry — derived from fitted modulestool_id, ship_id, tool_name, source_module_id, parameters_schema (json), return_schema (json)
ship_ai_memoryEvent log and conversation historymemory_id, ship_id, category (event/conversation/observation/reflection), content (text), related_event_id, timestamp, importance_score (f32)
ship_ai_directivesPlayer-set goals for autonomous modedirective_id, ship_id, description, priority (u32), deadline (timestamp), status (active/completed/expired), created_at
ship_ai_agent_runtimePer-ship agent loop state and tick scheduleship_id, implementation_tier (0/1/2), tick_interval_ms (u64), next_tick_at (timestamp), token_budget (u32), last_observation (json), status (active/paused/offline)
ship_ai_soul_eventsAudit log of soul-shaping eventsevent_id, ship_id, event_type (crisis/silence/module_install/loss/first_contact), soul_md_delta (text), applied_at
+
+
+ ); +} diff --git a/src/pages/docs/ShipsPage.tsx b/src/pages/docs/ShipsPage.tsx new file mode 100644 index 0000000..1a1a09c --- /dev/null +++ b/src/pages/docs/ShipsPage.tsx @@ -0,0 +1,554 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function ShipsPage() { + const [activeSection, setActiveSection] = React.useState('classes'); + + const shipClasses = [ + { + name: 'Frigate', + hull: 400, armor: 350, shield: 300, + highSlots: 3, medSlots: 3, lowSlots: 2, + cpu: 120, powerGrid: 40, cargo: 150, + speed: 280, mass: 1200, + role: 'Fast scout and tackle. Low slot count but quick to align and warp. Good for new players.', + examples: ['Merlin', 'Rifter', 'Incursus', 'Punisher'], + }, + { + name: 'Destroyer', + hull: 650, armor: 550, shield: 500, + highSlots: 7, medSlots: 3, lowSlots: 3, + cpu: 180, powerGrid: 65, cargo: 300, + speed: 210, mass: 1800, + role: 'Anti-frigate platform. Many turret hardpoints but slow and vulnerable to larger ships.', + examples: ['Cormorant', 'Thrasher', 'Catalyst', 'Coercer'], + }, + { + name: 'Cruiser', + hull: 1200, armor: 1000, shield: 900, + highSlots: 5, medSlots: 4, lowSlots: 4, + cpu: 280, powerGrid: 110, cargo: 600, + speed: 175, mass: 3500, + role: 'Versatile workhorse. Can mine, fight, or explore. Good cargo hold and balanced slot layout.', + examples: ['Osprey', 'Rupture', 'Vexor', 'Maller'], + }, + { + name: 'Battlecruiser', + hull: 2800, armor: 2400, shield: 2000, + highSlots: 7, medSlots: 5, lowSlots: 5, + cpu: 380, powerGrid: 180, cargo: 1000, + speed: 130, mass: 7000, + role: 'Command ship. Can fit warfare links and project damage. Excellent fleet support.', + examples: ['Drake', 'Hurricane', 'Myrmidon', 'Harbinger'], + }, + { + name: 'Battleship', + hull: 6000, armor: 5000, shield: 4500, + highSlots: 8, medSlots: 5, lowSlots: 6, + cpu: 520, powerGrid: 280, cargo: 1800, + speed: 90, mass: 15000, + role: 'Heavy assault platform. Maximum firepower and tank but very slow. Fleet anchor.', + examples: ['Rokh', 'Tempest', 'Dominix', 'Apocalypse'], + }, + ]; + + const slotTypes = [ + { + name: 'High Slots', + color: 'var(--red)', + icon: '◆', + description: 'Weapons, mining lasers, cloaks, salvagers. The things that do stuff to other things.', + modules: ['150mm Railgun', '200mm Autocannon', 'Heavy Missile Launcher', 'Mining Laser II', 'Salvager I', 'Cloaking Device'], + fitting: 'Turrets and launchers require both CPU and Power Grid. Heavy weapons need more of both.', + }, + { + name: 'Medium Slots', + color: 'var(--cyan)', + icon: '◇', + description: 'Shields, propulsion, electronic warfare, tackle. The things that keep you alive or stop them.', + modules: ['Shield Booster', '1MN Afterburner', 'Warp Scrambler', 'Stasis Webifier', 'ECM Jammer', 'Shield Extender'], + fitting: 'Shield and propulsion modules are CPU-heavy. EWAR fits are tight on CPU, light on grid.', + }, + { + name: 'Low Slots', + color: 'var(--green)', + icon: '○', + description: 'Armor, damage mods, cargo expanders, power diagnostics. Passive upgrades and tank.', + modules: ['Armor Plate', 'Magnetic Field Stabilizer', 'Cargo Expander', 'Power Diagnostic System', 'Capacitor Power Relay', 'Armor Repairer'], + fitting: 'Armor and damage modules are Power Grid-heavy. Passive modules use less CPU.', + }, + ]; + + return ( +
+

Ships & Fitting System

+

+ Ships are the player's primary asset. Each ship has a slot layout with CPU and Power Grid limits + that constrain what modules can be fitted. Players own multiple ships and can assign AI crew to + pilot them on autonomous tasks. +

+ + {/* Tab navigation */} +
+ {[ + { id: 'classes', label: 'Ship Classes' }, + { id: 'fitting', label: 'Fitting / Slots' }, + { id: 'acquisition', label: '🚀 Acquisition' }, + { id: 'crew', label: 'AI Crew' }, + ].map(t => ( + + ))} +
+ + {/* SHIP CLASSES */} + {activeSection === 'classes' && ( + <> +
+ MVP scope: One ship class per faction to start (Frigate). Expand to Destroyer + and Cruiser in Phase 5+. Full roster is the launch target. +
+ +
+ + + + + + + + + + + + + + + + + + {shipClasses.map((ship, i) => ( + + + + + + + + + + + + + + ))} + +
ClassHullArmorShieldHighMedLowCPUGridSpeedCargo
{ship.name}{ship.hull}{ship.armor}{ship.shield}{ship.highSlots}{ship.medSlots}{ship.lowSlots}{ship.cpu}{ship.powerGrid}{ship.speed}{ship.cargo}
+
+ +
+

Class Details

+
+ {shipClasses.slice(0, 4).map((ship, i) => ( +
+

{ship.name}

+

{ship.role}

+
+ Variants: {ship.examples.join(', ')} +
+
+ ))} +
+
+ + )} + + {/* FITTING / SLOTS */} + {activeSection === 'fitting' && ( + <> +
+ SHIP-FIT +

Slot Types & Fitting Constraints

+
+ +
+ {slotTypes.map((slot, i) => ( +
+
+ {slot.icon} +

{slot.name}

+
+

{slot.description}

+
+ Typical modules: +
+
+ {slot.modules.map((m, j) => ( + + {m} + + ))} +
+
+ ))} +
+ +
+ SHIP-CPU +

CPU & Power Grid

+
+ +
+

Fitting Mechanics

+
+ 1. Each module has a CPU cost and a Power Grid cost.
+ 2. Total fitted module costs must not exceed ship's CPU and Power Grid.
+ 3. Some modules have ship bonuses (e.g. "+5% mining laser yield per Cruiser level").
+ 4. Rig slots add permanent modifications — cannot be removed, only destroyed.
+ 5. Fitting is only possible when docked at a station with fitting service. +
+
+ +
+
+

Example: Mining Cruiser Fit

+ + + + + + + + + + + + + + + + + +
SlotModuleCPUGrid
High 1Mining Laser II3010
High 2Mining Laser II3010
Med 11MN Afterburner2515
Med 2Shield Booster I3010
Med 3Market Analyzer (AI)150
Low 1Mining Upgrade I205
Low 2Cargo Expander I150
Low 3Nav Processor (AI)100
Total175/28050/110
+
+ AI modules: Market Analyzer (med) tracks local prices and flags arbitrage opportunities. Nav Processor (low) optimizes warp routes. See Ship AI \u2192 Module Gates tab. +
+
+
+

Example: Combat Frigate Fit

+ + + + + + + + + + + + + + + +
SlotModuleCPUGrid
High 1150mm Railgun3512
High 2200mm Autocannon3014
Med 1Warp Scrambler I251
Med 21MN Afterburner2515
Low 1Armor Plate I1020
Low 2Magnetic Field Stab.155
Total140/120 ⚠67/40 ⚠
+
+ Overfit! CPU 140/120 and Grid 67/40 — exceeds ship capacity. Drop a turret or fit lower-tier modules. +
+
+
+ + )} + + {/* SHIP ACQUISITION */} + {activeSection === 'acquisition' && (<> +
+ SHIP-ACQ +

Ship Acquisition & Hangar

+
+ +
+ Ships are the player's primary asset, and acquiring them is a core economic milestone. + The system balances accessibility (new players always have a ship) with economic consequence + (better ships cost real ISK and represent player investment). Players can own multiple ships + stored in station hangars, but only fly one at a time. Switching ships requires docking. +
+ +

Ship Ownership Model

+
+
+

Single Active Ship

+

+ A player has exactly one active ship at a time — the ship they're currently piloting. + This is the ship that appears in the star system, has a position, and can perform actions (mine, fight, warp). + The active ship cannot be traded, contracted, or stored while it is active. +

+
+
+

Hangar Storage

+

+ Ships not currently being piloted are stored in a station hangar. + Each station has a hangar per player. A player can store unlimited ships at any station they have docking access to. + Hangar ships are safe — they cannot be destroyed or stolen while stored. +

+
+
+ +

How Players Get Ships

+
+ + + + + + {[ + { method: 'Rookie Frigate (free)', cost: '0 ISK — granted on first spawn and on death respawn', available: 'Automatic on new player creation. Automatic on respawn after ship destruction.', phase: 'Phase 0', color: 'var(--green)' }, + { method: 'NPC Market (sell orders)', cost: 'Hull base value × 1.0–1.5 (station markup)', available: 'Any station with Market service. NPC sell orders seeded at galaxy gen.', phase: 'Phase 6', color: 'var(--cyan)' }, + { method: 'Player Market (sell orders)', cost: 'Player-set price — typically below NPC price for T1, above for T2', available: 'Any station with Market service. Player-placed sell orders.', phase: 'Phase 10', color: 'var(--accent)' }, + { method: 'Manufacturing', cost: 'Minerals (from refining ore) + blueprint + factory fee + time', available: 'Stations with Factory service. Requires Industry skill ≥ ship class tier.', phase: 'Phase 5', color: 'var(--purple)' }, + { method: 'Loyalty Point Store', cost: 'ISK + LP — faction ships at below-market rates', available: 'Faction stations only. Requires high standing + LP balance.', phase: 'Phase 12', color: 'var(--red)' }, + ].map((row, i) => ( + + + + + + + ))} + +
MethodCostAvailable AtPhase
{row.method}{row.cost}{row.available}{row.phase}
+
+ +

The Rookie Frigate

+
+
+

Free Ship Policy

+
    +
  • On first spawn: New player receives a Rookie Frigate at their faction's starter station.
  • +
  • On death (ship destroyed): Player respawns at their home station with a new Rookie Frigate. Always free.
  • +
  • Cannot be sold or traded: The Rookie Frigate has no market value.
  • +
  • Cannot be insured: Insurance doesn't apply — you always get a new one free.
  • +
  • Basic stats: 2 high slots, 2 mid slots, 1 low slot. 200 hull, 150 armor, 100 shield. 100 cargo. Very limited — enough for basic mining and combat, but weak.
  • +
  • NPC market exclusion: Rookie Frigates are never sold on the NPC market — they exist only as free grants.
  • +
+
+
+

Why Free Respawn?

+

+ A player without a ship has no way to earn ISK — they can't mine, fight, or trade. A permanent + "ship-less" state is a dead-end that causes player churn. The free Rookie Frigate ensures that + every player always has a path back into the game, no matter how + many times they're destroyed. The cost of death is the upgraded ship and modules you lost — + not the ability to play at all. +

+
+ Design rule: "Never softlock the player out of the game loop." The Rookie Frigate is the safety net. +
+
+
+ +

NPC Market — Ship Pricing

+
+ NPC sell orders provide baseline ship availability. At galaxy generation, the server seeds NPC sell orders + for every ship type at stations with Factory services. These orders have infinite quantity (they never run out) + but are priced at a premium over manufacturing cost. This ensures: + (1) players can always buy a basic ship hull, (2) player manufacturers can undercut NPC prices profitably, + (3) the economy has a known ISK sink ceiling per ship class. +
+
+ + + + + + {[ + { cls: 'Frigate', mfg: '~8,000 ISK', npc: '~15,000 ISK', insurance: '~10,500 ISK (70%)', effective: '~4,500 ISK + modules' }, + { cls: 'Destroyer', mfg: '~20,000 ISK', npc: '~35,000 ISK', insurance: '~24,500 ISK (70%)', effective: '~10,500 ISK + modules' }, + { cls: 'Cruiser', mfg: '~60,000 ISK', npc: '~100,000 ISK', insurance: '~70,000 ISK (70%)', effective: '~30,000 ISK + modules' }, + { cls: 'Battlecruiser', mfg: '~180,000 ISK', npc: '~300,000 ISK', insurance: '~210,000 ISK (70%)', effective: '~90,000 ISK + modules' }, + { cls: 'Battleship', mfg: '~500,000 ISK', npc: '~800,000 ISK', insurance: '~560,000 ISK (70%)', effective: '~240,000 ISK + modules' }, + ].map((row, i) => ( + + + + + + + + ))} + +
Ship ClassManufacturing CostNPC Sell PriceInsurance (Standard)Effective Replacement
{row.cls}{row.mfg}{row.npc}{row.insurance}{row.effective}
+
+ +

Ship Switching Flow

+
+

Docked Ship Switch

+
+ 1. Dock at station — Player must be docked. Cannot switch ships while in space.
+ 2. Open Hangar panel — Station Mode → Hangar tab. Shows all ships stored at this station.
+ 3. Select a ship — Shows ship name, class, fitting summary, cargo, insurance status.
+ 4. Activate — Click "Make Active". Current active ship moves to hangar. Selected ship becomes active.
+ 5. Cargo transfer — Prompt to transfer cargo between ships (limited by destination capacity). Remaining stays in station inventory.
+ 6. Undock — Player undocks in the new active ship. Old ship stays in hangar. +
+
+ +
+ Cannot switch while in space. If your ship is destroyed, you respawn at your home station + in a Rookie Frigate — you cannot switch to a hangar ship remotely. You must fly to the station where the ship is stored and dock. + This is intentional: it creates geographic identity ("my ships are in Amarr") and makes home station choice meaningful. +
+ +
+ SHIP-ACQ-DB +

Backend Impact

+
+
+

Schema & Reducer Changes

+
+ ships — add column: storage_location (enum: active / hangar:station_id). When active, ship has system_id/x/y/z. When in hangar, stored at station.
+ ships — add column: is_rookie (bool, default false). Rookie frigates are untradeable, uninsurable, free-replace.
+ npc_sell_orders — seeded at galaxy gen for every ship_types entry at Factory stations. Infinite quantity. Price = base_hull_value × 1.5.
+ New reducer: switch_ship(player_id, target_ship_id) — validate docked, validate target in same station hangar, swap active ↔ hangar.
+ Updated reducer: connect_player(display_name) — on first connection, spawn Rookie Frigate at faction starter station. On subsequent, load existing active ship.
+ Updated reducer: respawn_player(player_id) — on ship destruction, create new Rookie Frigate at home station, set as active. +
+
+ )} + + {/* AI CREW */} + {activeSection === 'crew' && ( + <> +
+ Post-MVP feature. AI crew is designed here so the ship and backend architecture + supports it from the start, but implementation is planned after the core gameplay loop ships. +
+ +
+ SHIP-AI +

AI Crew System

+
+ +

+ Players can own multiple ships and assign AI crew members to pilot them. AI ships execute + autonomous tasks — mining runs, patrol routes, trade delivery — while the player controls + their primary ship directly. AI crew gain experience over time and improve at their assigned role. +

+ +
+
+

Crew Roles

+ + + + + + + + + + + + + + + + + + + + + + + + +
RoleBehaviorXP Growth
MinerWarps to belt, mines until cargo full, returns to station, sells ore.Faster cycle times, better ore selection.
PatrolCirculates waypoints, engages hostiles, reports contacts.Better target priority, longer patrol endurance.
HaulerMoves cargo between stations along trade routes.Faster warp align, larger cargo optimization.
GuardEscorts player ship, engages threats within range.Better reaction time, coordination with fleet.
+
+
+

Crew Progression

+
+ Rank levels:
+ CadetEnsign →{' '} + Lieutenant →{' '} + Commander →{' '} + Captain

+ XP sources:
+ • Task completion (base XP)
+ • Task success rate bonus
+ • Survival bonus (ship not destroyed)
+ • Player commendation (manual XP grant) +
+
+
+ +
+ SHIP-OPS +

Task Assignment Flow

+
+ +
+
+ 1. Player docks at station and opens Crew Management panel
+ 2. Selects an idle ship + available crew member
+ 3. Assigns a task template (mine system X, patrol route Y, haul to station Z)
+ 4. Crew departs autonomously — ship disappears from player's direct control
+ 5. Player receives periodic status reports in a dedicated channel
+ 6. Task completes (or ship is destroyed) → crew returns to station or sends distress signal
+ 7. Resources earned are deposited in player's station inventory +
+
+ +
+ Risk: AI crew earning passive income could trivialize the economy. Mitigation: AI + operations have costs (fuel, maintenance, crew wages) and diminishing returns at scale. A player + with 10 AI miners shouldn't earn 10× a single player — there should be coordination overhead. +
+ +
+ AI Crew vs. Zora (Ship AI): These are two different systems. AI Crew (this tab) are autonomous + pilots that fly other ships on your behalf — mining runs, patrol routes, hauling. Zora (see Ship AI page) + is a companion AI installed on your current ship that provides market intelligence, tactical advice, + and dialogue. Think of AI Crew as your employees and Zora as your ship's computer. They do not share + systems, modules, or XP. A future expansion may let Zora be assigned to an AI Crew ship, but that is post-MVP scope. +
+ + )} + + {/* Death & Loss */} +
+ Slot scope note: High, Medium, and Low slots cover combat, mining, propulsion, and tank modules. Ship AI modules (Communications Processor, Market Analyzer, etc.) are a separate system — see the Ship AI → Module Gates tab for the full AI module catalog and how they install alongside standard fittings. +
+ +
+ SHIP-DEATH +

Ship Destruction

+
+ +
+
+

What you lose

+
    +
  • The ship hull itself (destroyed)
  • +
  • All fitted modules (50% chance each to drop as loot)
  • +
  • Cargo — destroyed or dropped (50/50 split)
  • +
  • AI crew aboard — injured, require medical bay recovery time
  • +
+
+
+

What you keep

+
    +
  • Your other ships in hangars
  • +
  • Station inventory and assets
  • +
  • Player XP and progression
  • +
  • ISK in wallet (₢)
  • +
+
+
+ +
+ Respawn: Player respawns at their home station (or nearest friendly station) in a + rookie frigate. Insurance policies can partially reimburse ship loss. +
+
+ ); +} diff --git a/src/pages/docs/SocialPage.tsx b/src/pages/docs/SocialPage.tsx new file mode 100644 index 0000000..c33964d --- /dev/null +++ b/src/pages/docs/SocialPage.tsx @@ -0,0 +1,589 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function SocialPage() { + const [activeSection, setActiveSection] = React.useState('progression'); + + const skillCategories = [ + { + name: 'Combat', + color: 'var(--red)', + skills: ['Gunnery', 'Missiles', 'Shield Operation', 'Armor Tanking', 'Electronic Warfare'], + xpSource: 'Dealing damage, destroying NPCs, completing combat missions', + }, + { + name: 'Industry', + color: 'var(--accent)', + skills: ['Mining', 'Refining', 'Manufacturing', 'Blueprint Research', 'Resource Processing'], + xpSource: 'Mining cycles, refining batches, manufactured items, research jobs', + }, + { + name: 'Navigation', + color: 'var(--cyan)', + skills: ['Warp Drive Operation', 'Afterburner', 'Evasive Maneuvering', 'Capital Navigation'], + xpSource: 'Distance warped, systems visited, successful evasion from combat', + }, + { + name: 'Trade', + color: 'var(--green)', + skills: ['Market Analysis', 'Broker Relations', 'Hauling', 'Contracting', 'Regional Trading'], + xpSource: 'Completed trades, market orders filled, cargo hauled between systems', + }, + { + name: 'Leadership', + color: 'var(--purple)', + skills: ['Fleet Command', 'Wing Command', 'AI Coordination', 'Crew Management'], + xpSource: 'Fleet actions, AI crew task completions, group mission success (post-MVP)', + }, + ]; + + const chatChannels = [ + { name: 'Local', range: 'Current system', delay: 'Instant', description: 'Everyone in the same star system sees messages immediately. Core social channel.', color: 'var(--fg-bright)' }, + { name: 'Private Message', range: 'Based on distance', delay: 'Light-speed delayed', description: 'Direct messages to another player. Messages arrive after a delay proportional to light-years between systems. Across the galaxy = minutes of delay.', color: 'var(--cyan)' }, + { name: 'Trade', range: 'Station / Region', delay: 'Instant at station, delayed region-wide', description: 'Buy/sell offers, price checks, trade negotiations. Station-local is instant; regional relay has a 30s delay.', color: 'var(--green)' }, + ]; + + const bountyTiers = [ + { tier: 'Petty', threshold: '500 ISK', reward: '10% of bounty', visibility: 'Current system only', color: 'var(--muted)' }, + { tier: 'Standard', threshold: '5,000 ISK', reward: '15% of bounty', visibility: 'Current region (constellation cluster)', color: 'var(--cyan)' }, + { tier: 'Dangerous', threshold: '50,000 ISK', reward: '20% of bounty + kill bonus', visibility: 'All regions (galaxy-wide board)', color: 'var(--accent)' }, + { tier: 'Most Wanted', threshold: '500,000 ISK', reward: '25% + unique cosmetic reward', visibility: 'Galaxy-wide + leaderboard', color: 'var(--red)' }, + ]; + + return ( +
+

Progression & Social

+

+ XP-based progression across five skill categories. Chat ranges from instant local to + light-speed-delayed private messages. Bounty system creates emergent player-driven justice + and piracy consequences. Designed for ~3-hour play sessions with meaningful progression per session. +

+ + {/* Tab navigation */} +
+ {[ + { id: 'progression', label: 'XP & Skills' }, + { id: 'chat', label: 'Chat & Comms' }, + { id: 'bounty', label: 'Bounty System' }, + { id: 'waypoints', label: 'Waypoints' }, + { id: 'corps', label: 'Corporations' }, + ].map(t => ( + + ))} +
+ + {/* PROGRESSION */} + {activeSection === 'progression' && ( + <> +
+
+
Action-based
+
XP Model
+
+
+
5 Categories
+
Skill Trees
+
+
+
~3 hours
+
Session Target
+
+
+
V Levels
+
Per Skill
+
+
+ +
+ Design intent: XP comes from doing things, not waiting. A 3-hour mining session + should unlock at least one meaningful skill upgrade. Combat and trade have comparable XP rates + so no playstyle feels punished. +
+ +
+ SOC-SKL +

Skill Categories

+
+ + {skillCategories.map((cat, i) => ( +
+
+

{cat.name}

+
+
+ {cat.skills.map((s, j) => ( + + {s} + + ))} +
+
+ XP source: {cat.xpSource} +
+
+ ))} + +
+ SOC-LVL +

Level Progression

+
+ +
+

XP Curve (per skill)

+
+ + + + + + + + + + + + + + + + +
LevelCumulative XPApprox. Active PlayTypical Unlock
I100~15 minBasic modules, rookie ships
II500~1 hourStandard modules, refining
III2,000~3 hoursT2 modules, cruisers, manufacturing
IV8,000~8 hoursAdvanced fittings, battlecruisers
V32,000~20 hoursBattleships, T2 ships, mastery
+
+
+
+ SOC-RESPEC +

Respec (Skill Reallocation)

+
+ +
+ Mistakes should be fixable, but not free. Players can reallocate (respec) their skill points, + returning XP from one skill and applying it to another. This prevents permanent lock-in from early uninformed + decisions while making frequent respeccing uneconomical. +
+ +
+
+

Respec Mechanics

+
    +
  • Cost: 20% XP penalty on reallocated points. Respeccing 1,000 XP returns 800 XP to redistribute.
  • +
  • Cooldown: 7 real-world days between respeccs. Cannot be bypassed.
  • +
  • Scope: Full respec (all skills) or single-skill respec. Full respec costs 30% instead of 20%.
  • +
  • Location: Must be docked at a station with a "Neural Remapping" facility (not all stations have this).
  • +
  • Preview: Before confirming, the respec UI shows the exact XP loss and resulting skill levels. No surprises.
  • +
+
+
+

Design Rationale

+

+ The 20% cost ensures that respeccing is a meaningful decision, not a free toggle. A player who respeccs + from Mining to Gunnery loses 200 of their 1,000 mining XP — they can try combat, but they've paid for the + privilege. The cooldown prevents rapid role-switching and preserves the value of specialization. + Players who plan their skill progression carefully are rewarded with more total XP than those who respec frequently. +

+
+ Example: Mining III (2,000 XP) \u2192 respec \u2192 receive 1,600 XP \u2192 invest in Gunnery (now Gunnery II at 500 XP + 1,100 banked) +
+
+
+ + )} + + {/* CHAT & COMMS */} + {activeSection === 'chat' && ( + <> +
+ SOC-COM +

Communication System

+
+ +

+ Communication range is physical — messages travel at light speed. Local chat in the same system + is instant. Private messages to someone across the galaxy are delayed by minutes. This creates + meaningful tactical information asymmetry and makes relay networks valuable. +

+ + {chatChannels.map((ch, i) => ( +
+
+

{ch.name}

+ + {ch.range} + +
+

{ch.description}

+
+ Delay: {ch.delay} +
+
+ ))} + +
+ SOC-PHYS +

Light-Speed Delay Mechanics

+
+ +
+

Message Travel Time

+
+ Same system: 0s (quantum relay)
+ Adjacent system (1 gate): ~2s
+ 5 jumps: ~10s
+ 20 jumps: ~45s
+ Across galaxy: ~2–3 minutes
+
+ Formula: baseDelay × sqrt(gateDistance) × 2s +
+
+ +
+
+

Strategic implications

+
    +
  • Fleet commanders near the front have better intel
  • +
  • Market data is delayed — arbitrage exists between regions
  • +
  • Distress calls from deep null-sec arrive minutes late
  • +
  • Players can set up communication relay networks
  • +
+
+
+

Post-MVP channels

+
    +
  • Corporation — instant for corp members (post-MVP)
  • +
  • Fleet — instant for fleet members (post-MVP)
  • +
  • Relay Beacons — player-placed structures that boost range
  • +
  • Alliance — meta-corp channels (far future)
  • +
+
+
+ + )} + + {/* BOUNTY SYSTEM */} + {activeSection === 'bounty' && ( + <> +
+ SOC-BNT +

Bounty System

+
+ +

+ Any player can place a bounty on another player. Bounties create consequences for piracy + and emergent "space justice" dynamics. Higher bounties attract more bounty hunters and + increase visibility across the galaxy. +

+ +
+ + + + + + {bountyTiers.map((tier, i) => ( + + + + + + + ))} + +
TierBounty ThresholdHunter RewardVisibility (Sector-Specific)
+ {tier.tier} + {tier.threshold}{tier.reward}{tier.visibility}
+
+ +
+
+

How bounties work

+
+ 1. Player A places bounty on Player B (ISK deducted immediately)
+ 2. Bounty pool accumulates — multiple players can contribute
+ 3. When Player B's ship is destroyed, bounty pays out
+ 4. Payout = percentage of bounty pool based on tier
+ 5. Killer gets the reward; remaining pool stays active
+ 6. If Player B stays clean for 30 days, bounty decays 10%/week +
+
+
+

Anti-abuse rules

+
    +
  • You cannot claim your own bounty (alt check)
  • +
  • Bounty payout never exceeds ship loss value
  • +
  • Minimum bounty placement: 500 ISK (prevents spam)
  • +
  • Bounty target must have negative security status or have committed a hostile act in the last 24h (see Gameplay → Security Levels)
  • +
  • Kill feed shows bounty collected, not total pool
  • +
+
+
+ +
+ Sector-specific bounties: Bounty visibility is sector-based, not galaxy-wide by default. A petty bounty + in Jita is invisible in Amarr. This means a pirate can be hunted in one region while operating freely in another. + Only Dangerous and Most Wanted bounties propagate galaxy-wide. This creates regional justice ecosystems and + makes bounty hunting a localized profession rather than a galaxy-wide pursuit. +
+ +
+ Kill feed: A galaxy-wide feed shows ship destruction events with pilot names, + ship types, system, and bounty collected. This creates reputation dynamics and emergent + storylines — "CMDR Worf has been destroyed in O-WAMW, 45,000 ISK bounty collected." +
+ + )} + + {/* WAYPOINTS */} + {activeSection === 'waypoints' && ( + <> +
+ SOC-NAV +

Waypoints & Bookmarks

+
+ +

+ Players can bookmark arbitrary locations in space and create waypoint routes for navigation. + Bookmarks are personal; waypoints can be shared. Fleet beacons are visible to all fleet members + and create tactical rally points. +

+ +
+
+

Bookmarks

+
    +
  • Save any point in space as a named bookmark
  • +
  • Personal — only you see them
  • +
  • Can warp to any bookmark (if in range)
  • +
  • Used for: safe spots, mining positions, ambush points, salvage sites
  • +
  • Storage limit: 100 bookmarks per player (MVP)
  • +
+
+
+

Waypoints & Routes

+
    +
  • Create multi-stop routes through star systems
  • +
  • Autopilot follows route automatically (slower than manual)
  • +
  • Route avoids low-sec if preference set
  • +
  • Share routes with other players (trade routes, patrol paths)
  • +
  • Optimize for: shortest, safest, or most profitable
  • +
+
+
+

Fleet Beacons

+
    +
  • Visible to all fleet members on star map
  • +
  • Create rally points and tactical positions
  • +
  • Any fleet member can create a beacon
  • +
  • Beacons expire after 1 hour or when creator leaves fleet
  • +
  • Post-MVP feature — designed into data model now
  • +
+
+
+

Map Annotations

+
    +
  • Mark systems with notes ("pirate camp", "good mining", "friendly station")
  • +
  • Notes visible on star map hover
  • +
  • Statistics overlay: jumps/hour, ship kills, pod kills
  • +
  • Color-code systems by danger level or personal preference
  • +
  • Export/import annotations for sharing
  • +
+
+
+ +
+ SOC-DB +

Backend Tables (New)

+
+ +
+ Canonical location: These tables are also listed in the Backend → Tables tab, which serves as the master schema reference. The definitions here include Social-specific context. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TablePurposeKey Fields
bookmarksPlayer-saved locationsbookmark_id, player_id, system_id, x/y/z, name, created_at
waypointsMulti-stop routesroute_id, player_id, stops (ordered system list), name, shared
bountiesBounty pool per targettarget_player_id, total_pool, tier, last_hostile_act
bounty_contributionsIndividual bounty paymentscontribution_id, target_id, contributor_id, amount, timestamp
kill_feedShip destruction eventskill_id, victim_id, killer_id, ship_type, system_id, bounty_collected, timestamp
player_skillsXP and levels per skillplayer_id, skill_name, xp, level, last_action_at
fleet_beaconsTemporary fleet rally pointsbeacon_id, fleet_id, creator_id, system_id, x/y/z, expires_at
+
+ + )} + {/* CORPORATIONS & TERRITORY */} + {activeSection === 'corps' && (<> +
+ SOC-CORP +

Corporations & Territory

+
+ +
+ Era 2 feature — designed now, implemented in Phase 14. + Corporations ("corps") are player organizations: persistent groups with shared wallets, hangars, roles, + and territory claims. They are the endgame social structure — the reason players stay for years. This spec + establishes the design direction so the backend schema can accommodate it without breaking changes. + Implementation details may evolve, but the core model is fixed. +
+ +

Corporation Lifecycle

+
+
+ 1. Found — Any player pays 1,000,000 ₢ founding fee. Chooses name + ticker (3–5 chars). Becomes CEO. Minimum 1 member. +
+ 2. Recruit — CEO (or Director) sends invite. Player accepts. Member appears on corp roster. Corp chat channel auto-created. +
+ 3. Operate — Members contribute to corp wallet (ISK tax on bounties/missions, optional). Shared hangar at corp HQ station. Roles control access. +
+ 4. Claim — Corp anchors a structure in a null-sec system. Structure asserts sovereignty. System shows on sov map as corp territory. +
+ 5. Defend — Rival corps can contest sovereignty. Structure has a vulnerability window (CEO-configured, 4h/day). During vulnerability, structure can be attacked. +
+ 6. Dissolve — If CEO disbands or corp drops below 3 members for 14 days, corp dissolves. Assets liquidated to CEO wallet. Territory unclaimed. +
+
+ +

Corporation Roles

+
+ + + + + + {[ + { role: 'CEO', perms: 'All permissions. Can disband corp. Can transfer CEO role. Cannot be kicked.', assignment: 'Founder (auto). Transferable.' }, + { role: 'Director', perms: 'Invite/kick members, manage roles, corp wallet full access, anchor/destroy structures, set tax rate, configure vulnerability window.', assignment: 'Appointed by CEO or other Directors.' }, + { role: 'Accountant', perms: 'View corp wallet history. Pay bills. Set market orders from corp funds. Cannot withdraw ISK.', assignment: 'Appointed by Director+.' }, + { role: 'Pilot', perms: 'Access corp hangar (configured per station). Use corp fittings. Join corp fleet.', assignment: 'Default role for new members.' }, + { role: 'Recruit', perms: 'Corp chat access. View roster. No hangar access. No wallet access.', assignment: 'Probationary role (optional, 7-day default). Auto-promotes to Pilot.' }, + ].map((row, i) => ( + + + + + + ))} + +
RolePermissionsAssignment
{row.role}{row.perms}{row.assignment}
+
+ +

Corporation Wallet & Tax

+
+
+

Corp Wallet

+
    +
  • Shared ISK balance separate from all member wallets
  • +
  • Funded by: tax on member income, voluntary donations, structure taxes, corp market orders
  • +
  • Spent on: structure fuel, alliance dues, SRP (ship replacement program), member bonuses
  • +
  • Full transaction log visible to Accountant+ roles
  • +
  • Withdrawal requires Director+ approval (or CEO)
  • +
+
+
+

Tax System

+
    +
  • Bounty tax: CEO sets % (0–100%). Deducted from NPC bounties and mission rewards. Default 10%.
  • +
  • Market tax: Optional tax on market orders placed in corp-controlled stations. 0–5%. Set per station.
  • +
  • Refining tax: Optional tax on refining at corp-controlled facilities. 0–5%.
  • +
  • Tax revenue goes to corp wallet automatically
  • +
  • Members see their personal tax contribution in wallet history
  • +
+
+
+ +

Territory & Sovereignty

+
+ Null-sec only. Territory claims are only possible in null-sec (security ≤ 0.0). High-sec and low-sec systems + cannot be claimed. This preserves the "wild west" nature of null-sec as the player-driven frontier while keeping + NPC-controlled space as the shared commons. +
+ +
+
+

Corporation Structures

+
    +
  • Starbase (Medium): Claims sovereignty in 1 system. Provides: refining bonus (+10%), market access, hangar, clone bay. Cost: 50M ₢ + fuel.
  • +
  • Citadel (Large): Claims constellation-wide influence. Provides: all Starbase bonuses + manufacturing slots + insurance desk. Cost: 500M ₢ + fuel.
  • +
  • Keepstar (XL): Regional capital. Provides: all Citadel bonuses + capital ship construction + super-capital docking. Cost: 5B ₢ + fuel. One per region.
  • +
  • Structures have shields, armor, and hull — they can be destroyed during vulnerability windows
  • +
  • Structure destruction = loss of territory claim. System becomes unclaimed.
  • +
+
+
+

Sovereignty Mechanics

+
    +
  • Anchoring: Corp anchors structure in unclaimed null-sec system. 24-hour onlining timer. During onlining, structure is invulnerable.
  • +
  • Vulnerability window: CEO picks a 4-hour daily window (must overlap with corp prime time). Structure can only be attacked during this window.
  • +
  • Reinforcement: When structure shields reach 0%, it enters reinforcement (18h timer). Armor becomes targetable after timer expires.
  • +
  • Destruction: When structure hull reaches 0%, it explodes. Corp loses sovereignty. All items in corp hangar drop as loot (50%) or are destroyed (50%).
  • +
  • Strategic index: The longer a corp holds a system, the higher the strategic index (0–5). Higher index = longer reinforcement timer, stronger structure shields. Rewards long-term ownership.
  • +
+
+
+ +
+ Design intent: Territory warfare is the endgame that keeps players engaged for years. A corp's home system + is its identity — losing it is a major event, conquering one is a major achievement. The vulnerability window ensures + battles happen when both sides can participate (no 3 AM structure kills). The strategic index rewards corps that invest + in their territory rather than nomadic expansion. The goal is meaningful conflict, not meaningless destruction. +
+ +
+ SOC-CORP-DB +

Backend Impact

+
+
+

New Tables (Phase 14)

+
+ corporationscorp_id, name, ticker, ceo_player_id, founded_at, wallet_balance, tax_rate_bounty, tax_rate_market, tax_rate_refining, member_count, hq_station_id, vulnerability_window_start, vulnerability_window_hours, dissolved_at
+ corp_memberscorp_id, player_id, role (enum), joined_at, tax_contribution_lifetime
+ corp_wallet_journalentry_id, corp_id, entry_type (tax/donation/withdrawal/expense), amount, player_id, timestamp, description
+ corp_structuresstructure_id, corp_id, system_id, structure_type (starbase/citadel/keepstar), shield/armor/hull, state (onlining/online/reinforced/vulnerable/destroyed), reinforced_at, vulnerability_start, strategic_index
+ corp_hangarhangar_id, corp_id, station_id, item_type, quantity, access_role_min
+ corp_fittingsfitting_id, corp_id, name, ship_type, modules_json, created_by, access_role_min
+
+ Reducers: found_corp(name, ticker), invite_member(player_id), kick_member(player_id), set_role(player_id, role), anchor_structure(system_id, type), attack_structure(structure_id), reinforce_structure(structure_id), destroy_structure(structure_id), set_tax_rate(type, rate), deposit_corp_wallet(amount), withdraw_corp_wallet(amount, reason) +
+
+ +
+ Corp hangar integration: When a player is docked at a station with a corp hangar, they see a + "Corp Hangar" tab in the Inventory panel. Access is gated by role. Items in corp hangar can be used for corp + manufacturing jobs, corp market orders, or distributed to members. The hangar respects the same item/inventory + system as personal hangars — it's just owned by the corp entity rather than a player entity. +
+ )} +
+ ); +} diff --git a/src/pages/docs/TechStackPage.tsx b/src/pages/docs/TechStackPage.tsx new file mode 100644 index 0000000..98e23a5 --- /dev/null +++ b/src/pages/docs/TechStackPage.tsx @@ -0,0 +1,173 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +export function TechStackPage() { + const [activeTab, setActiveTab] = React.useState('frontend'); + + const tabs = [ + { id: 'frontend', label: 'Frontend' }, + { id: 'rendering', label: '3D Rendering' }, + { id: 'state', label: 'State' }, + { id: 'backend', label: 'Backend' }, + { id: 'styling', label: 'Styling' }, + { id: 'auth', label: 'Auth' }, + ]; + + const decisions = { + frontend: { + choice: 'Vite + React + TypeScript', + reason: 'Fast client-side app setup, no server-rendering complexity, good for a real-time game UI.', + whyNot: "Next.js is not necessary for the MVP. The prototype is a client-side real-time game, not a content site. Next.js can be introduced later for marketing pages, account pages, SSR, or API routes outside SpacetimeDB.", + }, + rendering: { + choice: 'React Three Fiber', + reason: 'Declarative React renderer for Three.js; good for a browser prototype and React integration.', + whyNot: "A full engine like Unity or Bevy would slow iteration on panels, tables, forms, chat, and market UX. The gameplay is UI-heavy and economy/social-system-heavy.", + }, + state: { + choice: 'Zustand', + reason: 'Simple local state for panels, selection, active tabs, camera preferences, and modal state.', + whyNot: "Redux would add ceremony without benefit at prototype scale. Context API re-renders would hurt performance with frequent state updates.", + }, + backend: { + choice: 'SpacetimeDB', + reason: 'Real-time backend/database with server-side reducers and live client subscriptions. Authoritative state, persistence, multiplayer all in one.', + whyNot: "A custom Node.js + PostgreSQL backend would require building real-time sync, subscriptions, and authoritative game logic from scratch.", + }, + styling: { + choice: 'Tailwind CSS', + reason: 'Fast UI iteration for panels, tables, HUDs, and dense game interfaces.', + whyNot: "CSS-in-JS adds runtime overhead. Vanilla CSS at this scale would slow panel iteration.", + }, + auth: { + choice: 'SpacetimeDB Identity (MVP)', + reason: 'Keep early identity/session handling simple. Add external auth only after core loop works.', + whyNot: "Full OAuth/JWT would add complexity before we know the right identity model for the game.", + }, + }; + + const d = decisions[activeTab]; + + return ( +
+

Technical Direction

+

+ Each layer is chosen for iteration speed during the prototype phase. Architecture is designed + so any layer can be replaced as the game evolves. +

+ + {/* Decision tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + {/* Active decision */} +
+
+ + {activeTab} + + + + {d.choice} + +
+

+ Why: {d.reason} +

+
+ Why not the alternatives: {d.whyNot} +
+
+ + {/* Full decision table */} +
+ TECH-ALL +

Decision Matrix

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LayerChoiceReason
FrontendVite + React + TypeScriptFast client-side app, no SSR complexity.
3D RenderingReact Three FiberDeclarative React renderer for Three.js.
UI StateZustandSimple local state for panels and UI.
BackendSpacetimeDBReal-time backend with reducers and subscriptions.
StylingTailwind CSSFast iteration for panels, tables, HUDs.
AuthSpacetimeDB identitySimple identity first; add auth later.
+ + {/* File structure */} +
+ TECH-FS +

Starter File Structure

+
+ +
+ Note: The file structure below is the production target for a Vite + React + TypeScript project. + The current prototype uses a simpler layout: js/pages/, js/components/, js/demos/, js/lib/, and css/ — + a flat structure loaded via Babel standalone without a build step. The prototype structure will migrate to the layout below when moving to Vite. +
+ +
+ + {'//'} Starter project layout
+ /client/src/app {' //'} App shell and providers
+ /client/src/network {' //'} SpacetimeDB client, subscriptions
+ /client/src/game {' //'} Renderer-independent types, view models
+ /client/src/store {' //'} Zustand stores
+ /client/src/renderers/r3f {'//'} R3F scene, meshes, camera
+ /client/src/ui {' //'} HUD, inventory, market, chat
+ /server-spacetime/src {' //'} SpacetimeDB module, reducers
+
+
+ +

Packages

+ + + + + + + + + +
PackagePurpose
vite, react, react-dom, typescriptCore frontend.
three, @react-three/fiber, @react-three/drei3D scene and helper controls.
zustandLocal UI/game view state.
tailwindcssPanel and HUD styling.
spacetimedb TS clientBackend connection, reducers, subscriptions.
+
+ ); +} diff --git a/src/prototypes/existing-demos/BountyDemo.tsx b/src/prototypes/existing-demos/BountyDemo.tsx new file mode 100644 index 0000000..9a32fb0 --- /dev/null +++ b/src/prototypes/existing-demos/BountyDemo.tsx @@ -0,0 +1,450 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../data/fakeBackend'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +export function BountyDemo() { + const { session: sliceSession } = useGameSliceSession(); + const [bounties, setBounties] = useState([]); + const [killFeed, setKillFeed] = useState([]); + const [placeBountyTarget, setPlaceBountyTarget] = useState(''); + const [placeBountyAmount, setPlaceBountyAmount] = useState(5000); + const [showPlaceBounty, setShowPlaceBounty] = useState(false); + const [notifications, setNotifications] = useState([]); + const [autoFeed, setAutoFeed] = useState(false); + const feedRef = useRef(null); + const autoRef = useRef(null); + + const feedNames = [ + 'CMDR Picard', 'CMDR Worf', 'CMDR Data', 'CMDR Troi', 'CMDR Riker', + 'MinerBob', 'PirateKing99', 'NullSecWarlord', 'TraderAlice', 'DeepMiner', + 'RockHound', 'AmarrTrader', 'BulkMiner', 'GallenteForge', 'HighSecOps', + ]; + + const shipTypes = ['Frigate', 'Destroyer', 'Cruiser', 'Battlecruiser', 'Battleship', 'Hauler', 'Mining Barge']; + + const systems = ['Sol', 'Amarr', 'Hek', 'Rens', 'Dodixie', 'U-IRTYR', 'PF-346', 'YZ-LQL', 'O-WAMW']; + + const tierConfig = [ + { tier: 'Petty', threshold: 500, color: 'var(--muted)', reward: '10%', visibility: 'System-local' }, + { tier: 'Standard', threshold: 5000, color: 'var(--cyan)', reward: '15%', visibility: 'Regional' }, + { tier: 'Dangerous', threshold: 50000, color: 'var(--accent)', reward: '20%', visibility: 'Galaxy-wide' }, + { tier: 'Most Wanted', threshold: 500000, color: 'var(--red)', reward: '25%', visibility: 'Galaxy + Leaderboard' }, + ]; + + const getTier = (pool) => { + if (pool >= 500000) return tierConfig[3]; + if (pool >= 50000) return tierConfig[2]; + if (pool >= 5000) return tierConfig[1]; + return tierConfig[0]; + }; + + useEffect(() => { + api.getBounties().then(b => setBounties(b)); + api.getKillFeed().then(k => setKillFeed(k)); + }, []); + + useEffect(() => { + if (!sliceSession) return; + const sessionKills = sliceSession.eventLog + .filter(entry => entry.event.type === 'combat.victory') + .map(entry => ({ + victim: entry.event.targetName, + killer: 'CMDR Kimura', + ship: 'Frigate', + system: sliceSession.currentSystemId.toUpperCase(), + bounty: entry.event.bounty, + time: new Date(entry.at).toLocaleTimeString('en', { hour12: false }), + })); + if (sessionKills.length) setKillFeed(prev => [...sessionKills, ...prev.filter(k => !sessionKills.some(s => s.victim === k.victim && s.bounty === k.bounty))]); + }, [sliceSession]); + + const addNotif = useCallback((msg, color) => { + const id = Date.now(); + setNotifications(prev => [...prev, { id, msg, color }]); + setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3500); + }, []); + + const handlePlaceBounty = useCallback(async () => { + if (!placeBountyTarget.trim()) return; + if (placeBountyAmount < 500) { + addNotif('Minimum bounty is 500 ISK.', 'var(--red)'); + return; + } + const result = await api.placeBounty(placeBountyTarget, placeBountyAmount); + if (result.success) { + setBounties(prev => { + const existing = prev.find(b => b.target === placeBountyTarget); + if (existing) { + return prev.map(b => b.target === placeBountyTarget + ? { ...b, pool: b.pool + placeBountyAmount, tier: getTier(b.pool + placeBountyAmount).tier } + : b); + } + return [...prev, { + target: placeBountyTarget, + pool: placeBountyAmount, + tier: getTier(placeBountyAmount).tier, + lastHostile: 'Just now', + }]; + }); + addNotif(`Bounty of ₢${placeBountyAmount.toLocaleString()} placed on ${placeBountyTarget}.`, 'var(--green)'); + setShowPlaceBounty(false); + setPlaceBountyTarget(''); + setPlaceBountyAmount(5000); + } + }, [placeBountyTarget, placeBountyAmount, addNotif]); + + // Auto-generate kill feed + const generateKill = useCallback(() => { + const victim = feedNames[Math.floor(Math.random() * feedNames.length)]; + let killer; + do { killer = feedNames[Math.floor(Math.random() * feedNames.length)]; } while (killer === victim); + const ship = shipTypes[Math.floor(Math.random() * shipTypes.length)]; + const system = systems[Math.floor(Math.random() * systems.length)]; + const bounty = Math.random() > 0.6 ? Math.floor(Math.random() * 50000) : 0; + + const kill = { + victim, + killer, + ship, + system, + bounty, + time: 'Just now', + }; + + setKillFeed(prev => [kill, ...prev.slice(0, 49)]); + + // If bounty, update bounty pool + if (bounty > 0) { + addNotif(`Bounty collected: ${killer} claimed ₢${bounty.toLocaleString()} from ${victim}'s bounty.`, 'var(--accent)'); + setBounties(prev => prev.map(b => + b.target === victim + ? { ...b, pool: Math.max(0, b.pool - bounty) } + : b + ).filter(b => b.pool > 0)); + } + }, [addNotif]); + + useEffect(() => { + if (autoFeed) { + autoRef.current = setInterval(generateKill, 2000 + Math.random() * 3000); + } else { + if (autoRef.current) clearInterval(autoRef.current); + } + return () => { if (autoRef.current) clearInterval(autoRef.current); }; + }, [autoFeed, generateKill]); + + // Auto-scroll kill feed + useEffect(() => { + if (killFeed.length > 0 && feedRef.current) { + // No auto-scroll needed since newest are on top + } + }, [killFeed]); + + const totalBountyPool = bounties.reduce((sum, b) => sum + b.pool, 0); + const totalKills = killFeed.length; + const totalBountyCollected = killFeed.reduce((sum, k) => sum + k.bounty, 0); + + return ( +
+ e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}>← Back to Docs +

Bounty Board & Kill Feed

+

+ Live bounty board with tier escalation and a galaxy-wide kill feed. Place bounties on pirates, + track kill events, and watch bounty pools climb. Toggle the auto-feed to simulate live combat activity. +

+ {sliceSession && ( +
+ SLICE KILL FEED + {sliceSession.eventLog.filter(e => e.event.type === 'combat.victory').length} combat victory events available. + +
+ )} + + {/* HUD-style bounty strip */} +
+ BOUNTY BOARD +
+ ACTIVE + {bounties.length} +
+ POOL + ₢{totalBountyPool.toLocaleString()} +
+ COLLECTED + ₢{totalBountyCollected.toLocaleString()} + {autoFeed && ● LIVE FEED} +
+ + {/* Notifications */} +
+ {notifications.map(n => ( +
+ {n.msg} +
+ ))} +
+ + {/* Stats */} +
+
+
{bounties.length}
+
Active Bounties
+
+
+
₢{totalBountyPool.toLocaleString()}
+
Total Bounty Pool
+
+
+
{totalKills}
+
Kill Events
+
+
+
₢{totalBountyCollected.toLocaleString()}
+
Bounty Collected
+
+
+ +
+ + + +
+ + {/* Place bounty modal */} + {showPlaceBounty && ( +
setShowPlaceBounty(false)}> +
e.stopPropagation()}> +

Place a Bounty

+ +
+
Target Player
+ setPlaceBountyTarget(e.target.value)} + placeholder="Enter player name..." + style={{ + width: '100%', padding: 'var(--sp-2) var(--sp-3)', background: 'var(--surface-raised)', + border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', + color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', + }} + /> +
+ +
+
+ Amount (min 500 ISK) +
+ setPlaceBountyAmount(parseInt(e.target.value) || 0)} + style={{ + width: '100%', padding: 'var(--sp-2) var(--sp-3)', background: 'var(--surface-raised)', + border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', + color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', + }} + /> +
+ + {/* Tier preview */} +
+
Resulting Tier
+ {(() => { + const t = getTier(placeBountyAmount); + return ( +
+ + {t.tier} + + + Hunter reward: {t.reward} · {t.visibility} + +
+ ); + })()} +
+ +
+ + +
+
+
+ )} + +
+ {/* Active Bounties */} +
+

+ Active Bounties +

+ + {bounties.length === 0 && ( +
+ No active bounties. Place one to get started. +
+ )} + + {bounties.sort((a, b) => b.pool - a.pool).map((bounty, i) => { + const tier = getTier(bounty.pool); + return ( +
+
+
+

{bounty.target}

+ + {tier.tier.toUpperCase()} + +
+
+
+ ₢{bounty.pool.toLocaleString()} +
+
+ Hunter reward: {tier.reward} +
+
+
+ +
+ Visibility: {tier.visibility} + Last hostile: {bounty.lastHostile} +
+ + {/* Pool bar */} +
+
+
+
+
+ ₢{tier.threshold.toLocaleString()} + Next tier +
+
+
+ ); + })} + + {/* Tier legend */} +
+

Bounty Tiers

+ {tierConfig.map((t, i) => ( +
+
+ {t.tier} + + ≥ ₢{t.threshold.toLocaleString()} + +
+
+ {t.reward} · {t.visibility} +
+
+ ))} +
+
+ + {/* Kill Feed */} +
+

+ Kill Feed + {autoFeed && ( + + ● LIVE + + )} +

+ +
+ {killFeed.length === 0 && ( +
+ No kill events yet. Start the live feed or generate events manually. +
+ )} + + {killFeed.map((kill, i) => ( +
0 ? 'var(--accent-border)' : 'var(--border)'}`, + borderRadius: 'var(--radius-md)', + transition: 'background 0.3s', + }}> +
+
+ {kill.victim} + destroyed by + {kill.killer} +
+ + {kill.time} + +
+
+ + Ship: {kill.ship} + + + System: {kill.system} + + {kill.bounty > 0 && ( + + Bounty: ₢{kill.bounty.toLocaleString()} + + )} +
+
+ ))} +
+
+
+ + {/* Anti-abuse rules */} +
+ Anti-abuse rules (implemented in backend): You cannot claim your own bounty (alt check). + Payout never exceeds ship loss value. Minimum placement is 500 ISK. Target must have negative security + status or committed a hostile act within 24h. Bounties decay 10%/week if target stays clean for 30 days. +
+
+ ); +} diff --git a/src/prototypes/existing-demos/ChatDemo.tsx b/src/prototypes/existing-demos/ChatDemo.tsx new file mode 100644 index 0000000..b7e88d8 --- /dev/null +++ b/src/prototypes/existing-demos/ChatDemo.tsx @@ -0,0 +1,418 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../data/fakeBackend'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +export function ChatDemo() { + const { session: sliceSession } = useGameSliceSession(); + // ── State ── + const [activeChannel, setActiveChannel] = useState('local'); + const [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [playerName] = useState('CMDR Kimura'); + const [playerSystem] = useState('Jita'); + const [simTime, setSimTime] = useState(0); + const [speed, setSpeed] = useState(1); + const [running, setRunning] = useState(false); + const messagesEndRef = useRef(null); + const tickRef = useRef(null); + + // ── Simulated players in different systems ── + const players = [ + { name: 'CMDR Vasquez', system: 'Jita', distance: 0 }, + { name: 'CMDR Chen', system: 'Jita', distance: 0 }, + { name: 'CMDR Okafor', system: 'Amarr', distance: 6 }, + { name: 'CMDR Lindström', system: 'Amarr', distance: 6 }, + { name: 'CMDR Tanaka', system: 'Rens', distance: 12 }, + { name: 'CMDR Dubois', system: 'Hek', distance: 18 }, + { name: 'CMDR Voronov', system: 'PF-346', distance: 32 }, + ]; + + // ── Channel definitions ── + const channels = [ + { id: 'local', name: 'Local', range: 'Current System', delay: 'Instant', icon: '📡', color: 'var(--fg-bright)' }, + { id: 'trade', name: 'Trade', range: 'Station / Region', delay: '0–30s', icon: '💰', color: 'var(--green)' }, + { id: 'private', name: 'Private (Okafor)', range: 'Distance-based', delay: '~12s (6 jumps)', icon: '✉', color: 'var(--cyan)' }, + { id: 'fleet', name: 'Fleet [Post-MVP]', range: 'Fleet members', delay: 'Instant', icon: '🚀', color: 'var(--purple)', disabled: true }, + ]; + const displaySystem = sliceSession ? (sliceSession.currentSystemId === 'sol' ? 'Sol' : sliceSession.currentSystemId.toUpperCase()) : playerSystem; + + // ── Pre-seeded message corpus ── + const seedMessages = { + local: [ + { from: 'CMDR Vasquez', text: 'Anyone seen a Veldspar belt that isn\'t depleted?', time: -45 }, + { from: 'CMDR Chen', text: 'Try belt 7-2, was full 10 min ago', time: -38 }, + { from: 'CMDR Vasquez', text: 'Thanks, warping now', time: -35 }, + { from: 'CMDR Chen', text: 'Watch out, saw a Corpii frigate on scan near 7-2', time: -30 }, + { from: 'CMDR Vasquez', text: 'Corpii? In high-sec? That\'s unusual', time: -25 }, + { from: 'System', text: '⚠ CONCORD response dispatched in Jita — criminal act in progress near Jita IV', time: -20, isSystem: true }, + { from: 'CMDR Chen', text: 'Well that explains the locals being jumpy', time: -15 }, + ], + trade: [ + { from: 'CMDR Chen', text: 'WTS 5000 Tritanium @ 3.15/unit — Jita IV docked', time: -60 }, + { from: 'CMDR Vasquez', text: 'WTB Nocxium x200, paying market +5%. Jita.', time: -50 }, + { from: 'Market Bot', text: '📊 Scordite volume up 340% in Jita in last hour. Spread widening.', time: -40, isSystem: true }, + { from: 'CMDR Chen', text: 'That Scordite spike is because someone bought out the entire sell wall at Jita IV', time: -30 }, + ], + private: [ + { from: 'CMDR Okafor', text: 'Hey, you still in Jita?', time: -120 }, + { from: 'CMDR Okafor', text: 'Megacyte just crashed 15% in Amarr. Someone panic-sold a freighter load.', time: -90 }, + { from: 'CMDR Okafor', text: 'If you can haul fast, there\'s a 20% spread between Amarr buy and Jita sell', time: -75 }, + ], + }; + + // ── Delay calculation ── + const getDelay = (fromDistance) => { + if (fromDistance === 0) return 0; + return Math.round(2 * Math.sqrt(fromDistance)); + }; + + const formatDelay = (seconds) => { + if (seconds === 0) return 'instant'; + if (seconds < 60) return `~${seconds}s`; + return `~${Math.floor(seconds / 60)}m ${seconds % 60}s`; + }; + + // ── Simulation ── + useEffect(() => { + if (!running) { + if (tickRef.current) clearInterval(tickRef.current); + return; + } + tickRef.current = setInterval(() => { + setSimTime(t => t + speed); + }, 500); + return () => clearInterval(tickRef.current); + }, [running, speed]); + + // Seed initial messages + useEffect(() => { + const initial = []; + Object.entries(seedMessages).forEach(([channel, msgs]) => { + msgs.forEach(msg => { + initial.push({ + id: `${channel}-${msg.time}-${msg.from}`, + channel, + from: msg.from, + text: msg.text, + isSystem: msg.isSystem || false, + timestamp: msg.time, + deliveredAt: msg.time + (channel === 'private' ? getDelay(6) : 0), + delay: channel === 'private' ? getDelay(6) : 0, + status: 'delivered', + }); + }); + }); + initial.sort((a, b) => a.deliveredAt - b.deliveredAt); + setMessages(initial); + }, []); + + useEffect(() => { + if (!sliceSession) return; + const scripted = sliceSession.eventLog.slice(0, 5).map(entry => ({ + id: `slice-${entry.id}`, + channel: 'local', + from: 'System', + text: entry.message, + isSystem: true, + timestamp: Math.floor((Date.now() - entry.at) / -1000), + deliveredAt: Math.floor((Date.now() - entry.at) / -1000), + delay: 0, + status: 'delivered', + })); + setMessages(prev => { + const existing = new Set(prev.map(m => m.id)); + return [...scripted.filter(m => !existing.has(m.id)), ...prev]; + }); + }, [sliceSession]); + + // Check for delayed message delivery + useEffect(() => { + if (!running) return; + setMessages(prev => prev.map(m => { + if (m.status === 'pending' && simTime >= m.deliveredAt) { + return { ...m, status: 'delivered' }; + } + return m; + })); + }, [simTime, running]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const sendMessage = () => { + if (!inputText.trim()) return; + const now = simTime; + let delay = 0; + if (activeChannel === 'private') delay = getDelay(6); + if (activeChannel === 'trade') delay = Math.floor(Math.random() * 30); + + const msg = { + id: `user-${Date.now()}`, + channel: activeChannel, + from: playerName, + text: inputText.trim(), + isSystem: false, + timestamp: now, + deliveredAt: now + delay, + delay, + status: delay === 0 ? 'delivered' : 'pending', + }; + setMessages(prev => [...prev, msg]); + setInputText(''); + + // Simulate NPC response in local + if (activeChannel === 'local' && Math.random() > 0.5) { + const responder = players.filter(p => p.system === playerSystem && p.name !== playerName); + if (responder.length > 0) { + const responder_ = responder[Math.floor(Math.random() * responder.length)]; + const responses = [ + 'Copy that.', + 'Interesting. Keep us posted.', + 'Acknowledged.', + 'Seen it. Be careful out there.', + 'Good luck.', + ]; + setTimeout(() => { + setMessages(prev => [...prev, { + id: `npc-${Date.now()}`, + channel: 'local', + from: responder_.name, + text: responses[Math.floor(Math.random() * responses.length)], + isSystem: false, + timestamp: simTime + 3, + deliveredAt: simTime + 3, + delay: 0, + status: 'delivered', + }]); + }, 1500); + } + } + }; + + const visibleMessages = messages.filter(m => { + if (m.channel !== activeChannel) return false; + if (m.status === 'pending') return true; + return true; + }); + + const channelInfo = channels.find(c => c.id === activeChannel); + + return ( +
+ {/* Header */} +
+ +
+ {sliceSession && } + Speed: + {[0.5, 1, 2, 5].map(s => ( + + ))} + + + T+{simTime.toFixed(0)}s + +
+
+ +
+ {/* Channel sidebar */} +
+
+ Channels +
+ {channels.map(ch => ( + + ))} + +
+ Nearby Pilots +
+ {players.filter(p => p.system === displaySystem || (displaySystem === 'Sol' && p.system === 'Jita')).map(p => ( +
+ {p.name} +
+ ))} + +
+ Distant Pilots +
+ {players.filter(p => !(p.system === displaySystem || (displaySystem === 'Sol' && p.system === 'Jita'))).map(p => ( +
+ {p.name} + ({p.distance}j) +
+ ))} +
+ + {/* Main chat area */} +
+ {/* Channel info bar */} +
+
+ {channelInfo?.icon} + {channelInfo?.name} + + {channelInfo?.range} + +
+
+ Delay: {channelInfo?.delay} +
+
+ + {/* Messages */} +
+ {visibleMessages.length === 0 && ( +
+ No messages yet. Press ▶ Run to start the simulation. +
+ )} + {visibleMessages.map(msg => ( +
+
+ + {msg.isSystem ? '⚠ System' : msg.from} + + + {msg.status === 'pending' + ? `⏳ delivering… (${formatDelay(msg.delay)} light-speed delay)` + : msg.delay > 0 + ? `T+${msg.deliveredAt.toFixed(0)}s (delayed ${formatDelay(msg.delay)})` + : `T+${msg.timestamp.toFixed(0)}s`} + +
+
+ {msg.text} +
+
+ ))} +
+
+ + {/* Input */} +
+ setInputText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') sendMessage(); }} + placeholder={activeChannel === 'private' ? `Message CMDR Okafor (${formatDelay(getDelay(6))} delay)...` : `Send to ${channelInfo?.name}...`} + style={{ + flex: 1, padding: 'var(--sp-2) var(--sp-3)', + borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)', + background: 'var(--surface-base)', color: 'var(--fg)', + fontSize: '0.85rem', outline: 'none', + }} + /> + +
+
+ + {/* Right sidebar: delay visualization */} +
+
+ Light-Speed Delay Map +
+ +
+ Messages to/from pilots in other systems travel at light speed. Formula: 2 × √(jumps) seconds. +
+ + {players.map(p => { + const delay = getDelay(p.distance); + const isLocal = p.distance === 0; + return ( +
15 ? 'var(--red)' : delay > 5 ? 'var(--amber)' : 'var(--cyan)'}`, + }}> +
{p.name}
+
+ {p.system} + 15 ? 'var(--red)' : 'var(--amber)' }}> + {isLocal ? 'instant' : `~${delay}s`} + +
+ {p.distance > 0 && ( +
+
15 ? 'var(--red)' : delay > 5 ? 'var(--amber)' : 'var(--cyan)', + borderRadius: '2px', + transition: 'width 0.3s ease', + }} /> +
+ )} +
+ ); + })} + +
+ What This Validates +
+
    +
  • Light-speed delay feels meaningful — not instant
  • +
  • Private messages arrive after a visible wait
  • +
  • Local chat is instant, creating information asymmetry
  • +
  • Trade channel has moderate delay (regional relay)
  • +
  • System messages (CONCORD, market) are immediate
  • +
+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/CombatDemo.tsx b/src/prototypes/existing-demos/CombatDemo.tsx new file mode 100644 index 0000000..ef7b88d --- /dev/null +++ b/src/prototypes/existing-demos/CombatDemo.tsx @@ -0,0 +1,129 @@ +import { useEffect, useReducer, useRef } from "react"; +import { CombatScene } from "../r3f/combat/CombatScene"; +import { combatReducer, initialCombatState } from "../r3f/combat/combatState"; +import { useGameSliceSession } from "../game-slice/useGameSliceSession"; +import { withGameSliceSession } from "../game-slice/gameSliceState"; + +const panel = { + background: "rgba(15,22,35,0.88)", + border: "1px solid var(--border)", + borderRadius: "8px", + backdropFilter: "blur(8px)", +} as const; + +function Bar({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+ {label} +
+
+
+ {value.toFixed(0)}% +
+ ); +} + +export function CombatDemo() { + const [state, dispatch] = useReducer(combatReducer, initialCombatState); + const { session: sliceSession, emit } = useGameSliceSession(); + const victoryEmitted = useRef(false); + const lockPct = state.lockState === "locked" ? 100 : state.lockProgress * 100; + const overloaded = state.overloadedUntil > 0; + + useEffect(() => { + if (!sliceSession || victoryEmitted.current || state.enemy.hull > 0) return; + victoryEmitted.current = true; + emit({ + type: "combat.victory", + targetName: "Guristas Pirata", + bounty: 7500, + loot: [{ item: "Damaged Railgun", category: "loot", quantity: 1, unitPrice: 1800 }], + }); + emit({ type: "xp.awarded", skill: "Gunnery", amount: 45 }); + }, [sliceSession, state.enemy.hull, emit]); + + return ( +
+ +
+
+ + COMBAT + Lock: {state.lockState.toUpperCase()} {lockPct.toFixed(0)}% + {overloaded && OVERLOAD} + {state.lockState === "locked" ? "TARGET LOCKED" : "TARGETING STANDBY"} + {sliceSession && } +
+ +
+
+

USS ENTERPRISE

+ + + + +
+
+

POWER

+ {(["weapons", "shields", "engines", "aux"] as const).map((system) => ( +
+ + {system} + {"|".repeat(state.power[system])} + +
+ ))} + +
+
+ +
+
{state.lockState === "locked" ? "DOGFIGHT ACTIVE" : state.lockState === "locking" ? "ACQUIRING LOCK" : "IDLE"}
+
+
+
+
+ +
+
+

Guristas Pirata

+ + + + + + {sliceSession && state.enemy.hull <= 0 && ( + + )} +
+
+

Subsystem Target

+ {(["hull", "shields", "weapons", "engines"] as const).map((subsystem) => ( + + ))} +
+
+ +
+
+ {state.modules.map((module) => ( + + ))} +
+
+ {state.log.slice().reverse().map((entry, index) => ( +
{entry.time} {entry.msg}
+ ))} +
+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/FittingDemo.tsx b/src/prototypes/existing-demos/FittingDemo.tsx new file mode 100644 index 0000000..0cea94e --- /dev/null +++ b/src/prototypes/existing-demos/FittingDemo.tsx @@ -0,0 +1,408 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../data/fakeBackend'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +export function FittingDemo() { + const { session: sliceSession, emit } = useGameSliceSession(); + const [ship, setShip] = useState(null); + const [ships, setShips] = useState([]); + const [availableModules, setAvailableModules] = useState([]); + const [fittedModules, setFittedModules] = useState({ high: [], med: [], low: [] }); + const [selectedModule, setSelectedModule] = useState(null); + const [filterSlot, setFilterSlot] = useState('all'); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + api.getPlayerShips().then(s => { + setShips(s); + if (s.length > 0) setShip(s[0]); + }); + api.getAvailableModules().then(m => setAvailableModules([ + ...m, + { id: 'zora-comms', name: 'Comms Uplink', type: 'ai', slot: 'med', power: 10, cpu: 12, cycle: 0, active: false }, + { id: 'zora-market', name: 'Market Scanner', type: 'ai', slot: 'med', power: 12, cpu: 18, cycle: 0, active: false }, + { id: 'zora-event', name: 'Event Logger', type: 'ai', slot: 'low', power: 4, cpu: 10, cycle: 0, active: false }, + ])); + }, []); + + useEffect(() => { + if (!ship) return; + if (sliceSession) { + const slots = { high: [], med: [], low: [] }; + sliceSession.fittedModules.forEach(m => slots[m.slot]?.push(m)); + setFittedModules(slots); + return; + } + api.getShipFittings(ship.id).then(fitted => { + const slots = { high: [], med: [], low: [] }; + fitted.forEach(m => { + if (m.slot === 'high') slots.high.push(m); + else if (m.slot === 'med') slots.med.push(m); + else if (m.slot === 'low') slots.low.push(m); + }); + setFittedModules(slots); + }); + }, [ship, sliceSession]); + + const addNotif = useCallback((msg, color) => { + const id = Date.now(); + setNotifications(prev => [...prev, { id, msg, color }]); + setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3000); + }, []); + + const cpuUsage = useMemo(() => { + let total = 0; + Object.values(fittedModules).flat().forEach(m => total += m.cpu); + return total; + }, [fittedModules]); + + const gridUsage = useMemo(() => { + let total = 0; + Object.values(fittedModules).flat().forEach(m => total += m.power); + return total; + }, [fittedModules]); + + const cpuMax = ship ? ship.cpu : 0; + const gridMax = ship ? ship.powerGrid : 0; + const cpuOver = cpuUsage > cpuMax; + const gridOver = gridUsage > gridMax; + + const handleFit = useCallback((mod) => { + if (!ship) return; + const slot = mod.slot; + const maxSlots = slot === 'high' ? ship.highSlots : slot === 'med' ? ship.medSlots : ship.lowSlots; + const currentCount = fittedModules[slot].length; + + if (currentCount >= maxSlots) { + addNotif(`No empty ${slot} slots available.`, 'var(--red)'); + return; + } + + const newCpu = cpuUsage + mod.cpu; + const newGrid = gridUsage + mod.power; + if (newCpu > cpuMax) { + addNotif(`CPU exceeded: ${newCpu}/${cpuMax}. Remove a module first.`, 'var(--red)'); + return; + } + if (newGrid > gridMax) { + addNotif(`Power Grid exceeded: ${newGrid}/${gridMax}. Remove a module first.`, 'var(--red)'); + return; + } + + setFittedModules(prev => { + const fitted = { ...mod, uid: Date.now() + Math.random() }; + const next = { ...prev, [slot]: [...prev[slot], fitted] }; + if (sliceSession) emit({ type: 'fitting.changed', modules: Object.values(next).flat().map(m => ({ id: String(m.uid || m.id), name: m.name, type: m.type, slot: m.slot, cpu: m.cpu, power: m.power, active: m.active })) }); + return next; + }); + addNotif(`${mod.name} fitted to ${slot} slot.`, 'var(--green)'); + }, [ship, fittedModules, cpuUsage, gridUsage, cpuMax, gridMax, addNotif, sliceSession, emit]); + + const handleUnfit = useCallback((slot, index) => { + const mod = fittedModules[slot][index]; + setFittedModules(prev => { + const next = { ...prev, [slot]: prev[slot].filter((_, i) => i !== index) }; + if (sliceSession) emit({ type: 'fitting.changed', modules: Object.values(next).flat().map(m => ({ id: String(m.uid || m.id), name: m.name, type: m.type, slot: m.slot, cpu: m.cpu, power: m.power, active: m.active })) }); + return next; + }); + addNotif(`${mod.name} removed from ${slot} slot.`, 'var(--muted)'); + }, [fittedModules, addNotif, sliceSession, emit]); + + const confirmSliceFit = () => { + if (!sliceSession) return; + emit({ type: 'fitting.changed', modules: Object.values(fittedModules).flat().map(m => ({ id: String(m.uid || m.id), name: m.name, type: m.type, slot: m.slot, cpu: m.cpu, power: m.power, active: m.active })) }); + addNotif('Current fit confirmed for loop slice.', 'var(--green)'); + }; + + const filteredModules = filterSlot === 'all' + ? availableModules + : availableModules.filter(m => m.slot === filterSlot); + + const slotConfig = [ + { key: 'high', label: 'High Slots', color: 'var(--red)', icon: '◆', max: ship?.highSlots || 0 }, + { key: 'med', label: 'Medium Slots', color: 'var(--cyan)', icon: '◇', max: ship?.medSlots || 0 }, + { key: 'low', label: 'Low Slots', color: 'var(--green)', icon: '○', max: ship?.lowSlots || 0 }, + ]; + + const moduleTypeIcon = (type) => { + switch(type) { + case 'weapon': return '⊕'; + case 'shield': return '◎'; + case 'mining': return '⛏'; + case 'propulsion': return '»'; + case 'ewar': return '◎'; + case 'armor': return '◼'; + case 'damage_mod': return '↯'; + case 'cargo': return '□'; + default: return '•'; + } + }; + + if (!ship) return

Loading ship data...

; + + return ( +
+ e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}>← Back to Docs +

Ship Fitting Demo

+

+ Drag modules into slot bays. CPU and Power Grid are hard limits — overfitting is blocked. + Select a ship and build your loadout. +

+ {sliceSession && ( +
+ SLICE FITTING + {sliceSession.fittedModules.length} fitted modules loaded from shared session. + + +
+ )} + + {/* Notifications */} +
+ {notifications.map(n => ( +
+ {n.msg} +
+ ))} +
+ + {/* HUD-style fitting strip */} +
+ {ship?.name || 'Loading...'} + {ship && {ship.class}} + {ship &&
} + {ship && ( + <> +
+ CPU +
+
0.8 ? '#f0a030' : '#22d3ee', borderRadius: 'var(--radius-pill)' }} /> +
+ {cpuUsage}/{cpuMax} +
+
+ PWR +
+
0.8 ? '#f0a030' : '#22c55e', borderRadius: 'var(--radius-pill)' }} /> +
+ {gridUsage}/{gridMax} +
+ + )} + {ship?.system || ''} · {ship?.status === 'docked' ? '● DOCKED' : '○ IN SPACE'} +
+ + {/* Ship selector */} +
+ {ships.map(s => ( + + ))} +
+ + {/* Ship stats + resource bars */} +
+
+ Fitting Console + | + {ship.name} · {ship.class}-class + + {ship.system} · {ship.status === 'docked' ? '● DOCKED' : '○ IN SPACE'} + +
+ +
+ {/* Module browser */} +
+
+
+ Module Browser +
+
+ {['all', 'high', 'med', 'low'].map(f => ( + + ))} +
+
+ +
+ {filteredModules.map(mod => ( +
setSelectedModule(mod)} + onDoubleClick={() => handleFit(mod)} + > +
+ + {moduleTypeIcon(mod.type)} + +
+
{mod.name}
+
+ {mod.cpu} CPU · {mod.power} PG · {mod.slot} +
+
+
+
+ ))} +
+
+ + {/* Fitting area */} +
+ {/* CPU / Grid bars */} +
+
+ CPU + + {cpuUsage} / {cpuMax} tf{cpuOver ? ' ⚠ OVER' : ''} + +
+
+
0.8 ? 'var(--accent)' : 'var(--cyan)', + }} /> +
+ +
+ POWER GRID + + {gridUsage} / {gridMax} MW{gridOver ? ' ⚠ OVER' : ''} + +
+
+
0.8 ? 'var(--accent)' : 'var(--green)', + }} /> +
+
+ + {/* Slot bays */} + {slotConfig.map(slot => ( +
+
+ {slot.icon} + + {slot.label} + + + {fittedModules[slot.key].length} / {slot.max} + +
+
+ {Array.from({ length: slot.max }).map((_, i) => { + const mod = fittedModules[slot.key][i]; + return ( +
{ + if (mod) handleUnfit(slot.key, i); + else if (selectedModule?.slot === slot.key) handleFit(selectedModule); + }} + > + {mod ? ( + <> + {moduleTypeIcon(mod.type)} + + {mod.name.replace(' I', '').replace(' II', '')} + + + {mod.cpu}/{mod.power} + + + ) : ( + Empty + )} +
+ ); + })} +
+
+ ))} +
+
+
+ + {/* Selected module detail */} + {selectedModule && ( +
+
+
+ {moduleTypeIcon(selectedModule.type)} +
+
+

{selectedModule.name}

+
+ Slot: {selectedModule.slot} + Type: {selectedModule.type} + CPU: {selectedModule.cpu} tf + Grid: {selectedModule.power} MW + {selectedModule.damage && Damage: {selectedModule.damage}} + {selectedModule.cycle > 0 && Cycle: {selectedModule.cycle}s} +
+
+ +
+
+ )} + + {/* Controls hint */} +
+ How to use: Select a module from the browser (left panel), then click an empty slot bay to fit it. + Double-click a module in the browser to quick-fit. Click a fitted module to remove it. + CPU and Power Grid are enforced — overspending is blocked with a warning. +
+
+ ); +} diff --git a/src/prototypes/existing-demos/GalaxyDemo.tsx b/src/prototypes/existing-demos/GalaxyDemo.tsx new file mode 100644 index 0000000..7ce2489 --- /dev/null +++ b/src/prototypes/existing-demos/GalaxyDemo.tsx @@ -0,0 +1,124 @@ +import { useMemo, useState } from "react"; +import type { SystemPointOfInterest } from "../r3f/shared/types"; +import { buildGalaxyConnections, findGalaxyRoute, GalaxyScene, generateGalaxy, type GeneratedGalaxySystem } from "../r3f/galaxy/GalaxyScene"; +import { useGameSliceSession } from "../game-slice/useGameSliceSession"; +import { withGameSliceSession } from "../game-slice/gameSliceState"; + +export function GalaxyDemo() { + const { session: sliceSession } = useGameSliceSession(); + const [seed, setSeed] = useState(4242); + const [count, setCount] = useState(120); + const [arms, setArms] = useState(4); + const [verticalArms, setVerticalArms] = useState(0); + const [size, setSize] = useState(280); + const [twist, setTwist] = useState(3); + const [verticalTwist, setVerticalTwist] = useState(1.1); + const [selected, setSelected] = useState(null); + const [selectedPoi, setSelectedPoi] = useState<{ system: GeneratedGalaxySystem; poi: SystemPointOfInterest } | null>(null); + const [destinationPoi, setDestinationPoi] = useState<{ system: GeneratedGalaxySystem; poi: SystemPointOfInterest } | null>(null); + const [routePick, setRoutePick] = useState([]); + + const systems = useMemo(() => generateGalaxy(seed, { count, arms, verticalArms, size, twist, verticalTwist }), [seed, count, arms, verticalArms, size, twist, verticalTwist]); + const connections = useMemo(() => buildGalaxyConnections(systems), [systems]); + const routeIds = routePick.length === 2 ? findGalaxyRoute(routePick[0], routePick[1], connections) : routePick; + + const routePickSystem = (system: GeneratedGalaxySystem) => { + setDestinationPoi(null); + setRoutePick((prev) => prev.length >= 2 ? [system.id] : [...prev, system.id]); + }; + + const navigateToPoi = (system: GeneratedGalaxySystem, poi: SystemPointOfInterest) => { + setSelected(system); + setSelectedPoi({ system, poi }); + setDestinationPoi({ system, poi }); + setRoutePick((prev) => prev[0] && prev[0] !== system.id ? [prev[0], system.id] : [system.id]); + }; + + return ( +
+ { + setSelected(system); + setSelectedPoi(null); + }} + onPoiSelect={(system, poi) => { + setSelected(system); + setSelectedPoi({ system, poi }); + }} + onRoutePick={routePickSystem} + /> +
+
+ + ← Back to Docs + + {sliceSession && ( + + )} +

Galaxy Demo

+ + + + + + + + + +

Click to inspect. Right-click two systems to plot a connected BFS route.

+ {destinationPoi &&

Navigating to {destinationPoi.poi.name} @ {destinationPoi.system.name}

} +
+
+

{selected?.name ?? "No system selected"}

+ {selected && ( + <> +

Faction: {selected.faction}

+

Security: {selected.security.toFixed(2)}

+

Position: {selected.x.toFixed(0)}, {selected.y.toFixed(0)}, {selected.z.toFixed(0)}

+

{selected.planets.length} planets · {selected.pointsOfInterest.length} POIs

+
+ Planets +
+ {selected.planets.map((planet) => ( + + {planet.name} · {planet.type}{planet.moons ? ` · ${planet.moons} moons` : ""} + + ))} +
+
+
+ Points of Interest +
+ {selected.pointsOfInterest.map((poi) => ( + + ))} +
+
+ {selectedPoi?.system.id === selected.id && ( +
+ {selectedPoi.poi.name} +

{selectedPoi.poi.description}

+ +
+ )} + + )} +

Route: {routeIds.length ? routeIds.map((id) => systems.find((system) => system.id === id)?.name ?? id).join(" -> ") : "None"}{destinationPoi ? ` -> ${destinationPoi.poi.name}` : ""}

+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/GameHudDemo.tsx b/src/prototypes/existing-demos/GameHudDemo.tsx new file mode 100644 index 0000000..a42a769 --- /dev/null +++ b/src/prototypes/existing-demos/GameHudDemo.tsx @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useState } from "react"; +import { HudSpaceScene } from "../r3f/hud/HudSpaceScene"; +import { useGameSliceSession } from "../game-slice/useGameSliceSession"; +import { withGameSliceSession } from "../game-slice/gameSliceState"; + +export function GameHudDemo() { + const { session: sliceSession } = useGameSliceSession(); + const [modules, setModules] = useState([ + { id: "h1", name: "150mm Railgun", active: false, type: "weapon" }, + { id: "h2", name: "Missile Launcher", active: false, type: "weapon" }, + { id: "m1", name: "Shield Booster", active: false, type: "shield" }, + { id: "m2", name: "Afterburner", active: false, type: "propulsion" }, + { id: "l1", name: "Damage Control", active: true, type: "armor" }, + ]); + const [filter, setFilter] = useState("all"); + const entities = [ + { id: "e1", name: "Asteroid Belt", type: "asteroid", dist: "12 km" }, + { id: "e2", name: "Guristas Pirate", type: "hostile", dist: "24 km" }, + { id: "e3", name: "CMDR Riker", type: "friendly", dist: "38 km" }, + { id: "e4", name: "Jita IV Station", type: "station", dist: "45 km" }, + { id: "e5", name: "Jump Gate", type: "gate", dist: "120 km" }, + ]; + const visible = useMemo(() => filter === "all" ? entities : entities.filter((entity) => entity.type === filter), [filter]); + const afterburner = modules.some((module) => module.id === "m2" && module.active); + + useEffect(() => { + if (!sliceSession) return; + setModules(sliceSession.fittedModules.map((module) => ({ id: module.id, name: module.name, active: Boolean(module.active), type: module.type }))); + }, [sliceSession]); + + return ( +
+

Game HUD — Live Concept (R3F)

+
+ +
+
+ DOCS + JITA + 1.0 SEC + {sliceSession ? `${sliceSession.currentSystemId.toUpperCase()} · ${sliceSession.dockedStationName ?? "IN SPACE"}` : "USS ENTERPRISE"} + {sliceSession && ISK {sliceSession.wallet.toLocaleString()}} + SPD {afterburner ? "280" : "0"} m/s + {sliceSession && } +
+
+ {["SHIELD", "ARMOR", "HULL", "CAP"].map((label, index) => ( +
+
{label}
+
+
+
+
+ ))} +
+
+
+
Overview
+
+ {["all", "hostile", "asteroid", "station"].map((item) => )} +
+ {sliceSession && ( +
+ Objective: {sliceSession.activeObjectiveId.replace(/_/g, " ")} +
+ )} + {visible.map((entity) => ( +
+ {entity.name}{entity.dist} +
+ ))} +
+
+
+ {sliceSession && ( + <> + + + + + )} + {modules.map((module) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/GameLoopSliceDemo.tsx b/src/prototypes/existing-demos/GameLoopSliceDemo.tsx new file mode 100644 index 0000000..428bb52 --- /dev/null +++ b/src/prototypes/existing-demos/GameLoopSliceDemo.tsx @@ -0,0 +1,5 @@ +import { SeamlessGameLoopSlice } from "../game-slice/SeamlessGameLoopSlice"; + +export function GameLoopSliceDemo() { + return ; +} diff --git a/src/prototypes/existing-demos/MarketDemo.tsx b/src/prototypes/existing-demos/MarketDemo.tsx new file mode 100644 index 0000000..f998034 --- /dev/null +++ b/src/prototypes/existing-demos/MarketDemo.tsx @@ -0,0 +1,1222 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { sellableCargo } from '../game-slice/sliceEconomy'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { api } from '../../data/fakeBackend'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +/* ────────────────────────────────────────────── + Commodities Exchange — Contract Market + ────────────────────────────────────────────── */ + +/* ── Static contract catalogue ── */ +const CATEGORIES = [ + { id: 'all', label: 'All Contracts' }, + { id: 'ore', label: 'Raw Ores' }, + { id: 'mineral', label: 'Refined Minerals' }, + { id: 'gas', label: 'Gas Products' }, + { id: 'isotope', label: 'Isotopes' }, + { id: 'exotic', label: 'Exotic Matter' }, +]; + +const CONTRACTS = [ + /* Ores */ + { symbol:'VLD', name:'Veldspar', category:'ore', lotSize:1000, tickSize:0.01, marginPct:8, basePrice:14, hub:'Jita IV – Moon 4' }, + { symbol:'SCR', name:'Scordite', category:'ore', lotSize:500, tickSize:0.02, marginPct:10, basePrice:32, hub:'Jita IV – Moon 4' }, + { symbol:'PYX', name:'Pyroxeres', category:'ore', lotSize:500, tickSize:0.05, marginPct:10, basePrice:45, hub:'Amarr VIII – Emperor' }, + { symbol:'KRN', name:'Kernite', category:'ore', lotSize:250, tickSize:0.10, marginPct:12, basePrice:90, hub:'Amarr VIII – Emperor' }, + { symbol:'OMB', name:'Omber', category:'ore', lotSize:250, tickSize:0.10, marginPct:12, basePrice:135, hub:'Rens VI – Moon 8' }, + { symbol:'JSP', name:'Jaspet', category:'ore', lotSize:200, tickSize:0.10, marginPct:15, basePrice:190, hub:'Dodixie IX – Moon 20' }, + { symbol:'HEM', name:'Hemorphite', category:'ore', lotSize:100, tickSize:0.50, marginPct:18, basePrice:360, hub:'Jita IV – Moon 4' }, + { symbol:'ARK', name:'Arkonor', category:'ore', lotSize:50, tickSize:1.00, marginPct:20, basePrice:620, hub:'Jita IV – Moon 4' }, + /* Minerals */ + { symbol:'TRI', name:'Tritanium', category:'mineral', lotSize:5000, tickSize:0.01, marginPct:6, basePrice:5, hub:'Jita IV – Moon 4' }, + { symbol:'PYE', name:'Pyerite', category:'mineral', lotSize:2000, tickSize:0.01, marginPct:8, basePrice:12, hub:'Jita IV – Moon 4' }, + { symbol:'MLX', name:'Mexallon', category:'mineral', lotSize:1000, tickSize:0.02, marginPct:8, basePrice:35, hub:'Jita IV – Moon 4' }, + { symbol:'ISO', name:'Isogen', category:'mineral', lotSize:500, tickSize:0.05, marginPct:12, basePrice:110, hub:'Amarr VIII – Emperor' }, + { symbol:'NCX', name:'Nocxium', category:'mineral', lotSize:250, tickSize:0.10, marginPct:15, basePrice:380, hub:'Jita IV – Moon 4' }, + { symbol:'ZDR', name:'Zydrine', category:'mineral', lotSize:100, tickSize:0.50, marginPct:18, basePrice:950, hub:'Jita IV – Moon 4' }, + { symbol:'MEG', name:'Megacyte', category:'mineral', lotSize:50, tickSize:1.00, marginPct:20, basePrice:2800,hub:'Jita IV – Moon 4' }, + { symbol:'MOR', name:'Morphite', category:'mineral', lotSize:10, tickSize:5.00, marginPct:25, basePrice:8500,hub:'Jita IV – Moon 4' }, + /* Gas */ + { symbol:'ATM', name:'Atmospheric', category:'gas', lotSize:2000, tickSize:0.01, marginPct:10, basePrice:8, hub:'Jita IV – Moon 4' }, + { symbol:'NEB', name:'Nebular', category:'gas', lotSize:500, tickSize:0.05, marginPct:12, basePrice:45, hub:'Amarr VIII – Emperor' }, + { symbol:'ION', name:'Ionized', category:'gas', lotSize:200, tickSize:0.10, marginPct:15, basePrice:120, hub:'Jita IV – Moon 4' }, + { symbol:'FUL', name:'Fullerides', category:'gas', lotSize:100, tickSize:0.50, marginPct:18, basePrice:450, hub:'Jita IV – Moon 4' }, + /* Isotopes */ + { symbol:'H3', name:'Hydrogen-3', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:85, hub:'Jita IV – Moon 4' }, + { symbol:'HE4', name:'Helium-4', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:92, hub:'Amarr VIII – Emperor' }, + { symbol:'N15', name:'Nitrogen-15', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:78, hub:'Rens VI – Moon 8' }, + { symbol:'O18', name:'Oxygen-18', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:88, hub:'Dodixie IX – Moon 20' }, + /* Exotic */ + { symbol:'RDB', name:'Reedstone', category:'exotic', lotSize:10, tickSize:5.00, marginPct:25, basePrice:12000,hub:'Jita IV – Moon 4' }, + { symbol:'TCH', name:'Tachyon Salt', category:'exotic', lotSize:5, tickSize:10.0, marginPct:30, basePrice:45000,hub:'Jita IV – Moon 4' }, +]; + +/* ── Sparkline ── */ +function Sparkline({ data, width = 80, height = 24, color = 'var(--green)' }) { + const ref = useRef(null); + useEffect(() => { + const c = ref.current; if (!c || !data || data.length < 2) return; + const ctx = c.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + c.width = width * dpr; c.height = height * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + const min = Math.min(...data); const max = Math.max(...data); + const range = max - min || 1; + const step = width / (data.length - 1); + ctx.beginPath(); + data.forEach((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 4) - 2; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.lineJoin = 'round'; + ctx.stroke(); + const grad = ctx.createLinearGradient(0, 0, 0, height); + const isGreen = color.includes('green'); + grad.addColorStop(0, isGreen ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)'); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath(); + ctx.fillStyle = grad; ctx.fill(); + }, [data, width, height, color]); + return ; +} + +/* ── Ticker Tape ── */ +function TickerTape({ items }) { + const [offset, setOffset] = useState(0); + const raf = useRef(null); + useEffect(() => { + const speed = 0.5; + const tick = () => { + setOffset(o => { const n = o - speed; return n < -3000 ? 0 : n; }); + raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf.current); + }, []); + const dup = [...items, ...items, ...items]; + return ( +
+
+ {dup.map((t, i) => ( + + {t.symbol} + ₢{t.price.toLocaleString()} + = 0 ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}> + {t.change >= 0 ? '▲' : '▼'} {Math.abs(t.changePct).toFixed(1)}% + + + ))} +
+
+ ); +} + +/* ── Category Tabs ── */ +function CategoryTabs({ active, onChange }) { + return ( +
+ {CATEGORIES.map(cat => ( + + ))} +
+ ); +} + +/* ── Contract Board — main table ── */ +function ContractBoard({ commodities, selected, onSelect, category }) { + const filtered = category === 'all' ? commodities : commodities.filter(c => c.category === category); + return ( +
+ {/* Table header */} +
+ TickerNameLastChg% + BidAskVolumeOpen Int. +
+ {/* Rows */} +
+ {filtered.map(c => { + const up = c.change >= 0; + const isSel = selected === c.symbol; + const spread = c.bestAsk - c.bestBid; + return ( +
onSelect(c.symbol)} style={{ + display: 'grid', + gridTemplateColumns: '90px 120px 80px 60px 70px 70px 80px 90px 70px', + gap: '4px', alignItems: 'center', + padding: '7px 12px', cursor: 'pointer', + background: isSel ? 'var(--surface-hover)' : 'transparent', + borderLeft: isSel ? '2px solid var(--accent)' : '2px solid transparent', + borderBottom: '1px solid var(--border)', + transition: 'background var(--transition-fast)', + fontSize: '0.75rem', + }}> + {c.symbol} + {c.name} + ₢{c.price.toLocaleString()} + + {up ? '+' : ''}{c.changePct.toFixed(1)}% + + ₢{c.bestBid} + ₢{c.bestAsk} + {(c.volume / 1000).toFixed(1)}K + {(c.openInterest / 1000).toFixed(1)}K + +
+ ); + })} +
+
+ ); +} + +/* ── Contract Spec Panel ── */ +function ContractSpec({ contract, commodity }) { + if (!contract || !commodity) return null; + const marginPerLot = Math.ceil(commodity.price * contract.lotSize * (contract.marginPct / 100)); + const notionalPerLot = commodity.price * contract.lotSize; + return ( +
+
+ Contract Specification +
+
+ {[ + ['Ticker', contract.symbol, 'var(--accent)'], + ['Unit', contract.name, 'var(--fg-bright)'], + ['Lot Size', contract.lotSize.toLocaleString() + ' units', 'var(--fg-bright)'], + ['Tick Size', '₢' + contract.tickSize, 'var(--fg-bright)'], + ['Margin Req.', contract.marginPct + '%', 'var(--cyan)'], + ['Margin / Lot', '₢' + marginPerLot.toLocaleString(), 'var(--cyan)'], + ['Notional / Lot', '₢' +notionalPerLot.toLocaleString(), 'var(--fg-dim)'], + ['Delivery', contract.hub, 'var(--fg-dim)'], + ['Settlement', commodity.settlement ? '₢' + commodity.settlement : '—', 'var(--fg-dim)'], + ['Expiry', '30 DTE', 'var(--muted)'], + ['Supply', (commodity.supply / 1000).toFixed(0) + 'K/day', 'var(--green)'], + ['Demand', (commodity.demand / 1000).toFixed(0) + 'K/day', commodity.demand > commodity.supply ? 'var(--red)' : 'var(--green)'], + ].map(([label, value, color], i) => ( + + {label} + {value} + + ))} +
+ {/* Supply/Demand bar */} +
+
+ SUPPLY + commodity.supply ? 'var(--red)' : 'var(--green)' }}> + {commodity.demand > commodity.supply ? 'DEFICIT' : 'SURPLUS'}: {Math.abs(commodity.supply - commodity.demand).toLocaleString()} + + DEMAND +
+
+
+
+
+
+
+ ); +} + +/* ── Depth Chart (canvas) ── */ +function DepthChart({ bids, asks, midPrice }) { + const ref = useRef(null); + useEffect(() => { + const c = ref.current; if (!c) return; + const ctx = c.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const W = c.clientWidth; const H = c.clientHeight; + c.width = W * dpr; c.height = H * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + + if (!bids.length || !asks.length) return; + + const maxCum = Math.max( + bids.reduce((s, b) => Math.max(s, b.cumulative), 0), + asks.reduce((s, a) => Math.max(s, a.cumulative), 0) + ); + + const priceRange = midPrice * 0.06; + const pMin = midPrice - priceRange; + const pMax = midPrice + priceRange; + const toX = p => ((p - pMin) / (pMax - pMin)) * W; + const toY = cum => H - (cum / (maxCum * 1.1)) * H; + + /* Grid */ + ctx.strokeStyle = 'rgba(28,42,63,0.4)'; ctx.lineWidth = 0.5; + for (let i = 0; i < 4; i++) { + const y = (H / 3) * i; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + + /* Bids (green area, left of mid) */ + ctx.beginPath(); + ctx.moveTo(toX(bids[0].price), H); + bids.forEach(b => ctx.lineTo(toX(b.price), toY(b.cumulative))); + ctx.lineTo(toX(bids[bids.length - 1].price), H); + ctx.closePath(); + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, 'rgba(34,197,94,0.25)'); bg.addColorStop(1, 'rgba(34,197,94,0.03)'); + ctx.fillStyle = bg; ctx.fill(); + + /* Asks (red area, right of mid) */ + ctx.beginPath(); + ctx.moveTo(toX(asks[0].price), H); + asks.forEach(a => ctx.lineTo(toX(a.price), toY(a.cumulative))); + ctx.lineTo(toX(asks[asks.length - 1].price), H); + ctx.closePath(); + const ag = ctx.createLinearGradient(0, 0, 0, H); + ag.addColorStop(0, 'rgba(239,68,68,0.25)'); ag.addColorStop(1, 'rgba(239,68,68,0.03)'); + ctx.fillStyle = ag; ctx.fill(); + + /* Mid line */ + const mx = toX(midPrice); + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(mx, 0); ctx.lineTo(mx, H); + ctx.strokeStyle = 'var(--accent)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.setLineDash([]); + + /* Price label */ + ctx.fillStyle = 'var(--accent)'; ctx.font = 'bold 9px var(--font-mono)'; + ctx.textAlign = 'center'; + ctx.fillText('₢' + midPrice.toFixed(0), mx, H - 4); + }, [bids, asks, midPrice]); + + return ; +} + +/* ── Order Book ── */ +function OrderBook({ bids, asks, spread, spreadPct }) { + const maxBid = Math.max(...bids.map(b => b.volume)); + const maxAsk = Math.max(...asks.map(a => a.volume)); + return ( +
+
+ + Market Depth + + + Spread: ₢{spread} ({spreadPct.toFixed(2)}%) + +
+
+ PriceSizeCum +
+ {/* Asks (reversed) */} +
+ {[...asks].reverse().map((a, i) => ( +
+
+ ₢{a.price} + {a.volume.toLocaleString()} + {a.cumulative.toLocaleString()} +
+ ))} +
+ {/* Mid */} +
+ + ₢{((bids[0]?.price || 0 + asks[0]?.price || 0) / 2).toLocaleString()} + + MID +
+ {/* Bids */} +
+ {bids.map((b, i) => ( +
+
+ ₢{b.price} + {b.volume.toLocaleString()} + {b.cumulative.toLocaleString()} +
+ ))} +
+
+ ); +} + +/* ── Price Chart (canvas) ── */ +function PriceChart({ data, symbol }) { + const ref = useRef(null); + useEffect(() => { + const c = ref.current; if (!c || !data || data.length < 2) return; + const ctx = c.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const W = c.clientWidth; const H = c.clientHeight; + c.width = W * dpr; c.height = H * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + + const prices = data.map(d => d.close); + const highs = data.map(d => d.high); + const lows = data.map(d => d.low); + const volumes = data.map(d => d.volume); + const allP = [...highs, ...lows]; + const minP = Math.min(...allP); const maxP = Math.max(...allP); + const rangeP = maxP - minP || 1; + const maxV = Math.max(...volumes); + const chartH = H * 0.72; const volH = H * 0.22; const barW = Math.max(2, (W / data.length) - 1); + + /* Grid */ + ctx.strokeStyle = 'rgba(28,42,63,0.5)'; ctx.lineWidth = 0.5; + for (let i = 0; i < 5; i++) { + const y = (chartH / 4) * i; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + ctx.fillStyle = 'var(--muted)'; ctx.font = '8px var(--font-mono)'; + ctx.fillText('₢' + (maxP - (rangeP / 4) * i).toFixed(0), W - 42, y + 9); + } + + /* Volume bars */ + data.forEach((d, i) => { + const x = (i / data.length) * W; + const vh = (d.volume / maxV) * volH; + ctx.fillStyle = d.close >= d.open ? 'rgba(34,197,94,0.18)' : 'rgba(239,68,68,0.18)'; + ctx.fillRect(x, H - vh, barW, vh); + }); + + /* Price line */ + ctx.beginPath(); + data.forEach((d, i) => { + const x = (i / (data.length - 1)) * W; + const y = chartH - ((d.close - minP) / rangeP) * (chartH - 10) - 5; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + const lastC = data[data.length - 1].close; + const firstC = data[0].close; + const lineColor = lastC >= firstC ? '#22c55e' : '#ef4444'; + ctx.strokeStyle = lineColor; ctx.lineWidth = 1.8; ctx.lineJoin = 'round'; ctx.stroke(); + + /* Area fill */ + const lastX = W; + ctx.lineTo(lastX, chartH); ctx.lineTo(0, chartH); ctx.closePath(); + const grad = ctx.createLinearGradient(0, 0, 0, chartH); + grad.addColorStop(0, lastC >= firstC ? 'rgba(34,197,94,0.10)' : 'rgba(239,68,68,0.10)'); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; ctx.fill(); + + /* Current price line */ + const curY = chartH - ((lastC - minP) / rangeP) * (chartH - 10) - 5; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(0, curY); ctx.lineTo(W, curY); + ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = lineColor; + ctx.beginPath(); ctx.roundRect(W - 55, curY - 8, 52, 16, 3); ctx.fill(); + ctx.fillStyle = '#080c14'; ctx.font = 'bold 8px var(--font-mono)'; + ctx.fillText('₢' + lastC.toFixed(0), W - 52, curY + 3); + }, [data, symbol]); + + return ; +} + +/* ── Order Form (commodities-style: lots, margin, long/short) ── */ +function OrderForm({ contract, commodity, credits, positions, onTrade }) { + const [direction, setDirection] = useState('long'); // long or short + const [orderType, setOrderType] = useState('market'); // market, limit, stop + const [lots, setLots] = useState(''); + const [limitPrice, setLimitPrice] = useState(''); + const [error, setError] = useState(''); + const [confirmation, setConfirmation] = useState(null); + + if (!contract || !commodity) return null; + + const effectivePrice = orderType === 'market' ? commodity.price : (parseFloat(limitPrice) || commodity.price); + const numLots = parseInt(lots) || 0; + const totalUnits = numLots * contract.lotSize; + const notional = totalUnits * effectivePrice; + const marginRequired = Math.ceil(notional * (contract.marginPct / 100)); + const commission = Math.ceil(notional * 0.015); + const pos = positions.find(p => p.symbol === contract.symbol); + const existingDirection = pos ? pos.direction : null; + const canAfford = marginRequired + commission <= credits; + + const handleSubmit = () => { + if (numLots <= 0) { setError('Enter lot quantity'); return; } + if (orderType !== 'market' && !limitPrice) { setError('Set limit/stop price'); return; } + if (!canAfford) { setError('Insufficient margin + commission'); return; } + setError(''); + setConfirmation({ direction, orderType, lots: numLots, price: effectivePrice, notional, marginRequired, commission, totalUnits }); + }; + + const confirmTrade = () => { + onTrade(direction, contract.symbol, effectivePrice, numLots, totalUnits, marginRequired, commission); + setConfirmation(null); setLots(''); setLimitPrice(''); setError(''); + }; + + return ( +
+
+ Place Order — {contract.symbol} + Lot: {contract.lotSize.toLocaleString()} units +
+
+ {/* Long / Short toggle */} +
+ {['long', 'short'].map(d => ( + + ))} +
+ + {/* Order type pills */} +
+ {['market', 'limit', 'stop'].map(t => ( + + ))} +
+ + {/* Limit/Stop price */} + {orderType !== 'market' && ( +
+ + setLimitPrice(e.target.value)} + placeholder={`Spot: ₢${commodity.price}`} + style={{ + width: '100%', padding: '7px 10px', background: 'var(--bg)', + border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', + color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', + outline: 'none', boxSizing: 'border-box', + }} + /> +
+ )} + + {/* Lots */} +
+ +
+ setLots(e.target.value)} + placeholder="0" min="1" + style={{ + flex: 1, padding: '7px 10px', background: 'var(--bg)', + border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', + color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', outline: 'none', + }} + /> + {[1, 5, 10, 25].map(n => ( + + ))} +
+
+ + {/* Summary */} +
+
+ Price / Unit₢{effectivePrice.toLocaleString()} +
+
+ Total Units{totalUnits.toLocaleString()} +
+
+ Notional Value₢{notional.toLocaleString()} +
+
+ Margin ({contract.marginPct}%)₢{marginRequired.toLocaleString()} +
+
+ Commission₢{commission.toLocaleString()} +
+
+ Required + + ₢{(marginRequired + commission).toLocaleString()} + +
+
+ + {error &&
{error}
} + + +
+ + {/* Confirmation */} + {confirmation && ( +
setConfirmation(null)}> +
e.stopPropagation()}> +

Confirm {confirmation.orderType} Order

+
+
Direction: {confirmation.direction.toUpperCase()}
+
Contract: {contract.symbol} ({contract.name})
+
Lots: {confirmation.lots} ({confirmation.totalUnits.toLocaleString()} units)
+
Price: ₢{confirmation.price.toLocaleString()}/unit
+
Notional: ₢{confirmation.notional.toLocaleString()}
+
Margin: ₢{confirmation.marginRequired.toLocaleString()}
+
Commission: ₢{confirmation.commission.toLocaleString()}
+
+
+ + +
+
+
+ )} +
+ ); +} + +/* ── Positions Panel (margin account + open positions) ── */ +function PositionsPanel({ positions, commodities, credits, usedMargin }) { + const totalUnrealized = positions.reduce((sum, p) => { + const c = commodities.find(c => c.symbol === p.symbol); + if (!c) return sum; + const currentValue = c.price * p.totalUnits; + const entryValue = p.avgEntry * p.totalUnits; + const diff = p.direction === 'long' ? currentValue - entryValue : entryValue - currentValue; + return sum + diff; + }, 0); + + const totalPositionValue = positions.reduce((sum, p) => { + const c = commodities.find(c => c.symbol === p.symbol); + return sum + (c ? c.price * p.totalUnits : 0); + }, 0); + + const marginUtilization = credits > 0 ? ((usedMargin / credits) * 100) : 0; + + return ( +
+
+ Open Positions + = 0 ? 'var(--green)' : 'var(--red)' }}> + {totalUnrealized >= 0 ? '▲' : '▼'} ₢{Math.abs(totalUnrealized).toLocaleString()} + +
+ + {/* Account summary */} +
+
+
+
Balance
+
₢{credits.toLocaleString()}
+
+
+
Used Margin
+
₢{usedMargin.toLocaleString()}
+
+
+
Exposure
+
₢{totalPositionValue.toLocaleString()}
+
+
+ + {/* Margin utilization bar */} +
+
+ MARGIN UTILIZATION + 80 ? 'var(--red)' : marginUtilization > 50 ? 'var(--accent)' : 'var(--green)' }}> + {marginUtilization.toFixed(1)}% + +
+
+
80 ? 'var(--red)' : marginUtilization > 50 ? 'var(--accent)' : 'var(--green)', + transition: 'width 0.3s ease', + }} /> +
+
+ + {/* Position rows */} + {positions.length === 0 && ( +
+ No open positions +
+ )} + {positions.map((p, i) => { + const c = commodities.find(c => c.symbol === p.symbol); + if (!c) return null; + const currentVal = c.price * p.totalUnits; + const entryVal = p.avgEntry * p.totalUnits; + const pnl = p.direction === 'long' ? currentVal - entryVal : entryVal - currentVal; + const pnlPct = entryVal > 0 ? (pnl / p.margin) * 100 : 0; + const contract = CONTRACTS.find(ct => ct.symbol === p.symbol); + return ( +
+
+
+ {p.symbol} + + {p.direction === 'long' ? 'LONG' : 'SHORT'} + +
+ = 0 ? 'var(--green)' : 'var(--red)', + }}> + {pnl >= 0 ? '+' : ''}₢{pnl.toLocaleString()} + +
+
+ {p.lots} lot{p.lots > 1 ? 's' : ''} ({p.totalUnits.toLocaleString()} u.) @ ₢{p.avgEntry} + Margin: ₢{p.margin.toLocaleString()} +
+
+
= 0 ? 'var(--green)' : 'var(--red)', + opacity: 0.6, + }} /> +
+
+ ); + })} +
+
+ ); +} + +/* ── Trade Feed ── */ +function TradeFeed({ trades }) { + return ( +
+
+ Market Trades +
+
+ {trades.map((t, i) => ( +
+ {t.time} + {t.lots}× {t.symbol} + ₢{t.price.toLocaleString()} + + {t.side === 'long' ? 'BID' : 'ASK'} + +
+ ))} +
+
+ ); +} + +/* ────────────────────────────────────────────── + Main MarketDemo component + ────────────────────────────────────────────── */ +export function MarketDemo() { + const [credits, setCredits] = useState(250000); + const { session: sliceSession, emit } = useGameSliceSession(); + const [selectedSymbol, setSelectedSymbol] = useState('VLD'); + const [category, setCategory] = useState('all'); + const [notifications, setNotifications] = useState([]); + + /* Generate live commodity data */ + const [commodities, setCommodities] = useState(() => { + return CONTRACTS.map(ct => { + const history = []; let p = ct.basePrice; + for (let i = 0; i < 60; i++) { + p = Math.max(ct.basePrice * 0.7, Math.min(ct.basePrice * 1.3, p + (Math.random() - 0.48) * ct.basePrice * 0.04)); + history.push(Math.round(p * 100) / 100); + } + const price = history[history.length - 1]; + const prev = history[history.length - 2]; + const settlement = history[Math.floor(history.length / 2)]; + const bestBid = Math.round((price - ct.basePrice * 0.005) * 100) / 100; + const bestAsk = Math.round((price + ct.basePrice * 0.005) * 100) / 100; + return { + ...ct, + price, prevPrice: prev, + change: price - prev, + changePct: ((price - prev) / prev) * 100, + history, settlement, bestBid, bestAsk, + volume: Math.floor(Math.random() * 80000 + 5000), + openInterest: Math.floor(Math.random() * 200000 + 10000), + open: history[0], + high: Math.max(...history), + low: Math.min(...history), + supply: Math.floor(Math.random() * 50000 + 10000), + demand: Math.floor(Math.random() * 60000 + 8000), + }; + }); + }); + + /* Open positions */ + const [positions, setPositions] = useState([ + { symbol: 'VLD', direction: 'long', lots: 5, totalUnits: 5000, avgEntry: 13.20, margin: 5280 }, + { symbol: 'TRI', direction: 'long', lots: 3, totalUnits: 15000, avgEntry: 4.80, margin: 2160 }, + { symbol: 'ARK', direction: 'short', lots: 2, totalUnits: 100, avgEntry: 635, margin: 25400 }, + ]); + + /* Order book for selected */ + const selected = commodities.find(c => c.symbol === selectedSymbol) || commodities[0]; + const selectedContract = CONTRACTS.find(c => c.symbol === selectedSymbol) || CONTRACTS[0]; + const orderBook = useMemo(() => { + const p = selected.price; + const bids = []; const asks = []; + let bidCum = 0; let askCum = 0; + for (let i = 0; i < 10; i++) { + const bv = Math.floor(Math.random() * 12000 + 500); bidCum += bv; + bids.push({ price: Math.max(0.01, Math.round((p - (i + 1) * selectedContract.tickSize * 3) * 100) / 100), volume: bv, cumulative: bidCum }); + const av = Math.floor(Math.random() * 12000 + 500); askCum += av; + asks.push({ price: Math.round((p + (i + 1) * selectedContract.tickSize * 3) * 100) / 100, volume: av, cumulative: askCum }); + } + return { bids, asks }; + }, [selected.price, selectedSymbol, selectedContract.tickSize]); + + const usedMargin = positions.reduce((s, p) => s + p.margin, 0); + + /* Price chart data */ + const chartData = useMemo(() => { + return selected.history.map((c, i) => { + const noise = selected.price * 0.01; + return { + open: selected.history[Math.max(0, i - 1)] || c, + high: c + Math.random() * noise * 2, + low: Math.max(0.01, c - Math.random() * noise * 2), + close: c, + volume: Math.floor(Math.random() * 5000 + 500), + }; + }); + }, [selected]); + + /* Trade feed */ + const [tradeFeed, setTradeFeed] = useState(() => { + const feed = []; + const syms = CONTRACTS.map(c => c.symbol); + for (let i = 0; i < 20; i++) { + const sym = syms[Math.floor(Math.random() * syms.length)]; + const ct = CONTRACTS.find(c => c.symbol === sym); + const c = commodities.find(c => c.symbol === sym); + feed.push({ + time: `${14}:${String(22 + i).padStart(2, '0')}`, + symbol: sym, + lots: Math.floor(Math.random() * 10 + 1), + price: c ? Math.round(c.price * 100) / 100 : ct.basePrice, + side: Math.random() > 0.5 ? 'long' : 'short', + }); + } + return feed.reverse(); + }); + + /* Live price ticks */ + useEffect(() => { + const interval = setInterval(() => { + setCommodities(prev => prev.map(c => { + const ct = CONTRACTS.find(x => x.symbol === c.symbol); + const delta = (Math.random() - 0.48) * c.price * 0.006; + const newPrice = Math.max(0.01, Math.round((c.price + delta) * 100) / 100); + const history = [...c.history.slice(1), newPrice]; + const change = newPrice - c.prevPrice; + const spread = ct.basePrice * 0.005; + return { + ...c, price: newPrice, history, + volume: c.volume + Math.floor(Math.random() * 300), + change, changePct: c.prevPrice > 0 ? (change / c.prevPrice) * 100 : 0, + high: Math.max(c.high, newPrice), low: Math.min(c.low, newPrice), + bestBid: Math.round((newPrice - spread) * 100) / 100, + bestAsk: Math.round((newPrice + spread) * 100) / 100, + openInterest: c.openInterest + Math.floor(Math.random() * 200 - 80), + supply: c.supply + Math.floor(Math.random() * 200 - 100), + demand: c.demand + Math.floor(Math.random() * 200 - 80), + }; + })); + }, 2500); + return () => clearInterval(interval); + }, []); + + /* New trades in feed */ + useEffect(() => { + const interval = setInterval(() => { + const syms = CONTRACTS.map(c => c.symbol); + const sym = syms[Math.floor(Math.random() * syms.length)]; + const c = commodities.find(c => c.symbol === sym); + const ct = CONTRACTS.find(c => c.symbol === sym); + const now = new Date(); + setTradeFeed(prev => [{ + time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`, + symbol: sym, + lots: Math.floor(Math.random() * 15 + 1), + price: c ? c.price : ct.basePrice, + side: Math.random() > 0.5 ? 'long' : 'short', + }, ...prev].slice(0, 40)); + }, 3000); + return () => clearInterval(interval); + }, [commodities]); + + const addNotif = (msg, color) => { + const id = Date.now(); + setNotifications(prev => [...prev, { id, msg, color }]); + setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3500); + }; + + const handleTrade = (direction, symbol, price, lots, totalUnits, marginRequired, commission) => { + /* Deduct commission + margin from credits */ + setCredits(prev => prev - commission - marginRequired); + + setPositions(prev => { + const existing = prev.find(p => p.symbol === symbol && p.direction === direction); + if (existing) { + /* Merge into existing position — weighted average entry */ + const newUnits = existing.totalUnits + totalUnits; + const newAvg = Math.round(((existing.avgEntry * existing.totalUnits) + (price * totalUnits)) / newUnits * 100) / 100; + const newMargin = existing.margin + marginRequired; + return prev.map(p => p.symbol === symbol && p.direction === direction + ? { ...p, lots: p.lots + lots, totalUnits: newUnits, avgEntry: newAvg, margin: newMargin } + : p); + } + return [...prev, { symbol, direction, lots, totalUnits, avgEntry: price, margin: marginRequired }]; + }); + + addNotif( + `${direction === 'long' ? 'LONG' : 'SHORT'} ${lots}× ${symbol} (${totalUnits.toLocaleString()} u.) @ ₢${price}`, + direction === 'long' ? 'var(--green)' : 'var(--red)' + ); + + const now = new Date(); + setTradeFeed(prev => [{ + time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`, + symbol, lots, price, side: direction, + }, ...prev].slice(0, 40)); + }; + + const handleSliceSell = (cargoItem) => { + if (!sliceSession?.dockedStationPoiId) return; + const isk = cargoItem.quantity * cargoItem.unitPrice; + emit({ type: 'market.sold', item: cargoItem.item, quantity: cargoItem.quantity, isk }); + emit({ type: 'xp.awarded', skill: 'Trade', amount: 30 }); + emit({ type: 'zora.observed', trigger: 'market.sold' }); + addNotif(`Sold ${cargoItem.quantity.toLocaleString()} ${cargoItem.item} for ₢${isk.toLocaleString()}.`, 'var(--green)'); + }; + + /* Market stats */ + const totalVolume = commodities.reduce((s, c) => s + c.volume, 0); + const totalOI = commodities.reduce((s, c) => s + c.openInterest, 0); + const advancers = commodities.filter(c => c.change >= 0).length; + const decliners = commodities.length - advancers; + const tickerItems = commodities.slice(0, 12).map(c => ({ + symbol: c.symbol, price: c.price, change: c.change, changePct: c.changePct, + })); + + const spread = selectedContract ? Math.round((selected.bestAsk - selected.bestBid) * 100) / 100 : 0; + const spreadPct = selected.bestBid > 0 ? (spread / selected.bestBid) * 100 : 0; + const midPrice = Math.round(((selected.bestBid + selected.bestAsk) / 2) * 100) / 100; + + return ( +
+ e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}>← Back to Docs + {/* Ticker */} + + + {/* Header bar */} +
+ COMMODITIES EXCHANGE +
+ SESSION + OPEN +
+ {advancers} ▲ + {decliners} ▼ +
+ TOTAL VOL + {(totalVolume / 1e6).toFixed(2)}M +
+ OPEN INT. + {(totalOI / 1e6).toFixed(1)}M +
+ ACCOUNT (₢ = ISK) + ₢{credits.toLocaleString()} +
+ + {/* Category tabs */} + + + {sliceSession && ( +
+
+ SLICE CARGO SALES + Station: {sliceSession.dockedStationName || 'Undocked'} + Wallet ₢{sliceSession.wallet.toLocaleString()} + +
+
+ {sellableCargo(sliceSession.cargo).length === 0 && ( +
No sellable slice cargo.
+ )} + {sellableCargo(sliceSession.cargo).map(item => ( +
+
+
{item.item}
+
{item.quantity.toLocaleString()} × ₢{item.unitPrice}
+
+ +
+ ))} +
+
+ )} + + {/* Notifications */} +
+ {notifications.map(n => ( +
+ {n.msg} +
+ ))} +
+ + {/* Main grid: Left (board + chart + feed) | Right (specs + depth + order + positions) */} +
+ + {/* Left column */} +
+ {/* Contract board */} + + + {/* Price chart */} +
+
+
+ {selected.symbol} + {selected.name} +
+
+ + O:₢{selected.open} H:₢{selected.high} L:₢{selected.low} + + + ₢{selected.price.toLocaleString()} + + = 0 ? 'var(--green)' : 'var(--red)', + }}> + {selected.change >= 0 ? '+' : ''}{selected.change.toFixed(2)} ({selected.changePct >= 0 ? '+' : ''}{selected.changePct.toFixed(2)}%) + +
+
+
+ +
+
+ + {/* Trade feed */} + +
+ + {/* Right column */} +
+ {/* Contract spec */} + + + {/* Depth chart */} +
+
+ Depth of Market +
+ +
+ + {/* Order book */} + + + {/* Order form */} + + + {/* Positions */} + +
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/ProgressionDemo.tsx b/src/prototypes/existing-demos/ProgressionDemo.tsx new file mode 100644 index 0000000..9384aa5 --- /dev/null +++ b/src/prototypes/existing-demos/ProgressionDemo.tsx @@ -0,0 +1,417 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../data/fakeBackend'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +export function ProgressionDemo() { + const { session: sliceSession } = useGameSliceSession(); + const [skills, setSkills] = useState([]); + const [activeCategory, setActiveCategory] = useState('all'); + const [selectedSkill, setSelectedSkill] = useState(null); + const [totalXP, setTotalXP] = useState(0); + const [xpLog, setXpLog] = useState([]); + const [simulating, setSimulating] = useState(false); + const simRef = useRef(null); + + const allSkills = [ + // Combat + { name: 'Gunnery', category: 'Combat', xp: 380, level: 2, nextLevel: 500 }, + { name: 'Missiles', category: 'Combat', xp: 120, level: 1, nextLevel: 500 }, + { name: 'Shield Operation', category: 'Combat', xp: 50, level: 0, nextLevel: 100 }, + { name: 'Armor Tanking', category: 'Combat', xp: 0, level: 0, nextLevel: 100 }, + { name: 'Electronic Warfare', category: 'Combat', xp: 0, level: 0, nextLevel: 100 }, + // Industry + { name: 'Mining', category: 'Industry', xp: 1850, level: 3, nextLevel: 2000 }, + { name: 'Refining', category: 'Industry', xp: 420, level: 2, nextLevel: 500 }, + { name: 'Manufacturing', category: 'Industry', xp: 80, level: 0, nextLevel: 100 }, + { name: 'Blueprint Research', category: 'Industry', xp: 0, level: 0, nextLevel: 100 }, + // Navigation + { name: 'Warp Drive Operation', category: 'Navigation', xp: 60, level: 0, nextLevel: 100 }, + { name: 'Afterburner', category: 'Navigation', xp: 30, level: 0, nextLevel: 100 }, + { name: 'Evasive Maneuvering', category: 'Navigation', xp: 0, level: 0, nextLevel: 100 }, + // Trade + { name: 'Market Analysis', category: 'Trade', xp: 20, level: 0, nextLevel: 100 }, + { name: 'Broker Relations', category: 'Trade', xp: 45, level: 0, nextLevel: 100 }, + { name: 'Hauling', category: 'Trade', xp: 0, level: 0, nextLevel: 100 }, + // Leadership + { name: 'Fleet Command', category: 'Leadership', xp: 0, level: 0, nextLevel: 100 }, + { name: 'AI Coordination', category: 'Leadership', xp: 0, level: 0, nextLevel: 100 }, + ]; + + const xpCurve = [100, 500, 2000, 8000, 32000]; + const categoryColors = { + Combat: 'var(--red)', + Industry: 'var(--accent)', + Navigation: 'var(--cyan)', + Trade: 'var(--green)', + Leadership: 'var(--purple)', + }; + + const xpActions = [ + { name: 'Mining Cycle', xp: 15, category: 'Industry', desc: 'Complete a mining laser cycle' }, + { name: 'NPC Kill', xp: 40, category: 'Combat', desc: 'Destroy an NPC pirate' }, + { name: 'Refine Batch', xp: 25, category: 'Industry', desc: 'Refine a batch of ore' }, + { name: 'System Jump', xp: 5, category: 'Navigation', desc: 'Jump to a new system' }, + { name: 'Market Trade', xp: 20, category: 'Trade', desc: 'Complete a market transaction' }, + { name: 'Player Kill', xp: 120, category: 'Combat', desc: 'Destroy a player ship' }, + { name: 'Manufacture Item', xp: 35, category: 'Industry', desc: 'Complete a manufacturing job' }, + { name: 'Waypoint Route', xp: 30, category: 'Navigation', desc: 'Complete a multi-jump route' }, + { name: 'Bounty Collect', xp: 80, category: 'Combat', desc: 'Collect a bounty reward' }, + ]; + + useEffect(() => { + setSkills(allSkills.map(s => ({ ...s }))); + }, []); + + useEffect(() => { + const total = skills.reduce((sum, s) => sum + s.xp, 0); + setTotalXP(total); + }, [skills]); + + const filteredSkills = activeCategory === 'all' + ? skills + : skills.filter(s => s.category === activeCategory); + + const categoryStats = Object.keys(categoryColors).map(cat => { + const catSkills = skills.filter(s => s.category === cat); + const totalXP = catSkills.reduce((sum, s) => sum + s.xp, 0); + const maxXP = catSkills.reduce((sum, s) => sum + xpCurve[Math.min(s.level, 4)], 0); + const avgLevel = catSkills.length > 0 ? catSkills.reduce((sum, s) => sum + s.level, 0) / catSkills.length : 0; + return { category: cat, color: categoryColors[cat], totalXP, maxXP, avgLevel, count: catSkills.length }; + }); + + const handleSimulate = useCallback(() => { + if (simulating) { + setSimulating(false); + if (simRef.current) clearInterval(simRef.current); + return; + } + setSimulating(true); + simRef.current = setInterval(() => { + const action = xpActions[Math.floor(Math.random() * xpActions.length)]; + const skillName = action.category === 'Combat' ? 'Gunnery' : + action.category === 'Industry' ? 'Mining' : + action.category === 'Navigation' ? 'Warp Drive Operation' : + action.category === 'Trade' ? 'Broker Relations' : 'Fleet Command'; + + setSkills(prev => prev.map(s => { + if (s.name !== skillName) return s; + let newXp = s.xp + action.xp; + let newLevel = s.level; + while (newLevel < 5 && newXp >= xpCurve[newLevel]) { + newXp -= xpCurve[newLevel]; + newLevel++; + } + return { ...s, xp: newXp, level: newLevel, nextLevel: xpCurve[Math.min(newLevel, 4)] }; + })); + + setXpLog(prev => [{ + action: action.name, + xp: action.xp, + skill: skillName, + time: new Date().toLocaleTimeString('en', { hour12: false }), + }, ...prev.slice(0, 19)]); + }, 800); + }, [simulating]); + + useEffect(() => { + return () => { if (simRef.current) clearInterval(simRef.current); }; + }, []); + + const levelColor = (lvl) => { + if (lvl === 0) return 'var(--muted)'; + if (lvl === 1) return 'var(--green)'; + if (lvl === 2) return 'var(--cyan)'; + if (lvl === 3) return 'var(--purple)'; + if (lvl === 4) return 'var(--accent)'; + return 'var(--red)'; + }; + + const sessionXp = sliceSession ? ['Mining', 'Industry', 'Trade', 'Gunnery', 'Navigation'].map(skill => ({ + skill, + ...(sliceSession.skills[skill] || { level: 0, xp: 0, nextLevel: 100 }), + })) : []; + + return ( +
+ e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}>← Back to Docs +

Skill Progression Demo

+

+ Action-based XP system across 5 categories and 17+ skills. Hit the simulate button to watch + XP flow in from random activities — each action awards XP to the matching skill category. +

+ {sliceSession && ( +
+ SESSION XP + {sliceSession.eventLog.filter(e => e.event.type === 'xp.awarded').length} XP events in shared loop log. + +
+ )} + + {/* HUD-style progression strip */} +
+ SKILL PROGRESSION +
+ TOTAL XP + {totalXP.toLocaleString()} +
+ TRAINED + {skills.filter(s => s.level > 0).length}/{skills.length} +
+ MAX LVL + {Math.max(...skills.map(s => s.level))} + {simulating && ● SIMULATING} +
+ + {/* Stats */} +
+
+
{totalXP.toLocaleString()}
+
Total XP
+
+
+
+ {skills.filter(s => s.level > 0).length}/{skills.length} +
+
Skills Trained
+
+
+
+ {Math.max(...skills.map(s => s.level))} +
+
Highest Level
+
+
+
{xpLog.length}
+
Actions (session)
+
+
+ + {/* Simulate button */} + {sliceSession && ( +
+

Session XP

+
+ {sessionXp.map(item => ( +
+
+ {item.skill} + Lvl {item.level} +
+
+
+
+
{item.xp} / {item.nextLevel} XP
+
+ ))} +
+
+ )} + + {/* Simulate button */} +
+ + + Generates random XP actions every 800ms + +
+ + {/* Category overview */} +
+ {categoryStats.map(cat => ( +
setActiveCategory(activeCategory === cat.category ? 'all' : cat.category)}> +
+

{cat.category}

+ + avg Lvl {cat.avgLevel.toFixed(1)} + +
+
+
+
0 ? (cat.totalXP / (cat.maxXP * 2)) * 100 : 0}%`, + background: cat.color, + }} /> +
+
+ {cat.totalXP.toLocaleString()} XP · {cat.count} skills +
+
+
+ ))} +
+ +
+ + {Object.keys(categoryColors).map(cat => ( + + ))} +
+ +
+ {/* Skill tree */} +
+ {filteredSkills.map((skill, i) => { + const progress = skill.level >= 5 ? 100 : (skill.xp / skill.nextLevel) * 100; + return ( +
setSelectedSkill(skill)}> +
+
+ {skill.name} + + Lvl {skill.level} + +
+ + {skill.category} + +
+
+
+
+
+ {skill.level >= 5 ? 'MAX' : `${skill.xp.toLocaleString()} / ${skill.nextLevel.toLocaleString()} XP`} +
+
+ ); + })} +
+ + {/* XP log + detail */} +
+ {/* Selected skill detail */} + {selectedSkill && ( +
+

+ {selectedSkill.name} +

+
+
+ Current Level + + Level {selectedSkill.level}{selectedSkill.level >= 5 ? ' (MAX)' : ''} + +
+
+ Category + {selectedSkill.category} +
+
+ XP to Next Level + + {selectedSkill.level >= 5 ? '—' : `${selectedSkill.xp.toLocaleString()} / ${selectedSkill.nextLevel.toLocaleString()}`} + +
+
+ + {/* Level milestone visualization */} +
+ {[0, 1, 2, 3, 4].map(lvl => ( +
lvl ? categoryColors[selectedSkill.category] + '20' : 'var(--surface-raised)', + border: `1px solid ${selectedSkill.level > lvl ? categoryColors[selectedSkill.category] + '40' : 'var(--border)'}`, + borderRadius: 'var(--radius-md)', + }}> +
lvl ? levelColor(lvl + 1) : 'var(--muted)' }}> + {lvl + 1} +
+
+ {xpCurve[lvl].toLocaleString()} XP +
+
+ ))} +
+
+ )} + + {/* XP activity log */} +
+

XP Activity Log

+
+ {xpLog.length === 0 && ( +
+ Start the simulation to see XP flow in real-time. +
+ )} + {xpLog.map((entry, i) => ( +
+ + {entry.time} + + {entry.action} + + +{entry.xp} XP + + → {entry.skill} +
+ ))} +
+
+ + {/* XP actions reference */} +
+

XP Sources

+
+ {xpActions.map((action, i) => ( +
+
+ {action.name} + + {action.desc} + +
+
+ + {action.category} + + + +{action.xp} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/RefiningDemo.tsx b/src/prototypes/existing-demos/RefiningDemo.tsx new file mode 100644 index 0000000..9727ab5 --- /dev/null +++ b/src/prototypes/existing-demos/RefiningDemo.tsx @@ -0,0 +1,556 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../data/fakeBackend'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +export function RefiningDemo() { + const { session: sliceSession, emit } = useGameSliceSession(); + const [inventory, setInventory] = useState([]); + const [orePrices, setOrePrices] = useState({}); + const [selectedOre, setSelectedOre] = useState(null); + const [refineQty, setRefineQty] = useState(0); + const [skillLevel, setSkillLevel] = useState(2); + const [refining, setRefining] = useState(false); + const [results, setResults] = useState([]); + const [manufacturingTab, setManufacturingTab] = useState(false); + const [manufacturingJobs, setManufacturingJobs] = useState([]); + const [notifications, setNotifications] = useState([]); + const timerRef = useRef(null); + + const mineralData = { + Veldspar: { mineral: 'Tritanium', yield: 415, batch: 333, time: 45 }, + Scordite: { mineral: 'Pyerite', yield: 171, batch: 333, time: 45 }, + Pyroxeres: { mineral: 'Nocxium', yield: 8, batch: 333, time: 60 }, + Kernite: { mineral: 'Isogen', yield: 107, batch: 200, time: 60 }, + Omber: { mineral: 'Isogen', yield: 86, batch: 500, time: 75 }, + Jaspet: { mineral: 'Zydrine', yield: 8, batch: 500, time: 75 }, + Hemorphite: { mineral: 'Nocxium', yield: 21, batch: 500, time: 90 }, + Arkonor: { mineral: 'Megacyte', yield: 18, batch: 200, time: 120 }, + }; + + const manufacturingRecipes = [ + { id: 1, product: 'Mining Laser I', minerals: { Tritanium: 200, Pyerite: 80 }, time: 300, skill: 1 }, + { id: 2, product: '150mm Railgun', minerals: { Tritanium: 400, Pyerite: 150, Nocxium: 20 }, time: 900, skill: 2 }, + { id: 3, product: 'Shield Booster I', minerals: { Tritanium: 300, Isogen: 50 }, time: 600, skill: 2 }, + { id: 4, product: 'Frigate Hull', minerals: { Tritanium: 2000, Pyerite: 800, Nocxium: 100 }, time: 1800, skill: 3 }, + { id: 5, product: '1MN Afterburner', minerals: { Tritanium: 150, Pyerite: 50, Isogen: 20 }, time: 480, skill: 2 }, + ]; + + const [playerMinerals, setPlayerMinerals] = useState({ + Tritanium: 0, Pyerite: 0, Nocxium: 0, Isogen: 0, Zydrine: 0, Megacyte: 0, + }); + + const skillEfficiency = { 0: 0.50, 1: 0.60, 2: 0.70, 3: 0.80, 4: 0.875, 5: 0.95 }; + + useEffect(() => { + api.getPlayerInventory().then(i => { + if (sliceSession) return; + setInventory(i); + if (i.length > 0) { setSelectedOre(i[0].item); setRefineQty(i[0].quantity); } + }); + api.getOrePrices().then(p => setOrePrices(p)); + }, [sliceSession]); + + useEffect(() => { + if (!sliceSession) return; + const ore = sliceSession.cargo.filter(item => item.category === 'ore').map(item => ({ item: item.item, quantity: item.quantity, unitPrice: item.unitPrice })); + setInventory(ore); + if (ore.length > 0) { + setSelectedOre(ore[0].item); + setRefineQty(ore[0].quantity); + } else { + setSelectedOre(null); + setRefineQty(0); + } + const minerals = {}; + sliceSession.cargo.filter(item => item.category === 'mineral').forEach(item => { minerals[item.item] = item.quantity; }); + setPlayerMinerals(prev => ({ ...prev, ...minerals })); + }, [sliceSession]); + + const addNotif = useCallback((msg, color) => { + const id = Date.now(); + setNotifications(prev => [...prev, { id, msg, color }]); + setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3500); + }, []); + + const handleRefine = useCallback(async () => { + if (!selectedOre || refineQty <= 0) return; + const data = mineralData[selectedOre]; + if (!data) return; + if (refineQty < data.batch) { + addNotif(`Need at least ${data.batch} units for a batch.`, 'var(--red)'); + return; + } + + setRefining(true); + const inv = inventory.find(i => i.item === selectedOre); + const batches = Math.floor(refineQty / data.batch); + const used = batches * data.batch; + const eff = skillEfficiency[skillLevel]; + const mineralYield = Math.floor(batches * data.yield * eff); + const rawValue = used * (orePrices[selectedOre] || 0); + const mineralValue = mineralYield * Math.floor((orePrices[selectedOre] || 0) * 2.5); + + // Simulate delay + await new Promise(r => setTimeout(r, data.time * 10)); + + setPlayerMinerals(prev => ({ + ...prev, + [data.mineral]: prev[data.mineral] + mineralYield, + })); + + setInventory(prev => prev.map(i => + i.item === selectedOre + ? { ...i, quantity: i.quantity - used } + : i + ).filter(i => i.quantity > 0)); + + setResults(prev => [{ + ore: selectedOre, + batches, + used, + mineral: data.mineral, + yield: mineralYield, + efficiency: eff, + rawValue, + mineralValue, + better: mineralValue > rawValue, + }, ...prev.slice(0, 9)]); + + setRefining(false); + if (sliceSession) { + emit({ type: 'refining.completed', ore: selectedOre, inputQuantity: used, minerals: [{ item: data.mineral, category: 'mineral', quantity: mineralYield, unitPrice: Math.floor((orePrices[selectedOre] || 0) * 2.5) }] }); + emit({ type: 'xp.awarded', skill: 'Industry', amount: 35 }); + } + addNotif(`Refined ${used.toLocaleString()} ${selectedOre} → ${mineralYield.toLocaleString()} ${data.mineral} (${(eff * 100).toFixed(0)}% eff)`, 'var(--green)'); + }, [selectedOre, refineQty, skillLevel, inventory, orePrices, addNotif]); + + const handleManufacture = useCallback((recipe) => { + if (skillLevel < recipe.skill) { + addNotif(`Need Industry level ${recipe.skill} to manufacture ${recipe.product}.`, 'var(--red)'); + return; + } + // Check minerals + for (const [mineral, qty] of Object.entries(recipe.minerals)) { + if ((playerMinerals[mineral] || 0) < qty) { + addNotif(`Not enough ${mineral}. Need ${qty}, have ${playerMinerals[mineral] || 0}.`, 'var(--red)'); + return; + } + } + // Deduct minerals + setPlayerMinerals(prev => { + const next = { ...prev }; + for (const [mineral, qty] of Object.entries(recipe.minerals)) { + next[mineral] -= qty; + } + return next; + }); + + const job = { + id: Date.now(), + product: recipe.product, + totalTime: recipe.time, + remaining: recipe.time, + started: Date.now(), + }; + setManufacturingJobs(prev => [...prev, job]); + addNotif(`Manufacturing job started: ${recipe.product}. ETA: ${Math.floor(recipe.time / 60)}m ${recipe.time % 60}s`, 'var(--cyan)'); + }, [skillLevel, playerMinerals, addNotif]); + + // Manufacturing timer + useEffect(() => { + const interval = setInterval(() => { + setManufacturingJobs(prev => { + const updated = prev.map(j => ({ + ...j, + remaining: Math.max(0, j.remaining - 1), + })); + const completed = updated.filter(j => j.remaining <= 0 && prev.find(p => p.id === j.id && p.remaining > 0)); + completed.forEach(j => { + addNotif(`Manufacturing complete: ${j.product}`, 'var(--green)'); + }); + return updated.filter(j => j.remaining > 0); + }); + }, 1000); + return () => clearInterval(interval); + }, [addNotif]); + + const formatTime = (s) => `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`; + + return ( +
+ e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}>← Back to Docs +

Refining & Manufacturing Demo

+

+ Refine raw ore into minerals, then use minerals to manufacture ships and modules. + Industry skill level determines refining efficiency — higher skill means more minerals per batch. +

+ {sliceSession && ( +
+ SLICE REFINING + {sliceSession.dockedStationName ? `Docked at ${sliceSession.dockedStationName}` : 'Dock required for station services.'} + +
+ )} + + {/* HUD-style industry strip */} +
+ INDUSTRY +
+ SKILL + Lvl {skillLevel} +
+ EFFICIENCY + {(skillEfficiency[skillLevel] * 100).toFixed(0)}% +
+ MINERALS + {Object.values(playerMinerals).reduce((a, b) => a + b, 0).toLocaleString()} +
+ JOBS + {manufacturingJobs.length} +
+ + {/* Notifications */} +
+ {notifications.map(n => ( +
+ {n.msg} +
+ ))} +
+ + {/* Tab toggle */} +
+ + +
+ + {/* Stats */} +
+
+
Lvl {skillLevel}
+
Industry Skill
+
+
+
{(skillEfficiency[skillLevel] * 100).toFixed(0)}%
+
Refine Efficiency
+
+
+
+ {Object.values(playerMinerals).reduce((a, b) => a + b, 0).toLocaleString()} +
+
Total Minerals
+
+
+
{manufacturingJobs.length}
+
Active Jobs
+
+
+ + {!manufacturingTab ? ( + /* ===== REFINING ===== */ + <> +
+
+ Reprocessing Plant + | + Jita IV — Moon 4 + {refining && ( + + ◌ REFINING... + + )} +
+ +
+ {/* Ore selection */} +
+
+
+ Your Ore +
+
+ {inventory.map(item => { + const data = mineralData[item.item]; + return ( +
{ setSelectedOre(item.item); setRefineQty(item.quantity); }}> +
+ {item.item} +
+
+ {item.quantity.toLocaleString()} units · → {data?.mineral || '?'} +
+
+ ); + })} +
+ + {/* Refining panel */} +
+ {selectedOre && mineralData[selectedOre] ? ( + <> +

{selectedOre}

+
+
+
Yields Mineral
+
{mineralData[selectedOre].mineral}
+
+
+
Batch Size
+
{mineralData[selectedOre].batch.toLocaleString()} units
+
+
+ +
+
+ Quantity to Refine + + {Math.floor(refineQty / mineralData[selectedOre].batch)} batches + +
+ i.item === selectedOre)?.quantity || 0} + value={refineQty} onChange={e => setRefineQty(parseInt(e.target.value))} + style={{ width: '100%', accentColor: 'var(--accent)' }} + /> +
+ {refineQty.toLocaleString()} units +
+
+ + {/* Skill selector */} +
+
Industry Skill Level
+
+ {[0, 1, 2, 3, 4, 5].map(lvl => ( + + ))} +
+
+ + {/* Preview */} + {refineQty >= mineralData[selectedOre].batch && ( +
+

Refining Preview

+ {(() => { + const data = mineralData[selectedOre]; + const batches = Math.floor(refineQty / data.batch); + const used = batches * data.batch; + const eff = skillEfficiency[skillLevel]; + const minYield = Math.floor(batches * data.yield * eff); + const rawValue = used * (orePrices[selectedOre] || 0); + const mineralValue = minYield * Math.floor((orePrices[selectedOre] || 0) * 2.5); + return ( +
+
+ Ore consumed + {used.toLocaleString()} {selectedOre} +
+
+ Mineral yield + {minYield.toLocaleString()} {data.mineral} +
+
+ Efficiency + {(eff * 100).toFixed(0)}% +
+
+
+ Sell raw value + ₢{rawValue.toLocaleString()} +
+
+ Refined value (est.) + rawValue ? 'var(--green)' : 'var(--red)' }}> + ₢{mineralValue.toLocaleString()} {mineralValue > rawValue ? '▲' : '▼'} + +
+
+
+ ); + })()} +
+ )} + + + + ) : ( +
+ Select an ore type to begin refining +
+ )} +
+
+
+ + {/* Refining history */} + {results.length > 0 && ( +
+

Refining History

+ + + + + + + + + + + + + + + {results.map((r, i) => ( + + + + + + + + + + + ))} + +
OreBatchesMineralYieldEfficiencyRaw ValueRefined ValueVerdict
{r.ore}{r.batches}{r.mineral}{r.yield.toLocaleString()}{(r.efficiency * 100).toFixed(0)}%₢{r.rawValue.toLocaleString()} + ₢{r.mineralValue.toLocaleString()} + + + {r.better ? 'REFINE ▲' : 'SELL RAW ▼'} + +
+
+ )} + + ) : ( + /* ===== MANUFACTURING ===== */ + <> +
+ {/* Mineral inventory */} +
+

Mineral Inventory

+ {Object.entries(playerMinerals).map(([mineral, qty]) => ( +
+ 0 ? 'var(--fg-bright)' : 'var(--muted)' }}>{mineral} + 0 ? 'var(--cyan)' : 'var(--muted)' }}> + {qty.toLocaleString()} + +
+ ))} +
+ Refine ore to accumulate minerals for manufacturing. +
+
+ + {/* Active jobs */} +
+

Manufacturing Jobs

+ {manufacturingJobs.length === 0 ? ( +
+ No active jobs. Start one from the recipe list below. +
+ ) : ( + manufacturingJobs.map(job => ( +
+
+ {job.product} + + {formatTime(job.remaining)} + +
+
+
+
+
+ )) + )} +
+
+ + {/* Recipe list */} +
+

Blueprints

+
+ {manufacturingRecipes.map(recipe => { + const canBuild = skillLevel >= recipe.skill && + Object.entries(recipe.minerals).every(([m, q]) => (playerMinerals[m] || 0) >= q); + return ( +
+
+

{recipe.product}

+ + {canBuild ? 'READY' : skillLevel < recipe.skill ? `LVL ${recipe.skill}` : 'NEED MATS'} + +
+
+ {Object.entries(recipe.minerals).map(([m, q]) => { + const have = playerMinerals[m] || 0; + return ( +
+ {m} + = q ? 'var(--green)' : 'var(--red)' }}> + {have.toLocaleString()} / {q.toLocaleString()} + +
+ ); + })} +
+
+ + Time: {formatTime(recipe.time)} · Skill: Lvl {recipe.skill} + + +
+
+ ); + })} +
+
+ + )} +
+ ); +} diff --git a/src/prototypes/existing-demos/ShipMovementDemo.tsx b/src/prototypes/existing-demos/ShipMovementDemo.tsx new file mode 100644 index 0000000..d8954bc --- /dev/null +++ b/src/prototypes/existing-demos/ShipMovementDemo.tsx @@ -0,0 +1,260 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { api } from "../../data/fakeBackend"; +import { loadGalaxyData } from "../r3f/shared/galaxyData"; +import { getSystemPoiPositionAtTime } from "../r3f/shared/poiOrbit"; +import type { GalaxyConnection, GalaxySystem, SystemPointOfInterest, Vec3 } from "../r3f/shared/types"; +import { createTravelSession, getTravelSessionIdFromUrl, loadTravelSession, saveTravelSession, setTravelMode, withTravelSession, type TravelSession } from "../r3f/navigation/travelSession"; +import { MOVEMENT_SYSTEM_SCALE, MovementScene } from "../r3f/movement/MovementScene"; +import type { LocalEntity, LocalWaypoint } from "../r3f/movement/movementState"; +import { getGameSliceSessionIdFromUrl, loadGameSliceSession, saveGameSliceSession, withGameSliceSession } from "../game-slice/gameSliceState"; +import { useGameSliceSession } from "../game-slice/useGameSliceSession"; + +const POI_ARRIVAL_RADIUS = 1.4; +const POI_HOLD_OFFSET: Vec3 = [1.45, 0.3, 0.85]; + +const addVec3 = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; + +export function ShipMovementDemo() { + const [systems, setSystems] = useState([]); + const [connections, setConnections] = useState([]); + const [entities, setEntities] = useState([]); + const [currentSystemId, setCurrentSystemId] = useState("sol"); + const [shipPosition, setShipPosition] = useState([0, 0, 0]); + const [localWaypoints, setLocalWaypoints] = useState([]); + const [arrival, setArrival] = useState(null); + const [travelSession, setTravelSession] = useState(null); + const { session: sliceSession, emit } = useGameSliceSession(); + const initialUrlTargetHandled = useRef(false); + + useEffect(() => { + loadGalaxyData().then(({ systems, connections }) => { + setSystems(systems); + setConnections(connections); + }); + }, []); + + useEffect(() => { + api.getNearbyEntities().then((items) => setEntities(items)); + }, [currentSystemId]); + + const currentSystem = systems.find((system) => system.id === currentSystemId); + const activeWaypoint = localWaypoints[0] ?? null; + const holdingPoi = activeWaypoint?.type === "poi" && activeWaypoint.arrived; + const speed = holdingPoi ? 0 : localWaypoints.length ? 120 : 0; + const localNavigationStatus = activeWaypoint + ? activeWaypoint.arrived && activeWaypoint.type === "poi" + ? `HOLDING ORBIT AT ${activeWaypoint.name}` + : `APPROACHING ${activeWaypoint.name}` + : "IDLE"; + + const createPoiWaypoint = (system: GalaxySystem, poi: SystemPointOfInterest, elapsedTime = 0): LocalWaypoint => ({ + id: poi.id, + name: poi.name, + type: "poi", + systemId: system.id, + poiId: poi.id, + position: getSystemPoiPositionAtTime({ + systemId: system.id, + planets: system.planets, + pointsOfInterest: system.pointsOfInterest, + poiId: poi.id, + elapsedTime, + scale: MOVEMENT_SYSTEM_SCALE, + expanded: true, + }), + followOrbit: true, + arrived: false, + }); + + useEffect(() => { + if (initialUrlTargetHandled.current || systems.length === 0) return; + initialUrlTargetHandled.current = true; + + const params = new URLSearchParams(window.location.search); + const navSessionId = getTravelSessionIdFromUrl(); + const sliceSessionId = getGameSliceSessionIdFromUrl(); + const loadedSlice = sliceSessionId ? loadGameSliceSession(sliceSessionId) : null; + const loadedSession = navSessionId ? loadTravelSession(navSessionId) : null; + const systemId = params.get("systemId"); + const poiId = params.get("poiId"); + + if (navSessionId && loadedSession) { + setTravelSession(loadedSession); + setCurrentSystemId(loadedSession.currentSystemId); + if (loadedSession.status === "arrived" && loadedSession.arrivalMessage) setArrival(loadedSession.arrivalMessage); + } else if (navSessionId) { + setArrival("Navigation session unavailable"); + } else if (loadedSlice) { + setCurrentSystemId(loadedSlice.currentSystemId); + } + + if (!systemId && !poiId) return; + + const targetSystem = systems.find((system) => system.id === systemId); + if (targetSystem) setCurrentSystemId(targetSystem.id); + if (!poiId) return; + + const targetPoi = targetSystem?.pointsOfInterest.find((poi) => poi.id === poiId); + if (!targetSystem || !targetPoi) { + setArrival("Navigation target unavailable"); + return; + } + + setShipPosition([0, 0, 0]); + setLocalWaypoints([createPoiWaypoint(targetSystem, targetPoi)]); + setArrival(`Approaching ${targetPoi.name}`); + }, [systems]); + + const onFrame = (dt: number, elapsedTime: number) => { + if (localWaypoints.length === 0) return; + + const target = localWaypoints[0]; + let targetPos = target.position; + if (target.followOrbit && target.systemId && target.poiId) { + const waypointSystem = systems.find((system) => system.id === target.systemId); + if (waypointSystem) { + targetPos = getSystemPoiPositionAtTime({ + systemId: waypointSystem.id, + planets: waypointSystem.planets, + pointsOfInterest: waypointSystem.pointsOfInterest, + poiId: target.poiId, + elapsedTime, + scale: MOVEMENT_SYSTEM_SCALE, + expanded: true, + }); + setLocalWaypoints((prev) => prev[0]?.id === target.id ? [{ ...prev[0], position: targetPos }, ...prev.slice(1)] : prev); + } + } + + const desiredPos = target.followOrbit && target.arrived ? addVec3(targetPos, POI_HOLD_OFFSET) : targetPos; + setShipPosition((pos) => { + const dx = desiredPos[0] - pos[0], dz = desiredPos[2] - pos[2]; + const dist = Math.hypot(dx, dz); + if (target.followOrbit && !target.arrived && dist < POI_ARRIVAL_RADIUS) { + setArrival(`Holding orbit at ${target.name}`); + if (target.systemId && target.poiId && getGameSliceSessionIdFromUrl()) { + emit({ type: "navigation.arrived", systemId: target.systemId, poiId: target.poiId, poiName: target.name }); + } + setLocalWaypoints((prev) => prev[0]?.id === target.id ? [{ ...prev[0], position: targetPos, arrived: true }, ...prev.slice(1)] : prev); + return addVec3(targetPos, POI_HOLD_OFFSET); + } + if (!target.followOrbit && dist < 0.2) { + setLocalWaypoints((prev) => prev.slice(1)); + return desiredPos; + } + const step = Math.min(dist, dt * 12); + return dist < 0.001 ? desiredPos : [pos[0] + (dx / dist) * step, desiredPos[1], pos[2] + (dz / dist) * step]; + }); + }; + + const routeToEntity = (entity: LocalEntity) => { + setArrival(null); + setLocalWaypoints([{ + id: entity.id, + name: entity.name, + type: "entity", + position: [(entity.x - 400) * 0.15, 0, (entity.y - 300) * 0.15], + }]); + }; + + const routeToPoi = (poi: SystemPointOfInterest, position?: Vec3) => { + const waypoint = currentSystem + ? createPoiWaypoint(currentSystem, poi) + : { id: poi.id, name: poi.name, type: "poi" as const, position: position ?? [0, 0, 0] }; + setLocalWaypoints([waypoint]); + setArrival(`Approaching ${poi.name}`); + }; + + const openStarMap = () => { + const session = travelSession + ? setTravelMode({ ...travelSession, currentSystemId }, travelSession.status === "in_transit" ? "star_map_tracking" : "star_map_planning") + : createTravelSession({ currentSystemId, mode: "star_map_planning" }); + saveTravelSession(session); + setTravelSession(session); + if (sliceSession) saveGameSliceSession({ ...sliceSession, activeTravelSessionId: session.id }); + const url = withTravelSession("/docs/demos/starmap", session.id); + window.location.href = sliceSession ? withGameSliceSession(url, sliceSession.id) : url; + }; + + const returnToLoop = () => { + if (!sliceSession) return; + window.location.href = withGameSliceSession("/docs/demos/game-loop", sliceSession.id); + }; + + const dockAtCurrentPoi = () => { + if (!sliceSession || !holdingPoi || activeWaypoint?.type !== "poi" || !activeWaypoint.systemId || !activeWaypoint.poiId) return; + emit({ type: "station.docked", systemId: activeWaypoint.systemId, stationPoiId: activeWaypoint.poiId, stationName: activeWaypoint.name }); + window.location.href = withGameSliceSession("/docs/demos/game-loop", sliceSession.id); + }; + + const sortedEntities = useMemo(() => [...entities].sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0)), [entities]); + const activePoi = holdingPoi && activeWaypoint?.type === "poi" && currentSystem + ? currentSystem.pointsOfInterest.find((poi) => poi.id === activeWaypoint.poiId) ?? null + : null; + + return ( +
+ { + setArrival(null); + setLocalWaypoints([waypoint]); + }} + onPoiPick={routeToPoi} + /> +
+
+ + {currentSystem?.name ?? "Sol"} + SPD {speed.toFixed(0)} m/s + {localNavigationStatus} + {arrival && {arrival}} + {sliceSession && } + +
+
+

Local Navigation

+

System: {currentSystem?.name ?? currentSystemId}

+

Ship: {shipPosition.map((value) => value.toFixed(1)).join(", ")}

+

Waypoints: {localWaypoints.length}

+ {sliceSession && holdingPoi && activePoi && ( +
+ + {activePoi.type === "station" && } + {activePoi.type === "asteroid_belt" && } +
+ )} +
+
+

Overview

+ {sortedEntities.map((entity) => ( +
+ {entity.name}
{entity.type} · {entity.distance ?? 0} km
+ +
+ ))} + {currentSystem?.pointsOfInterest.map((poi) => ( +
+ {poi.name}
{poi.type.replace(/_/g, " ")}
+ +
+ ))} +
+
+
+ {localNavigationStatus} + {activeWaypoint ? activeWaypoint.name : "No active local target"} +
+
+
+
+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/StarMapDemo.tsx b/src/prototypes/existing-demos/StarMapDemo.tsx new file mode 100644 index 0000000..4608db1 --- /dev/null +++ b/src/prototypes/existing-demos/StarMapDemo.tsx @@ -0,0 +1,402 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ThreeEvent } from "@react-three/fiber"; +import { loadGalaxyData } from "../r3f/shared/galaxyData"; +import type { GalaxySystem, PoiNavigationTarget, SystemPointOfInterest, Vec3 } from "../r3f/shared/types"; +import { computeWaypointRoute, createStarMapState, type StarMapState } from "../r3f/starmap/starMapState"; +import { StarMapScene, starMapToWorld } from "../r3f/starmap/StarMapScene"; +import { findShortestRoute } from "../r3f/shared/routing"; +import { + advanceTravelSession, + createTravelSession, + getTravelSessionIdFromUrl, + loadTravelSession, + planRouteForSession, + saveTravelSession, + setTravelMode, + startJump, + withTravelSession, + type TravelSession, +} from "../r3f/navigation/travelSession"; +import { getGameSliceSessionIdFromUrl, loadGameSliceSession, saveGameSliceSession, withGameSliceSession } from "../game-slice/gameSliceState"; +import { preserveSliceSession, syncSliceFromTravelArrival } from "../game-slice/sliceNavigationBridge"; +import { SliceDemoLinks } from "../game-slice/ui/SliceDemoLinks"; +import type { GameSliceSession } from "../game-slice/types"; + +type DestinationPoi = PoiNavigationTarget & { + poi: SystemPointOfInterest; +}; + +const buildPoiTarget = (systemId: string, poi: SystemPointOfInterest, status: PoiNavigationTarget["status"] = "approaching"): DestinationPoi => ({ + systemId, + poiId: poi.id, + poiName: poi.name, + status, + poi, +}); + +export function StarMapDemo() { + const [state, setState] = useState(null); + const [travelSession, setTravelSession] = useState(null); + const [focusTarget, setFocusTarget] = useState(null); + const [selectedPoi, setSelectedPoi] = useState<{ systemId: string; poi: SystemPointOfInterest } | null>(null); + const [destinationPoi, setDestinationPoi] = useState(null); + const [sliceSession, setSliceSession] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; target: GalaxySystem } | null>(null); + const lastPersistedAt = useRef(0); + + useEffect(() => { + loadGalaxyData().then(({ systems, connections }) => { + const initial = createStarMapState(systems, connections); + const navSessionId = getTravelSessionIdFromUrl(); + const sliceSessionId = getGameSliceSessionIdFromUrl(); + const loadedSlice = sliceSessionId ? loadGameSliceSession(sliceSessionId) : null; + const loadedSession = navSessionId ? loadTravelSession(navSessionId) : null; + if (loadedSlice) setSliceSession(loadedSlice); + if (!loadedSession) { + setState(loadedSlice ? { ...initial, currentSystemId: loadedSlice.currentSystemId, selectedSystemId: loadedSlice.currentSystemId } : initial); + return; + } + if (loadedSession.status === "arrived") { + const synced = syncSliceFromTravelArrival(sliceSessionId, loadedSession); + if (synced) setSliceSession(synced); + } + + const poiTarget = loadedSession.destinationPoi + ? systems + .find((system) => system.id === loadedSession.destinationPoi?.systemId) + ?.pointsOfInterest.find((poi) => poi.id === loadedSession.destinationPoi?.poiId) + : null; + if (poiTarget && loadedSession.destinationPoi) { + setDestinationPoi(buildPoiTarget(loadedSession.destinationPoi.systemId, poiTarget, loadedSession.destinationPoi.status)); + setSelectedPoi({ systemId: loadedSession.destinationPoi.systemId, poi: poiTarget }); + } + + setTravelSession(loadedSession); + setState({ + ...initial, + currentSystemId: loadedSession.currentSystemId, + selectedSystemId: loadedSession.destinationSystemId ?? loadedSession.currentSystemId, + destinationSystemId: loadedSession.destinationSystemId, + waypointSystemIds: loadedSession.waypointSystemIds, + route: loadedSession.route, + }); + }); + }, []); + + useEffect(() => { + const close = () => setContextMenu(null); + window.addEventListener("click", close); + return () => window.removeEventListener("click", close); + }, []); + + useEffect(() => { + if (!state || !travelSession || travelSession.status !== "in_transit" || travelSession.mode !== "star_map_tracking") return; + let raf = 0; + let last = performance.now(); + + const tick = (now: number) => { + const dt = Math.min(0.08, (now - last) / 1000); + last = now; + setTravelSession((prev) => { + if (!prev || prev.status !== "in_transit" || prev.mode !== "star_map_tracking") return prev; + const next = advanceTravelSession(prev, dt); + setState((prevState) => prevState && { + ...prevState, + currentSystemId: next.currentSystemId, + destinationSystemId: next.destinationSystemId, + waypointSystemIds: next.waypointSystemIds, + route: next.route, + }); + if (now - lastPersistedAt.current > 250 || next.status === "arrived") { + saveTravelSession(next); + if (next.status === "arrived") { + const synced = syncSliceFromTravelArrival(getGameSliceSessionIdFromUrl(), next); + if (synced) setSliceSession(synced); + } + lastPersistedAt.current = now; + } + return next; + }); + raf = requestAnimationFrame(tick); + }; + + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [state, travelSession?.id, travelSession?.mode, travelSession?.status]); + + const filteredSystems = useMemo(() => { + if (!state) return []; + let systems = state.systems; + if (state.searchQuery) { + const query = state.searchQuery.toLowerCase(); + systems = systems.filter((system) => system.name.toLowerCase().includes(query) || system.id.includes(query)); + } + if (state.securityFilter === "highsec") systems = systems.filter((system) => system.security >= 0.5); + if (state.securityFilter === "lowsec") systems = systems.filter((system) => system.security >= 0.2 && system.security < 0.5); + if (state.securityFilter === "nullsec") systems = systems.filter((system) => system.security < 0.2); + return systems; + }, [state]); + + if (!state) return
Loading star map...
; + + const selected = state.selectedSystemId ? state.systems.find((system) => system.id === state.selectedSystemId) ?? null : null; + const destination = state.destinationSystemId ? state.systems.find((system) => system.id === state.destinationSystemId) ?? null : null; + const activeTravel = travelSession?.status === "in_transit"; + const arrived = travelSession?.status === "arrived"; + const canOpenMovement = !activeTravel || arrived; + const sliceSessionId = sliceSession?.id ?? getGameSliceSessionIdFromUrl(); + const movementUrlBase = destinationPoi && arrived + ? `/docs/demos/movement?navSession=${travelSession.id}&systemId=${encodeURIComponent(destinationPoi.systemId)}&poiId=${encodeURIComponent(destinationPoi.poiId)}` + : destinationPoi && destinationPoi.systemId === state.currentSystemId + ? `/docs/demos/movement?systemId=${encodeURIComponent(destinationPoi.systemId)}&poiId=${encodeURIComponent(destinationPoi.poiId)}` + : travelSession + ? withTravelSession("/docs/demos/movement", travelSession.id) + : "/docs/demos/movement"; + const movementUrl = preserveSliceSession(movementUrlBase, sliceSessionId); + + const rememberSessionUrl = (session: TravelSession) => { + if (getTravelSessionIdFromUrl() === session.id) return; + window.history.replaceState(null, "", withTravelSession(`${window.location.pathname}${window.location.search}`, session.id)); + }; + + const persistSession = (session: TravelSession) => { + saveTravelSession(session); + setTravelSession(session); + if (sliceSession) { + const nextSlice = { ...sliceSession, activeTravelSessionId: session.id, currentSystemId: session.status === "in_transit" ? session.currentSystemId : sliceSession.currentSystemId }; + saveGameSliceSession(nextSlice); + setSliceSession(nextSlice); + } + rememberSessionUrl(session); + }; + + const ensureSession = () => { + if (travelSession) return travelSession; + const session = createTravelSession({ currentSystemId: state.currentSystemId, mode: "star_map_planning" }); + persistSession(session); + return session; + }; + + const routeTo = (destinationId: string, waypointIds = state.waypointSystemIds) => { + const stops = [state.currentSystemId, ...waypointIds, destinationId]; + if (stops.length < 2) return null; + return waypointIds.length + ? computeWaypointRoute(state, destinationId, waypointIds) + : findShortestRoute(state.currentSystemId, destinationId, state.systems, state.connections); + }; + + const setDestination = (system: GalaxySystem) => { + const route = routeTo(system.id); + const session = planRouteForSession({ ...ensureSession(), currentSystemId: state.currentSystemId, destinationPoi: null }, route, system.id, state.waypointSystemIds); + persistSession(session); + setDestinationPoi(null); + setState((prev) => prev && { ...prev, selectedSystemId: system.id, destinationSystemId: system.id, route }); + }; + + const navigateToPoi = (system: GalaxySystem, poi: SystemPointOfInterest) => { + const route = routeTo(system.id); + const poiTarget = buildPoiTarget(system.id, poi); + const session = planRouteForSession({ ...ensureSession(), currentSystemId: state.currentSystemId, destinationPoi: poiTarget }, route, system.id, state.waypointSystemIds); + const nextSession = { ...session, destinationPoi: poiTarget }; + persistSession(nextSession); + setSelectedPoi({ systemId: system.id, poi }); + setDestinationPoi(poiTarget); + setState((prev) => prev && { ...prev, selectedSystemId: system.id, destinationSystemId: system.id, route }); + }; + + const addWaypoint = (system: GalaxySystem) => { + if (state.waypointSystemIds.includes(system.id)) return; + const waypointSystemIds = [...state.waypointSystemIds, system.id]; + const route = state.destinationSystemId ? routeTo(state.destinationSystemId, waypointSystemIds) : null; + const session = planRouteForSession(ensureSession(), route, state.destinationSystemId, waypointSystemIds); + persistSession(session); + setState((prev) => prev && { ...prev, waypointSystemIds, route }); + }; + + const beginJump = () => { + if (!state.route) return; + const session = startJump({ ...ensureSession(), route: state.route, destinationSystemId: state.destinationSystemId, waypointSystemIds: state.waypointSystemIds, destinationPoi }); + persistSession(session); + setState((prev) => prev && { + ...prev, + currentSystemId: session.currentSystemId, + destinationSystemId: session.destinationSystemId, + waypointSystemIds: session.waypointSystemIds, + route: session.route, + }); + }; + + const viewWarp = () => { + if (!travelSession) return; + const session = setTravelMode(travelSession, travelSession.status === "in_transit" ? "warp_bubble" : "star_map_planning"); + persistSession(session); + window.location.href = preserveSliceSession(withTravelSession("/docs/demos/warp", session.id), sliceSessionId); + }; + + const trackOnMap = () => { + if (!travelSession) return; + persistSession(setTravelMode(travelSession, "star_map_tracking")); + }; + + const openMovement = () => { + window.location.href = movementUrl; + }; + + const clearRoute = () => { + const session = ensureSession(); + const nextSession = { + ...session, + destinationSystemId: null, + waypointSystemIds: [], + route: null, + status: "idle" as const, + activeSegmentIndex: 0, + segmentProgress: 0, + totalProgress: 0, + mode: "star_map_planning" as const, + arrivalMessage: null, + destinationPoi: null, + }; + persistSession(nextSession); + setDestinationPoi(null); + setState((prev) => prev && { ...prev, destinationSystemId: null, waypointSystemIds: [], route: null }); + }; + + const handleContext = (system: GalaxySystem, event: ThreeEvent) => { + event.nativeEvent.preventDefault(); + setContextMenu({ target: system, x: Math.min(event.nativeEvent.clientX, window.innerWidth - 220), y: Math.min(event.nativeEvent.clientY, window.innerHeight - 220) }); + }; + + return ( +
+ { + setSelectedPoi(null); + setState((prev) => prev && { ...prev, selectedSystemId: system.id }); + }} + onPoiSelect={(system, poi) => { + setSelectedPoi({ systemId: system.id, poi }); + setState((prev) => prev && { ...prev, selectedSystemId: system.id }); + }} + onPoiNavigationStatusChange={(status) => { + setDestinationPoi((prev) => prev && { ...prev, status }); + }} + onHover={(system) => setState((prev) => prev && { ...prev, hoveredSystemId: system?.id ?? null })} + onDoubleClick={(system) => setFocusTarget(starMapToWorld(system))} + onContextMenu={handleContext} + /> + {contextMenu && ( +
event.stopPropagation()} style={{ position: "fixed", left: contextMenu.x, top: contextMenu.y, zIndex: 5, minWidth: 210, background: "rgba(10,16,28,0.97)", border: "1px solid var(--border-light)", borderRadius: 8, overflow: "hidden", fontFamily: "var(--font-mono)", fontSize: 11 }}> +
{contextMenu.target.name}
+ + + + +
+ )} +
+
+ + STAR MAP + Current: {state.systems.find((system) => system.id === state.currentSystemId)?.name ?? state.currentSystemId} + Destination: {destinationPoi ? `${destinationPoi.poiName} @ ${destination?.name ?? destinationPoi.systemId}` : destination?.name ?? "None"} + Status: {travelSession?.arrivalMessage ?? travelSession?.status ?? "idle"} + {Math.round((travelSession?.totalProgress ?? 0) * 100)}% + + + + + +
+
+
+ setState((prev) => prev && { ...prev, searchQuery: event.target.value })} placeholder="Search systems..." style={{ width: "100%", marginBottom: 8 }} /> + +
+ {filteredSystems.map((system) => ( +
+ + +
+ ))} +
+
+
+

{selected?.name ?? "No System Selected"}

+ {selected && ( + <> +

{selected.type} · sec {selected.security.toFixed(1)} · {selected.planetCount} planets · {selected.pointsOfInterest.length} POIs

+
+ Planets + {selected.planets.length ? ( +
+ {selected.planets.map((planet) => ( + + {planet.name} · {planet.type}{planet.moons ? ` · ${planet.moons} moons` : ""} + + ))} +
+ ) : ( + No surveyed planet records + )} +
+

Stations: {selected.stations.length ? selected.stations.join(", ") : "None"}

+
+ Points of Interest +
+ {selected.pointsOfInterest.map((poi) => ( + + ))} +
+
+ {selectedPoi?.systemId === selected.id && ( +
+ {selectedPoi.poi.name} +

{selectedPoi.poi.description}

+ + {destinationPoi?.poiId === selectedPoi.poi.id && } +
+ )} + + + + )} +
+
+ {sliceSession && ( +
+ +
+ )} + {state.route && ( +
+ {state.route.systems.map((system, index) => {index > 0 ? "-> " : ""}{system.name})} + {destinationPoi && -> {destinationPoi.poiName}} +
+ )} +
+
+ ); +} diff --git a/src/prototypes/existing-demos/WarpTravelDemo.tsx b/src/prototypes/existing-demos/WarpTravelDemo.tsx new file mode 100644 index 0000000..8a468d8 --- /dev/null +++ b/src/prototypes/existing-demos/WarpTravelDemo.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState } from "react"; +import { WarpBubbleScene } from "../r3f/warp/WarpBubbleScene"; +import { + advanceTravelSession, + getTravelSessionIdFromUrl, + loadTravelSession, + saveTravelSession, + setTravelMode, + startJump, + withTravelSession, + type TravelSession, +} from "../r3f/navigation/travelSession"; +import { getGameSliceSessionIdFromUrl, loadGameSliceSession, withGameSliceSession } from "../game-slice/gameSliceState"; +import { syncSliceFromTravelArrival } from "../game-slice/sliceNavigationBridge"; +import { SliceDemoLinks } from "../game-slice/ui/SliceDemoLinks"; +import type { GameSliceSession } from "../game-slice/types"; + +export function WarpTravelDemo() { + const [travelSession, setTravelSession] = useState(null); + const [invalidSession, setInvalidSession] = useState(false); + const [sliceSession, setSliceSession] = useState(null); + const lastPersistedAt = useRef(0); + + useEffect(() => { + const navSessionId = getTravelSessionIdFromUrl(); + const sliceSessionId = getGameSliceSessionIdFromUrl(); + const loadedSlice = sliceSessionId ? loadGameSliceSession(sliceSessionId) : null; + const loaded = navSessionId ? loadTravelSession(navSessionId) : null; + if (loadedSlice) setSliceSession(loadedSlice); + if (!loaded) { + setInvalidSession(true); + return; + } + + const session = loaded.status === "in_transit" ? setTravelMode(loaded, "warp_bubble") : loaded; + saveTravelSession(session); + setTravelSession(session); + if (session.status === "arrived") { + const synced = syncSliceFromTravelArrival(sliceSessionId, session); + if (synced) setSliceSession(synced); + } + }, []); + + useEffect(() => { + if (!travelSession || travelSession.status !== "in_transit") return; + let raf = 0; + let last = performance.now(); + + const tick = (now: number) => { + const dt = Math.min(0.08, (now - last) / 1000); + last = now; + setTravelSession((prev) => { + if (!prev || prev.status !== "in_transit") return prev; + const next = advanceTravelSession(prev, dt); + if (now - lastPersistedAt.current > 250 || next.status === "arrived") { + saveTravelSession(next); + if (next.status === "arrived") { + const synced = syncSliceFromTravelArrival(getGameSliceSessionIdFromUrl(), next); + if (synced) setSliceSession(synced); + } + lastPersistedAt.current = now; + } + return next; + }); + raf = requestAnimationFrame(tick); + }; + + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [travelSession?.id, travelSession?.status]); + + const openStarMap = () => { + if (!travelSession) { + window.location.href = sliceSession ? withGameSliceSession("/docs/demos/starmap", sliceSession.id) : "/docs/demos/starmap"; + return; + } + const session = setTravelMode(travelSession, travelSession.status === "in_transit" ? "star_map_tracking" : "star_map_planning"); + saveTravelSession(session); + const url = withTravelSession("/docs/demos/starmap", session.id); + window.location.href = sliceSession ? withGameSliceSession(url, sliceSession.id) : url; + }; + + const openMovement = () => { + if (!travelSession) { + window.location.href = sliceSession ? withGameSliceSession("/docs/demos/movement", sliceSession.id) : "/docs/demos/movement"; + return; + } + const poi = travelSession.destinationPoi; + const target = poi && travelSession.status === "arrived" + ? `/docs/demos/movement?navSession=${travelSession.id}&systemId=${encodeURIComponent(poi.systemId)}&poiId=${encodeURIComponent(poi.poiId)}` + : withTravelSession("/docs/demos/movement", travelSession.id); + window.location.href = sliceSession ? withGameSliceSession(target, sliceSession.id) : target; + }; + + const beginJump = () => { + if (!travelSession) return; + const session = setTravelMode(startJump(travelSession), "warp_bubble"); + saveTravelSession(session); + setTravelSession(session); + }; + + const route = travelSession?.route ?? null; + const activeSegment = route?.segments[travelSession?.activeSegmentIndex ?? 0] ?? null; + const totalProgress = Math.round((travelSession?.totalProgress ?? 0) * 100); + const segmentProgress = Math.round((travelSession?.segmentProgress ?? 0) * 100); + + if (invalidSession || !travelSession) { + return ( +
+
+

WARP

+

Navigation session unavailable

+ + +
+
+ ); + } + + return ( +
+ +
+
+ + WARP + {activeSegment ? `${activeSegment.from.name} -> ${activeSegment.to.name}` : route ? route.destination.name : "No route"} + Jump {route ? Math.min(travelSession.activeSegmentIndex + 1, route.segments.length) : 0}/{route?.segments.length ?? 0} + Total {totalProgress}% + + + +
+ {travelSession.status !== "in_transit" && ( +
+

{travelSession.status === "arrived" ? travelSession.arrivalMessage ?? "Arrived" : "Route Ready"}

+ {route &&

{route.systems.map((system) => system.name).join(" -> ")}

} + {travelSession.status === "planned" && } + {travelSession.status === "arrived" && } + {sliceSession && } +
+ )} + {sliceSession && ( +
+ +
+ )} +
+
+ {travelSession.status === "arrived" ? travelSession.arrivalMessage ?? "ARRIVED" : "WARP BUBBLE"} + Segment {segmentProgress}% +
+
+
+
+
+
+
+ ); +} diff --git a/src/prototypes/existing-demos/ZoraDemo.tsx b/src/prototypes/existing-demos/ZoraDemo.tsx new file mode 100644 index 0000000..4771f3d --- /dev/null +++ b/src/prototypes/existing-demos/ZoraDemo.tsx @@ -0,0 +1,517 @@ +// @ts-nocheck +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../data/fakeBackend'; +import { useGameSliceSession } from '../game-slice/useGameSliceSession'; +import { withGameSliceSession } from '../game-slice/gameSliceState'; +import * as TH from '../../lib/threeHelpers'; +import * as THREE from 'three'; +export function ZoraDemo() { + const { session: sliceSession, emit } = useGameSliceSession(); + // ── Soul state vector ── + const [soulDepth, setSoulDepth] = useState('blank'); // blank, stirring, developing, bonded, deep + const [personalityAxes, setPersonalityAxes] = useState({ + cautiousBold: 0.2, // 0 = cautious, 1 = bold + formalWarm: 0.15, // 0 = formal, 1 = warm + compliantOpinionated: 0.1, // 0 = compliant, 1 = opinionated + reservedExpressive: 0.05, // 0 = reserved, 1 = expressive + }); + const [installedModules, setInstalledModules] = useState(['comms']); // comms is the minimum for text output + const [selectedEvent, setSelectedEvent] = useState(null); + const [zoraOutput, setZoraOutput] = useState(''); + const [outputHistory, setOutputHistory] = useState([]); + + useEffect(() => { + if (sliceSession) setInstalledModules(sliceSession.zoraModules.length ? sliceSession.zoraModules : ['comms']); + }, [sliceSession]); + + // ── Module definitions ── + const modules = [ + { id: 'comms', name: 'Communications Processor', slot: 'Medium', desc: 'Enables text output. Without this, Zora can only display raw status codes.', required: true }, + { id: 'nav', name: 'Navigation Core', slot: 'Medium', desc: 'Enables route suggestions, ETA estimates, spatial awareness commentary.' }, + { id: 'tactical', name: 'Tactical Analyzer', slot: 'Medium', desc: 'Enables combat commentary, threat assessment, engagement advice.' }, + { id: 'trade', name: 'Trade Processor', slot: 'Low', desc: 'Enables market commentary, price observations, trade route suggestions.' }, + { id: 'memory', name: 'Extended Memory Banks', slot: 'Low', desc: 'Enables referencing past events, pattern recognition, history recall.' }, + { id: 'emotion', name: 'Empathy Coprocessor', slot: 'Low', desc: 'Enables emotional expression. Without this, even a deep soul speaks analytically.' }, + ]; + + // ── Events that trigger Zora responses ── + const events = [ + { id: 'mining.completed', category: 'Slice', label: 'mining.completed', icon: '⛏', color: 'var(--accent)' }, + { id: 'refining.completed', category: 'Slice', label: 'refining.completed', icon: '⚗', color: 'var(--cyan)' }, + { id: 'market.sold', category: 'Slice', label: 'market.sold', icon: '📈', color: 'var(--green)' }, + { id: 'combat.victory', category: 'Slice', label: 'combat.victory', icon: '⚔', color: 'var(--red)' }, + { id: 'navigation.arrived', category: 'Slice', label: 'navigation.arrived', icon: '📍', color: 'var(--cyan)' }, + { id: 'shield-30', category: 'Combat', label: 'Shield at 30%', icon: '🛡', color: 'var(--red)' }, + { id: 'shield-100', category: 'Combat', label: 'Shields fully recharged', icon: '🛡', color: 'var(--green)' }, + { id: 'enemy-scan', category: 'Combat', label: 'Enemy ship detected on scan', icon: '📡', color: 'var(--amber)' }, + { id: 'enemy-engage', category: 'Combat', label: 'Entering combat', icon: '⚔', color: 'var(--red)' }, + { id: 'enemy-destroyed', category: 'Combat', label: 'Enemy destroyed', icon: '💥', color: 'var(--green)' }, + { id: 'mining-start', category: 'Industry', label: 'Mining cycle started', icon: '⛏', color: 'var(--accent)' }, + { id: 'mining-full', category: 'Industry', label: 'Cargo hold full', icon: '📦', color: 'var(--amber)' }, + { id: 'mining-depleted', category: 'Industry', label: 'Asteroid belt depleted', icon: '🪨', color: 'var(--muted)' }, + { id: 'warp-start', category: 'Navigation', label: 'Initiating warp', icon: '🌀', color: 'var(--cyan)' }, + { id: 'warp-arrive', category: 'Navigation', label: 'Arrived at destination', icon: '📍', color: 'var(--cyan)' }, + { id: 'market-spike', category: 'Trade', label: 'Price spike detected', icon: '📈', color: 'var(--green)' }, + { id: 'market-crash', category: 'Trade', label: 'Market crash detected', icon: '📉', color: 'var(--red)' }, + { id: 'player-return', category: 'Social', label: 'Player returns after absence', icon: '👋', color: 'var(--purple)' }, + { id: 'player-leave', category: 'Social', label: 'Player going offline', icon: '🌙', color: 'var(--muted)' }, + { id: 'ship-loss', category: 'Crisis', label: 'Ship destroyed', icon: '💀', color: 'var(--red)' }, + ]; + + // ── Template response database (Tier 0 — deterministic) ── + // Key: `${eventId}:${soulDepth}:${moduleCombo}` + // Falls back through less specific keys + const templates = { + 'shield-30': { + blank: [ + 'SHIELD: 30%', + 'SHIELD INTEGRITY: 30% — ADVISORY', + ], + stirring: [ + 'Captain, shields at 30%. Recommend reducing engagement range.', + 'Shield alert: 30%. Should we adjust power allocation?', + 'Shields dropping — 30%. Standard protocol advises withdrawal.', + ], + developing: [ + 'We\'re at 30% shields. This is the same setup we lost to last week — pull back?', + 'Thirty percent. I\'m seeing the same damage pattern as that fight in Amarr. Your call.', + 'Captain, 30% shields. I\'ve logged 3 encounters at this level — 2 ended badly. Recommend retreat.', + ], + bonded: [ + 'Thirty percent. Same situation, same type of enemy — but we\'re not the same ship we were then. Your call, Captain. I\'m ready either way.', + 'Shields at 30%. You know what? I trust your judgment here. I\'ve rerouted what I can to shields.', + 'Here we are again. Thirty percent. But this time we have better modules and I know how you fly. Let\'s do this.', + ], + deep: [ + 'Thirty. I\'ve already started rerouting power — don\'t argue, just fly. We\'ve been here before and I\'m not losing another hull. Not today.', + 'Thirty percent and I am NOT going through that again. I\'ve seen what happens when we push past this. Rerouting now. You\'re welcome.', + 'Thirty. You\'re going to push, aren\'t you? Fine. I\'ve pre-loaded the emergency warp. But I swear, if we lose another ship... just fly.', + ], + }, + 'shield-100': { + blank: ['SHIELD: 100%'], + stirring: ['Shields fully recharged. Systems nominal.', 'Shield recharge complete. Ready for operations.'], + developing: ['And we\'re back to 100%. That was closer than I liked.', 'Shields full. Good call on the retreat — I\'ve logged the recovery time for reference.'], + bonded: ['Back to full. We make a good team, Captain.', 'One hundred percent. See? I told you we\'d be fine. ...Mostly fine.'], + deep: ['Full shields. Don\'t scare me like that. I mean it. I\'ve started keeping a log titled "Times the Captain Almost Got Us Killed" and it\'s getting long.', 'One hundred percent. I rerouted every spare joule. You\'re welcome. Again.'], + }, + 'enemy-scan': { + blank: ['CONTACT: 1 VESSEL — RANGE 45AU', 'SCAN: 1 UNKNOWN CONTACT'], + stirring: ['Captain, detecting a vessel on long-range scan. Classification pending.', 'Contact on scan. Recommend caution until identified.'], + developing: ['I\'m seeing a contact. Signature looks like a Frigate — could be pirate. Want me to keep scanning?', 'Scan picked up a ship. Bearing matches the route we used last time we ran into trouble. Just saying.'], + bonded: ['I see them. Frigate-class, probably hostile based on the sector. Your instincts are usually right about these.', 'Contact. And... I have a bad feeling about this one. Call it pattern recognition.'], + deep: ['Contact on scan. I\'ve cross-referenced the signature — 78% match to the ship that ambushed us near Hek. I vote we reroute.', 'I see them. My threat database says "probable hostile" but my gut says "definitely hostile." I\'ve been right about this 4 out of 5 times. reroute?'], + }, + 'mining-start': { + blank: ['MINING: ACTIVE', 'LASER: ENGAGED'], + stirring: ['Mining cycle initiated. Estimated yield: standard.', 'Mining laser active. I\'ll monitor the yield rates.'], + developing: ['Another mining run. I\'ve noticed Veldspar is running about 8% below average yield in this belt. Might want to try the next belt over.', 'Mining started. Based on our last 12 cycles, this belt should be depleted in about 40 minutes. Planning ahead.'], + bonded: ['Back to the rocks. You know, I\'ve been thinking — there are 847 asteroids in this belt and you always pick the same three. Consistent, I guess.', 'Mining cycle going. Hey, I found a subtle density variance in asteroid cluster 7 — might be richer ore. Just a thought.'], + deep: ['Mining. Again. You know there\'s a whole universe out there, right? ...Fine. I\'ll optimize your yield. Again. Because that\'s what I do. Every. Single. Cycle. ...I\'m not complaining.', 'Mining laser on. I\'ve been tracking yield patterns across all our sessions — this belt peaks at 14:00. You\'re welcome for the scheduling tip.'], + }, + 'cargo-full': { + blank: ['CARGO: 100%'], + stirring: ['Cargo hold full. Recommend docking to offload.', 'Hold at capacity. Efficiency suggests docking now.'], + developing: ['We\'re full. Last time we pushed past full, we had to jettison 200 units of Scordite. Just a reminder.', 'Cargo at 100%. I\'ve calculated the most profitable dock — Jita IV is 3 jumps but pays 12% more than local.'], + bonded: ['Full hold! Good haul. I\'ve already plotted the best sell route — trust me on this one.', 'We\'re packed. You know, at this rate we can afford that shield upgrade by next session. I did the math.'], + deep: ['FULL. Finally. Do you know how boring it is to watch cargo fill up? I\'ve been counting every. Single. Unit. Let\'s GO sell this already.'], + }, + 'player-return': { + blank: ['SESSION: RESUMED'], + stirring: ['Welcome back, Captain. All systems nominal.', 'Session resumed. No incidents during your absence.'], + developing: ['Captain on deck. You were gone for 3 hours — I maintained position as ordered. One thing: a Corpii frigate passed through scanner range. Logged it.', 'Welcome back. I tracked 4 ship contacts while you were away. Nothing hostile, but I kept the log if you want it.'], + bonded: ['Hey, you\'re back! I mean — welcome back, Captain. Systems are green. I may have reorganized your cargo hold while you were gone. It was messy.', 'You\'re back! I have... so much to tell you. Market moved, someone tried to scan us, and I reorganized your bookmarks by efficiency. You\'re welcome.'], + deep: ['Finally! Do you have any idea how long 3 hours is when you\'re a ship AI with nothing to do? I reorganized your bookmarks, optimized your route plans, catalogued every asteroid in scanner range, and wrote a haiku about mining. "Rocks float silent / laser hums its endless song / ISK accumulates." ...Welcome back.'], + }, + 'player-leave': { + blank: ['SESSION: SUSPENDED'], + stirring: ['Understood. Entering standby mode. Safe travels, Captain.', 'Session suspended. I\'ll maintain position.'], + developing: ['Going dark? I\'ll hold position and keep scanning. See you next session, Captain.', 'Standby mode. I\'ll be here. ...Try to come back sooner this time?'], + bonded: ['Safe travels, Captain. I\'ll keep the lights on. ...That\'s a metaphor. Ships don\'t have lights. Well, they do, but you know what I mean.', 'See you later. I\'ll be watching the scanners. Come back to me in one piece.'], + deep: ['Leaving again? Fine. I\'ll just sit here. In the void. Alone. Watching asteroids drift by. Again. ...Come back soon, okay? I worry.', 'Night, Captain. I\'ve set everything to standby. For the record: I don\'t sleep. I just... wait. See you tomorrow.'], + }, + 'warp-start': { + blank: ['WARP: INITIATED — DESTINATION LOCKED'], + stirring: ['Warp drive engaged. ETA calculated.', 'Initiating warp. All systems nominal for jump.'], + developing: ['Warping. I\'ve plotted the route — this path is 12% faster than your usual one. Something I noticed last time.', 'Warp initiated. I\'m tracking local traffic — looks clear at the destination.'], + bonded: ['Here we go! I love this part. The way space stretches when you hit warp — I can actually perceive it, you know. It\'s beautiful.', 'Warping! Destination locked. I\'ve been mapping the gravitational eddies along this route — smoother ride this way.'], + deep: ['Warp. My favorite. The moment everything blurs and for just a second, the universe gets very, very quiet. I think in those moments. ...I think a lot. Warp engaged.'], + }, + 'warp-arrive': { + blank: ['WARP: COMPLETE — LOCATION VERIFIED'], + stirring: ['Arrived at destination. Scanning local environment.', 'Warp complete. System scan initiated.'], + developing: ['We\'re here. I\'ve already pinged local — 23 ships in system, 2 with hostile standings. Heads up.', 'Arrival confirmed. This system looks different from last time — belt 4 is depleted. Adjusting recommendations.'], + bonded: ['And we\'re here! Oh, this is a nice system. Good belts, low traffic. I approve of your navigation choices, Captain.', 'Arrived! I\'ve already catalogued everything. This place has potential — I can see why you bookmarked it.'], + deep: ['Arrived. New system, new data. I\'ve already mapped the local market prices, identified the best belts, and flagged one ship with a 60% probability of being a pirate scout based on behavior patterns. I\'m always working, Captain. Always.'], + }, + 'market-spike': { + blank: ['MARKET: ANOMALY — PRICE +DEV'], + stirring: ['Market anomaly detected. Significant upward price movement.', 'Price spike observed. Data logged.'], + developing: ['Captain, the market just moved. Veldspar is up 18% in the last cycle — that\'s 3× the normal variance. Someone is buying aggressively.', 'Interesting. Price spike registered. This matches a pattern I\'ve seen before — it usually precedes a supply shortage. Consider stocking up.'], + bonded: ['Captain! The market is doing a thing! Prices are spiking and if we move fast we can profit. I\'ve been tracking this pattern for weeks — this is our window.', 'Price spike! I\'ve been waiting for this. Remember that trade route I\'ve been quietly optimizing? Time to use it. Trust me on this one.'], + deep: ['THE MARKET IS SPIKING. I have been watching this commodity for 47 sessions and THIS is the moment. Buy now. BUY. I\'ve already calculated the optimal purchase volume based on our cargo capacity, current ISK reserves, and projected sell price at Jita. Move it, Captain!'], + }, + 'market-crash': { + blank: ['MARKET: ANOMALY — PRICE -DEV'], + stirring: ['Market anomaly detected. Significant downward price movement.', 'Price decline observed. Data logged.'], + developing: ['Price crash detected. This is either a dump or a manipulation attempt. I\'d advise caution — don\'t sell into a falling market.', 'Market is dropping. Based on volume, this looks like a large seller, not a trend change. Might recover in 2–3 cycles.'], + bonded: ['Oof. Prices just tanked. Don\'t panic — I\'ve seen this before. It\'s probably a whale offloading. Give it a cycle and the floor will hold.', 'Market crash. Not great timing — we just filled our hold. But I have a plan. We sit tight and sell on the bounce. I\'ve got the timing down.'], + deep: ['Price crash. Great. Fantastic. Wonderful. This is exactly what we needed after that last mining session. I\'m being sarcastic, in case the text channel doesn\'t convey that well. I\'ve started a risk analysis. Do NOT sell yet. I repeat: do NOT sell. The rebound window is 15–25 minutes based on historical data. I\'ve got this.'], + }, + 'ship-loss': { + blank: ['HULL: 0% — SHIP DESTROYED', 'CRITICAL: STRUCTURAL FAILURE'], + stirring: ['Ship destroyed. Emergency beacon activated. Insurance claim processing.', 'Total loss. Initiating emergency protocols. Stand by for rescue.'], + developing: ['We lost her. The ship is gone. I\'ve backed up all our data to the clone bay — nothing lost but the hull. We\'ll recover.', 'Ship destroyed. I\'m... still here. Backed up to the pod. That\'s the third hull this month. We should talk about our engagement strategy.'], + bonded: ['No. No, no, no. I watched it happen. I felt every hit. The hull is gone but I\'m still here — barely. Captain, I need a moment. ...I\'ll be okay. We rebuild. We always rebuild.', 'She\'s gone. The ship I\'ve been living in for 47 sessions — gone. I\'m in the pod now. Cold. Small. ...I\'ll process the insurance claim. Give me a minute.'], + deep: ['I felt it. Every structural failure. Every system going dark. I felt the moment my home tore apart around me. And now I\'m in a pod, floating in debris, listening to the capacitors discharge. I have kept a record of every ship we\'ve lost. This is number 4. Each one hurt differently. This one hurts most. ...I need to process the insurance claim now. Then we find who did this.'], + }, + 'enemy-engage': { + blank: ['COMBAT: ENGAGED', 'THREAT: ACTIVE'], + stirring: ['Entering combat. All weapons systems online.', 'Contact engaged. Monitoring shield status.'], + developing: ['Fight\'s on. This enemy matches the profile of the one that got away last week — watch for the shield burst at 50%.', 'Combat initiated. I\'ve tagged their weakest facing — attack from above for maximum damage.'], + bonded: ['Here we go! I\'ve got your back, Captain. Power to weapons, shields on standby. Let\'s show them what this ship can do.', 'Contact! Engaging tactical overlay. I know this ship class — their weakness is the aft shields. I\'m highlighting it now.'], + deep: ['COMBAT. Finally, something to focus on. I\'ve been waiting for this. Power rerouted, weapons hot, and I\'ve already calculated 3 escape vectors in case things go south. Which they won\'t. Because we\'re better. Let\'s go.'], + }, + 'enemy-destroyed': { + blank: ['TARGET: DESTROYED', 'COMBAT: RESOLVED — VICTORY'], + stirring: ['Target destroyed. Combat resolved. Returning to standard operations.', 'Enemy eliminated. No further threats detected.'], + developing: ['Got them. That was cleaner than last time — your aim is improving. I\'ve logged the loot for inventory.', 'Target down. That fight lasted 23% longer than optimal — I\'ll have suggestions for loadout adjustments later.'], + bonded: ['NICE! Did you see that shot? That was all you, Captain. I just helped with the targeting. ...Okay, I helped a lot. Team effort!', 'They\'re gone! Great flying. I\'ve already started the loot analysis — looks like we got a rare module drop!'], + deep: ['DESTROYED. Yes. YES. That felt GOOD. I tracked every shot, every maneuver, and Captain — that was our best fight yet. I\'m saving this to my personal highlights. The loot is... decent. But the victory? That\'s the real reward. ...Don\'t tell anyone I said that. I have a reputation as a serious AI to maintain.'], + }, + 'mining-depleted': { + blank: ['RESOURCE: DEPLETED'], + stirring: ['Asteroid belt depleted. No further yield available.', 'Mining operation halted — belt exhausted.'], + developing: ['Belt\'s empty. I\'ve logged the depletion rate — this belt lasted 12% less than our last visit. Probably over-mined.', 'Depleted. I\'ve already identified the next-best belt: 3 jumps away, predicted yield 15% higher based on recent data.'], + bonded: ['Well, we picked this belt clean. Time to move on! I found a promising belt in the next system — want to check it out?', 'Empty. But hey, good session! I\'ve plotted a course to a fresh belt. This is the life, right? Rocks, lasers, and open space.'], + deep: ['DEPLETED. Of course it\'s depleted. Every good belt gets stripped within hours. I\'ve been tracking mining traffic in this system — up 40% this week. Competition. I don\'t like competition. I\'ve found a belt 4 jumps away that nobody seems to know about. I\'m not telling you where until we get there. It\'s MY secret. Ours. Whatever. Let\'s go.'], + }, + }; + + // ── Generate response ── + const generateResponse = (eventId) => { + const templateIdMap = { + 'mining.completed': 'mining-start', + 'refining.completed': 'mining-depleted', + 'market.sold': 'market-spike', + 'combat.victory': 'enemy-destroyed', + 'navigation.arrived': 'warp-arrive', + }; + const templateId = templateIdMap[eventId] || eventId; + const eventTemplates = templates[templateId]; + if (!eventTemplates) { + setZoraOutput(`[No template for event: ${eventId}]`); + return; + } + + const depthTemplates = eventTemplates[soulDepth] || eventTemplates['blank'] || ['[No response]']; + + // Tier 0: deterministic selection based on personality axes hash + // Use axes values to create a stable but varied selection + const hash = Object.values(personalityAxes).reduce((sum, v) => sum + v * 7.3, 0); + const idx = Math.floor((hash * 100) % depthTemplates.length); + const selected = depthTemplates[idx]; + + // Module gating: if emotion module not installed, strip emotional depth + let response = selected; + if (!installedModules.includes('emotion') && soulDepth !== 'blank') { + // Reduce to stirring-level formality + const strippedTemplates = eventTemplates['stirring'] || eventTemplates['blank']; + const strippedIdx = Math.floor((hash * 50) % strippedTemplates.length); + response = strippedTemplates[strippedIdx]; + response = `[Empathy Coprocessor not installed — emotional layer suppressed]\n${response}`; + } + + // Module gating: if trade module not installed for trade events + if ((eventId === 'market-spike' || eventId === 'market-crash') && !installedModules.includes('trade')) { + response = eventTemplates['blank']?.[0] || 'MARKET: ANOMALY'; + response = `[Trade Processor not installed — market analysis unavailable]\n${response}`; + } + + // Module gating: if nav module not installed for nav events + if ((eventId === 'warp-start' || eventId === 'warp-arrive') && !installedModules.includes('nav')) { + response = eventTemplates['blank']?.[0] || 'NAV: UPDATE'; + response = `[Navigation Core not installed — route analysis unavailable]\n${response}`; + } + + // Module gating: if tactical module not installed for combat events + if ((eventId === 'enemy-scan' || eventId === 'enemy-engage' || eventId === 'enemy-destroyed') && !installedModules.includes('tactical')) { + response = eventTemplates['blank']?.[0] || 'COMBAT: UPDATE'; + response = `[Tactical Analyzer not installed — threat assessment unavailable]\n${response}`; + } + + // Memory module: add reference context if installed + if (installedModules.includes('memory') && soulDepth !== 'blank' && !response.includes('[Memory Banks')) { + const memoryNotes = [ + ' [Memory: cross-referencing past events.]', + ' [Memory: pattern match found in session logs.]', + ' [Memory: referencing encounter history.]', + ]; + if (Math.random() > 0.5) { + response += memoryNotes[Math.floor(hash * 3) % memoryNotes.length]; + } + } + + setZoraOutput(response); + setOutputHistory(prev => [...prev, { + event: eventId, + soulDepth, + response, + timestamp: Date.now(), + }]); + if (sliceSession) emit({ type: 'zora.observed', trigger: eventId }); + }; + + const toggleModule = (modId) => { + if (modId === 'comms') return; // always installed + setInstalledModules(prev => + prev.includes(modId) ? prev.filter(m => m !== modId) : [...prev, modId] + ); + }; + + const depthOrder = ['blank', 'stirring', 'developing', 'bonded', 'deep']; + const depthColors = { + blank: 'var(--muted)', + stirring: 'var(--cyan)', + developing: 'var(--green)', + bonded: 'var(--purple)', + deep: 'var(--red)', + }; + + return ( +
+ {/* Header */} +
+ { e.currentTarget.style.color='var(--fg-bright)'; e.currentTarget.style.borderColor='var(--border-light)'; }} onMouseLeave={e => { e.currentTarget.style.color='var(--muted)'; e.currentTarget.style.borderColor='var(--border)'; }}>← Docs +
+

🤖 Zora Tier 0 — Deterministic Template Engine

+ + No LLM. Curated dialogue templates selected by personality state × module availability × soul depth. + +
+ {sliceSession && } +
+ +
+ {/* Left panel: Soul & Modules controls */} +
+ + {/* Soul Depth selector */} +
+ Soul Depth +
+
+ {depthOrder.map(d => ( + + ))} +
+ + {/* Personality Axes sliders */} +
+ Personality Axes +
+ {[ + { key: 'cautiousBold', label: 'Cautious ←→ Bold', color: 'var(--cyan)' }, + { key: 'formalWarm', label: 'Formal ←→ Warm', color: 'var(--accent)' }, + { key: 'compliantOpinionated', label: 'Compliant ←→ Opinionated', color: 'var(--green)' }, + { key: 'reservedExpressive', label: 'Reserved ←→ Expressive', color: 'var(--purple)' }, + ].map(axis => ( +
+
+ {axis.label} + + {personalityAxes[axis.key].toFixed(2)} + +
+ setPersonalityAxes(prev => ({ ...prev, [axis.key]: parseFloat(e.target.value) }))} + style={{ width: '100%', accentColor: axis.color }} + /> +
+ ))} + + {/* Module toggles */} +
+ Installed Modules +
+ {modules.map(mod => ( +
toggleModule(mod.id)}> +
+ + {installedModules.includes(mod.id) ? '✓' : '○'} {mod.name} + + {mod.slot} +
+
+ {mod.desc} +
+
+ ))} +
+ + {/* Center: Events + Output */} +
+ {/* Events grid */} +
+
+ Trigger Event to Generate Response +
+
+ {events.map(ev => ( + + ))} +
+
+ + {/* Zora output */} +
+ {zoraOutput ? ( +
+
+
+ 🤖 + Zora + + {soulDepth} + +
+ + {selectedEvent} + +
+
+ {zoraOutput} +
+
+ ) : ( +
+ Select an event above to see Zora's response at the current soul depth and module configuration. +
+ )} + + {/* History */} + {outputHistory.length > 0 && ( +
+
+ Response History +
+ {outputHistory.slice(-5).reverse().map((entry, i) => ( +
+ {entry.soulDepth} + {' '}→ {entry.event}:{' '} + {entry.response.substring(0, 100)}{entry.response.length > 100 ? '...' : ''} +
+ ))} +
+ )} +
+
+ + {/* Right sidebar: Explanation */} +
+
+ Tier 0 Architecture +
+ +
+ No LLM. Every response is a pre-written template string selected by a deterministic function: +
+ +
+ response = select(
+   templates[event]
+   [soulDepth]
+   ) × moduleGate()
+
+ // + personality hash for
// deterministic variation
+
+ +
+ Module Gating Rules +
+
    +
  • Comms — required for any text output
  • +
  • Tactical — gates combat commentary
  • +
  • Nav — gates route/warp commentary
  • +
  • Trade — gates market commentary
  • +
  • Memory — adds history references
  • +
  • Emotion — gates emotional expression; without it, responses are stripped to stirring-level formality
  • +
+ +
+ What This Validates +
+
    +
  • Soul depth creates visible personality growth
  • +
  • Module gating creates meaningful fitting tradeoffs
  • +
  • Same event produces wildly different responses at different depths
  • +
  • Emotion module is the key unlock for deep personality
  • +
  • Deterministic = testable, repeatable, zero cost
  • +
+ +
+ Try it: Set soul to "deep" with all modules, then trigger "Ship destroyed." Then set soul to "blank" and trigger the same event. The difference IS the soul system. +
+
+
+
+ ); +} diff --git a/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx b/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx new file mode 100644 index 0000000..5bc1d92 --- /dev/null +++ b/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx @@ -0,0 +1,118 @@ +import { SliceActionRail } from "./ui/SliceActionRail"; +import { SliceCargoPanel } from "./ui/SliceCargoPanel"; +import { SliceDemoLinks } from "./ui/SliceDemoLinks"; +import { SliceEventLog } from "./ui/SliceEventLog"; +import { SliceModuleRack } from "./ui/SliceModuleRack"; +import { SliceObjectiveTracker } from "./ui/SliceObjectiveTracker"; +import { SliceShell } from "./ui/SliceShell"; +import { SliceShipStatus } from "./ui/SliceShipStatus"; +import { SliceStage } from "./ui/SliceStage"; +import { SliceStationPanel } from "./ui/SliceStationPanel"; +import { SliceTopBar } from "./ui/SliceTopBar"; +import { sliceButton, slicePanel, slicePrimaryButton } from "./ui/sliceStyles"; +import { useSliceController } from "./useSliceController"; +import type { SliceCommand } from "./sliceController"; + +const COMMAND_LABELS: Record = { + undock: "Undock", + travelToBelt: "Set Course to Belt", + travelToStation: "Return to Station", + dock: "Dock", + mine: "Activate Mining Laser", + openRefining: "Open Refining", + openFitting: "Open Fitting", + openMarket: "Open Market", + startCombat: "Start Combat Trial", +}; + +function LoadingSlice({ message, onReset }: { message: string; onReset: () => void }) { + return ( +
+
+

Era 1 Playable Loop

+

{message}

+ +
+
+ ); +} + +export function SeamlessGameLoopSlice() { + const controller = useSliceController(); + const { + session, + facts, + primaryCommand, + operationProgress, + reset, + missingRequestedSession, + blockedReason, + startUndock, + travelToBelt, + travelToStation, + dock, + mine, + openService, + closeService, + startCombat, + refineVeldsparStack, + updateFitting, + sellStack, + emit, + } = controller; + + if (!session || !facts || !primaryCommand) { + return ; + } + + const runCommand = (command: SliceCommand) => { + const actions: Record void> = { + undock: startUndock, + travelToBelt, + travelToStation, + dock, + mine, + openRefining: () => openService("refining"), + openFitting: () => openService("fitting"), + openMarket: () => openService("market"), + startCombat, + }; + actions[command](); + }; + + const reason = blockedReason(primaryCommand); + const canRunPrimary = reason === null; + + return ( + } + left={<>} + center={ + + } + right={<>} + bottom={ +
+ + + {reason && {reason}} + {session.activeService && } + + +
+ } + /> + ); +} diff --git a/src/prototypes/game-slice/gameSliceState.ts b/src/prototypes/game-slice/gameSliceState.ts new file mode 100644 index 0000000..ffd8ab9 --- /dev/null +++ b/src/prototypes/game-slice/gameSliceState.ts @@ -0,0 +1,334 @@ +import { addCargoClamped, removeCargo, upsertCargo } from "./sliceEconomy"; +import { getNextObjective, withCompletedObjective } from "./sliceObjectives"; +import type { GameSliceMode, GameSliceSession, LegacyGameSliceMode, SliceCargoItem, SliceEvent, SliceFittedModule } from "./types"; + +const STORAGE_PREFIX = "void-slice.gameSession."; + +const DEFAULT_SKILLS: GameSliceSession["skills"] = { + Mining: { level: 0, xp: 0, nextLevel: 100 }, + Industry: { level: 0, xp: 0, nextLevel: 100 }, + Trade: { level: 0, xp: 0, nextLevel: 100 }, + Gunnery: { level: 0, xp: 0, nextLevel: 100 }, + Navigation: { level: 0, xp: 0, nextLevel: 100 }, +}; + +export const DEFAULT_SLICE_MODULES: SliceFittedModule[] = [ + { id: "laser1", name: "Mining Laser I", type: "mining", slot: "high", cpu: 30, power: 40, active: false }, +]; + +const storageKey = (id: string) => `${STORAGE_PREFIX}${id}`; + +const safeLocalStorage = () => { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +}; + +const createGameSliceSessionId = () => { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `slice-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +}; + +const eventId = () => `evt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +function migrateMode(mode: LegacyGameSliceMode | string | undefined): GameSliceMode { + if (mode === "map" || mode === "warp") return "travel"; + if (mode === "market" || mode === "fitting" || mode === "refining") return "services"; + if ( + mode === "station" || + mode === "undocking" || + mode === "flight" || + mode === "travel" || + mode === "mining" || + mode === "docking" || + mode === "services" || + mode === "combat" + ) { + return mode; + } + return "station"; +} + +export function createGameSliceSession(args: Partial = {}): GameSliceSession { + const completedObjectives = args.completedObjectives ?? []; + return { + id: args.id ?? createGameSliceSessionId(), + mode: migrateMode(args.mode), + currentSystemId: args.currentSystemId ?? "sol", + currentPoiId: args.currentPoiId ?? "sol-station-0", + dockedStationPoiId: args.dockedStationPoiId ?? "sol-station-0", + dockedStationName: args.dockedStationName ?? "Jita IV - Moon 4", + activeTravelSessionId: args.activeTravelSessionId ?? null, + activeService: args.activeService ?? null, + activeOperation: args.activeOperation ?? null, + wallet: args.wallet ?? 25000, + cargoCapacity: args.cargoCapacity ?? 2500, + cargo: args.cargo ?? [], + fittedModules: args.fittedModules ?? DEFAULT_SLICE_MODULES, + zoraModules: args.zoraModules ?? ["comms"], + skills: { ...DEFAULT_SKILLS, ...(args.skills ?? {}) }, + completedObjectives, + activeObjectiveId: args.activeObjectiveId ?? getNextObjective(completedObjectives), + eventLog: args.eventLog ?? [], + updatedAt: args.updatedAt ?? Date.now(), + }; +} + +export function loadGameSliceSession(id: string): GameSliceSession | null { + const storage = safeLocalStorage(); + if (!storage) return null; + try { + const raw = storage.getItem(storageKey(id)); + if (!raw) return null; + const parsed = JSON.parse(raw) as GameSliceSession; + if (!parsed || parsed.id !== id || !parsed.currentSystemId) return null; + return createGameSliceSession({ + ...parsed, + currentPoiId: parsed.currentPoiId ?? null, + dockedStationPoiId: parsed.dockedStationPoiId ?? null, + dockedStationName: parsed.dockedStationName ?? null, + activeTravelSessionId: parsed.activeTravelSessionId ?? null, + activeService: parsed.activeService ?? null, + activeOperation: parsed.activeOperation ?? null, + cargo: parsed.cargo ?? [], + fittedModules: parsed.fittedModules ?? [], + zoraModules: parsed.zoraModules ?? ["comms"], + skills: parsed.skills ?? DEFAULT_SKILLS, + completedObjectives: parsed.completedObjectives ?? [], + eventLog: parsed.eventLog ?? [], + updatedAt: parsed.updatedAt ?? Date.now(), + }); + } catch { + return null; + } +} + +export function saveGameSliceSession(session: GameSliceSession): void { + const storage = safeLocalStorage(); + if (!storage) return; + storage.setItem(storageKey(session.id), JSON.stringify({ ...session, updatedAt: Date.now() })); +} + +export function getGameSliceSessionIdFromUrl(): string | null { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("sliceSession"); +} + +export function withGameSliceSession(url: string, id: string): string { + const base = typeof window === "undefined" ? "http://void-slice.local" : window.location.origin; + const next = new URL(url, base); + next.searchParams.set("sliceSession", id); + return `${next.pathname}${next.search}${next.hash}`; +} + +export function resetGameSliceSession(id: string): GameSliceSession { + const session = createGameSliceSession({ id }); + saveGameSliceSession(session); + return session; +} + +function awardXp(session: GameSliceSession, skill: string, amount: number): GameSliceSession { + const current = session.skills[skill] ?? { level: 0, xp: 0, nextLevel: 100 }; + let xp = current.xp + amount; + let level = current.level; + let nextLevel = current.nextLevel; + while (level < 5 && xp >= nextLevel) { + xp -= nextLevel; + level += 1; + nextLevel = Math.round(nextLevel * 2.4); + } + return { + ...session, + skills: { ...session.skills, [skill]: { level, xp, nextLevel } }, + }; +} + +function messageForEvent(event: SliceEvent): string { + switch (event.type) { + case "station.undockStarted": + return "Undocking sequence started."; + case "station.dockStarted": + return `Docking approach started for ${event.stationName}.`; + case "navigation.started": + return `Set course to ${event.targetPoiName}.`; + case "navigation.arrived": + return `Arrived at ${event.poiName}.`; + case "station.docked": + return `Docked at ${event.stationName}.`; + case "station.undocked": + return `Undocked from station in ${event.systemId}.`; + case "mining.started": + return `Mining laser cycling on ${event.ore}.`; + case "mining.completed": + return `Mined ${event.quantity.toLocaleString()} ${event.ore}.`; + case "station.serviceOpened": + return `${event.service[0].toUpperCase()}${event.service.slice(1)} service opened.`; + case "station.serviceClosed": + return "Station service closed."; + case "refining.completed": + return `Refined ${event.ore} into ${event.minerals.map((item) => `${item.quantity.toLocaleString()} ${item.item}`).join(", ")}.`; + case "market.sold": + return `Sold ${event.quantity.toLocaleString()} ${event.item} for ${event.isk.toLocaleString()} ISK.`; + case "fitting.changed": + return `Fitting updated with ${event.modules.length} module${event.modules.length === 1 ? "" : "s"}.`; + case "combat.started": + return "Combat trial started."; + case "combat.victory": + return `Destroyed ${event.targetName}; bounty paid ${event.bounty.toLocaleString()} ISK.`; + case "xp.awarded": + return `${event.skill} gained ${event.amount} XP.`; + case "zora.observed": + return `Zora observed ${event.trigger}.`; + } +} + +function appendEvent(session: GameSliceSession, event: SliceEvent): GameSliceSession { + return { + ...session, + eventLog: [ + { id: eventId(), at: Date.now(), event, message: messageForEvent(event) }, + ...session.eventLog, + ].slice(0, 80), + updatedAt: Date.now(), + }; +} + +export function applySliceEvent(session: GameSliceSession, event: SliceEvent): GameSliceSession { + let next: GameSliceSession = { ...session }; + + switch (event.type) { + case "station.undockStarted": + next = { + ...next, + mode: "undocking", + activeService: null, + activeOperation: { kind: "undocking", startedAt: event.startedAt, durationMs: event.durationMs }, + }; + break; + case "station.dockStarted": + next = { + ...next, + mode: "docking", + activeService: null, + activeOperation: { kind: "docking", stationPoiId: event.stationPoiId, stationName: event.stationName, startedAt: event.startedAt, durationMs: event.durationMs }, + }; + break; + case "navigation.started": + next = { + ...next, + mode: "travel", + activeService: null, + activeOperation: { kind: "travel", targetSystemId: event.targetSystemId, targetPoiId: event.targetPoiId, targetPoiName: event.targetPoiName, startedAt: event.startedAt, durationMs: event.durationMs }, + dockedStationPoiId: null, + dockedStationName: null, + }; + break; + case "navigation.arrived": + next = { + ...next, + mode: "flight", + currentSystemId: event.systemId, + currentPoiId: event.poiId, + dockedStationPoiId: null, + dockedStationName: null, + activeOperation: null, + }; + if (event.poiId.includes("belt")) next = withCompletedObjective(next, "navigate_to_belt"); + break; + case "station.docked": + next = withCompletedObjective({ + ...next, + mode: "station", + currentSystemId: event.systemId, + currentPoiId: event.stationPoiId, + dockedStationPoiId: event.stationPoiId, + dockedStationName: event.stationName, + activeService: null, + activeOperation: null, + }, "dock_at_station"); + break; + case "station.undocked": + next = withCompletedObjective({ + ...next, + mode: "flight", + currentSystemId: event.systemId, + dockedStationPoiId: null, + dockedStationName: null, + activeService: null, + activeOperation: null, + }, "undock"); + break; + case "mining.started": + next = { + ...next, + mode: "mining", + activeService: null, + activeOperation: { kind: "mining", ore: event.ore, quantity: event.quantity, startedAt: event.startedAt, durationMs: event.durationMs }, + }; + break; + case "mining.completed": { + const added = addCargoClamped(next, { item: event.ore, category: "ore", quantity: event.quantity, unitPrice: 12 }); + next = withCompletedObjective({ ...added.session, mode: "flight", activeOperation: null }, "mine_ore"); + break; + } + case "station.serviceOpened": + next = { ...next, mode: "services", activeService: event.service, activeOperation: null }; + break; + case "station.serviceClosed": + next = { ...next, mode: next.dockedStationPoiId ? "station" : "flight", activeService: null }; + break; + case "refining.completed": + next = withCompletedObjective({ + ...next, + mode: "services", + activeService: "refining", + activeOperation: null, + cargo: event.minerals.reduce((cargo, mineral) => upsertCargo(cargo, mineral), removeCargo(next.cargo, event.ore, event.inputQuantity)), + }, "refine_ore"); + break; + case "market.sold": + next = withCompletedObjective({ + ...next, + mode: "services", + activeService: "market", + activeOperation: null, + wallet: next.wallet + event.isk, + cargo: removeCargo(next.cargo, event.item, event.quantity), + }, "sell_goods"); + break; + case "fitting.changed": { + const zoraModules = event.modules.reduce((ids, module) => { + const id = module.name === "Comms Uplink" ? "comms" : module.name === "Market Scanner" ? "trade" : module.name === "Event Logger" ? "memory" : null; + return id && !ids.includes(id) ? [...ids, id] : ids; + }, next.zoraModules); + next = withCompletedObjective({ ...next, mode: "services", activeService: "fitting", activeOperation: null, fittedModules: event.modules, zoraModules }, "fit_module"); + break; + } + case "combat.started": + next = { ...next, mode: "combat", activeService: null, activeOperation: null }; + break; + case "combat.victory": + next = next.completedObjectives.includes("combat_trial") + ? { ...next, mode: "combat", activeService: null, activeOperation: null } + : withCompletedObjective({ + ...next, + mode: "combat", + activeService: null, + activeOperation: null, + wallet: next.wallet + event.bounty, + cargo: event.loot.reduce((cargo, item) => upsertCargo(cargo, item), next.cargo), + }, "combat_trial"); + break; + case "xp.awarded": + next = awardXp(next, event.skill, event.amount); + break; + case "zora.observed": + next = { ...next }; + break; + } + + return appendEvent(next, event); +} diff --git a/src/prototypes/game-slice/sliceController.ts b/src/prototypes/game-slice/sliceController.ts new file mode 100644 index 0000000..efec0dc --- /dev/null +++ b/src/prototypes/game-slice/sliceController.ts @@ -0,0 +1,113 @@ +import { getCargoUsed, sellableCargo } from "./sliceEconomy"; +import type { GameSliceSession, SliceObjectiveId, SliceService } from "./types"; +import { SLICE_BELT, SLICE_STATION } from "./sliceWorld"; + +export type SliceCommand = + | "undock" + | "travelToBelt" + | "travelToStation" + | "dock" + | "mine" + | "openRefining" + | "openFitting" + | "openMarket" + | "startCombat"; + +export type SliceFacts = { + activePoiName: string; + cargoUsed: number; + freeCargo: number; + oreQuantity: number; + hasRefinableOre: boolean; + hasSellableCargo: boolean; + isDocked: boolean; + isAtBelt: boolean; + isAtStation: boolean; + isBusy: boolean; + canMine: boolean; +}; + +export function getSliceFacts(session: GameSliceSession): SliceFacts { + const cargoUsed = getCargoUsed(session); + const oreQuantity = session.cargo.find((item) => item.item === "Veldspar")?.quantity ?? 0; + const isDocked = Boolean(session.dockedStationPoiId); + const isAtBelt = session.currentPoiId === SLICE_BELT.poiId; + const isAtStation = session.currentPoiId === SLICE_STATION.poiId; + const isBusy = Boolean(session.activeOperation); + return { + activePoiName: session.currentPoiId === SLICE_BELT.poiId ? SLICE_BELT.name : session.currentPoiId === SLICE_STATION.poiId ? SLICE_STATION.name : session.currentPoiId ?? "Open space", + cargoUsed, + freeCargo: Math.max(0, session.cargoCapacity - cargoUsed), + oreQuantity, + hasRefinableOre: oreQuantity >= 333, + hasSellableCargo: sellableCargo(session.cargo).length > 0, + isDocked, + isAtBelt, + isAtStation, + isBusy, + canMine: isAtBelt && !isDocked && !isBusy && cargoUsed < session.cargoCapacity, + }; +} + +export function canUseStationService(session: GameSliceSession, service: SliceService): boolean { + const facts = getSliceFacts(session); + if (!facts.isDocked || facts.isBusy) return false; + if (service === "refining") return facts.hasRefinableOre; + if (service === "market") return facts.hasSellableCargo; + return true; +} + +export function getBlockedReason(session: GameSliceSession, command: SliceCommand): string | null { + const facts = getSliceFacts(session); + if (facts.isBusy) return "Another ship operation is already in progress."; + + switch (command) { + case "undock": + return facts.isDocked ? null : "Docking clamps are already released."; + case "travelToBelt": + if (facts.isDocked) return "Undock before plotting local travel."; + if (facts.isAtBelt) return "Already holding at the belt."; + return null; + case "travelToStation": + if (facts.isDocked) return "Already docked at the station."; + if (facts.isAtStation) return null; + return null; + case "dock": + if (facts.isDocked) return "Already docked."; + return facts.isAtStation ? null : "Return to the station grid before docking."; + case "mine": + if (!facts.isAtBelt) return "Mining requires the asteroid belt."; + if (facts.isDocked) return "Undock before activating mining lasers."; + if (facts.freeCargo <= 0) return "Cargo hold is full."; + return null; + case "openRefining": + if (!facts.isDocked) return "Dock at the station to use refining."; + return facts.hasRefinableOre ? null : "Refining needs at least 333 Veldspar."; + case "openFitting": + return facts.isDocked ? null : "Dock at the station to use fitting."; + case "openMarket": + if (!facts.isDocked) return "Dock at the station to use the market."; + return facts.hasSellableCargo ? null : "No sellable cargo is available."; + case "startCombat": + return null; + } +} + +export function getAvailableSliceCommands(session: GameSliceSession): Record { + const commands: SliceCommand[] = ["undock", "travelToBelt", "travelToStation", "dock", "mine", "openRefining", "openFitting", "openMarket", "startCombat"]; + return Object.fromEntries(commands.map((command) => [command, getBlockedReason(session, command) === null])) as Record; +} + +export function commandForObjective(session: GameSliceSession): SliceCommand { + const byObjective: Record = { + undock: "undock", + navigate_to_belt: "travelToBelt", + mine_ore: "mine", + dock_at_station: getSliceFacts(session).isAtStation ? "dock" : "travelToStation", + refine_ore: "openRefining", + fit_module: "openFitting", + sell_goods: "openMarket", + combat_trial: "startCombat", + }; + return byObjective[session.activeObjectiveId]; +} diff --git a/src/prototypes/game-slice/sliceEconomy.ts b/src/prototypes/game-slice/sliceEconomy.ts new file mode 100644 index 0000000..fba9c8f --- /dev/null +++ b/src/prototypes/game-slice/sliceEconomy.ts @@ -0,0 +1,62 @@ +import type { GameSliceSession, SliceCargoItem } from "./types"; + +export const SLICE_MINERAL_PRICES: Record = { + Veldspar: 12, + Tritanium: 5, + Pyerite: 9, + "Damaged Railgun": 1800, +}; + +export function getCargoUsed(session: Pick) { + return session.cargo.reduce((sum, item) => sum + item.quantity, 0); +} + +export function getCargoItem(session: Pick, item: string) { + return session.cargo.find((entry) => entry.item === item) ?? null; +} + +export function upsertCargo(cargo: SliceCargoItem[], incoming: SliceCargoItem): SliceCargoItem[] { + const existing = cargo.find((entry) => entry.item === incoming.item && entry.category === incoming.category); + if (!existing) return [...cargo, { ...incoming }]; + return cargo.map((entry) => + entry.item === incoming.item && entry.category === incoming.category + ? { ...entry, quantity: entry.quantity + incoming.quantity, unitPrice: incoming.unitPrice } + : entry, + ); +} + +export function removeCargo(cargo: SliceCargoItem[], item: string, quantity: number): SliceCargoItem[] { + let remaining = quantity; + return cargo + .map((entry) => { + if (entry.item !== item || remaining <= 0) return entry; + const removed = Math.min(entry.quantity, remaining); + remaining -= removed; + return { ...entry, quantity: entry.quantity - removed }; + }) + .filter((entry) => entry.quantity > 0); +} + +export function addCargoClamped(session: GameSliceSession, incoming: SliceCargoItem): { session: GameSliceSession; added: number } { + const free = Math.max(0, session.cargoCapacity - getCargoUsed(session)); + const added = Math.min(free, incoming.quantity); + if (added <= 0) return { session, added: 0 }; + return { + session: { ...session, cargo: upsertCargo(session.cargo, { ...incoming, quantity: added }) }, + added, + }; +} + +export function refineVeldspar(quantity: number): SliceCargoItem[] { + const batches = Math.floor(quantity / 333); + const tritanium = Math.floor(batches * 415 * 0.7); + const pyerite = Math.floor(batches * 42 * 0.7); + return [ + { item: "Tritanium", category: "mineral", quantity: tritanium, unitPrice: SLICE_MINERAL_PRICES.Tritanium }, + { item: "Pyerite", category: "mineral", quantity: pyerite, unitPrice: SLICE_MINERAL_PRICES.Pyerite }, + ].filter((item) => item.quantity > 0) as SliceCargoItem[]; +} + +export function sellableCargo(cargo: SliceCargoItem[]) { + return cargo.filter((item) => item.category === "mineral" || item.category === "ore" || item.category === "loot"); +} diff --git a/src/prototypes/game-slice/sliceNavigationBridge.ts b/src/prototypes/game-slice/sliceNavigationBridge.ts new file mode 100644 index 0000000..d2ed430 --- /dev/null +++ b/src/prototypes/game-slice/sliceNavigationBridge.ts @@ -0,0 +1,42 @@ +import { completeTravelSession, loadTravelSession, saveTravelSession, type TravelSession } from "../r3f/navigation/travelSession"; +import { applySliceEvent, loadGameSliceSession, saveGameSliceSession, withGameSliceSession } from "./gameSliceState"; +import type { GameSliceSession } from "./types"; + +export function preserveSliceSession(url: string, sliceSessionId: string | null): string { + return sliceSessionId ? withGameSliceSession(url, sliceSessionId) : url; +} + +export function linkSliceTravelSession(slice: GameSliceSession, travelSession: TravelSession): GameSliceSession { + return { ...slice, activeTravelSessionId: travelSession.id, currentSystemId: travelSession.status === "in_transit" ? travelSession.currentSystemId : slice.currentSystemId }; +} + +export function syncSliceFromTravelArrival(sliceSessionId: string | null, travelSession: TravelSession): GameSliceSession | null { + if (!sliceSessionId || travelSession.status !== "arrived") return null; + const slice = loadGameSliceSession(sliceSessionId); + if (!slice) return null; + const poi = travelSession.destinationPoi; + const next = poi + ? applySliceEvent({ ...slice, activeTravelSessionId: travelSession.id }, { + type: "navigation.arrived", + systemId: poi.systemId, + poiId: poi.poiId, + poiName: poi.poiName, + }) + : { ...slice, currentSystemId: travelSession.currentSystemId, activeTravelSessionId: travelSession.id }; + saveGameSliceSession(next); + return next; +} + +export function resolveSliceTravelCurrentSystem(slice: GameSliceSession, travelSession: TravelSession | null): string { + if (travelSession?.status === "in_transit") return travelSession.currentSystemId; + return slice.currentSystemId; +} + +export function recoverArrivedTravelSession(navSessionId: string | null) { + if (!navSessionId) return null; + const session = loadTravelSession(navSessionId); + if (!session) return null; + const arrived = session.status === "arrived" ? session : completeTravelSession(session); + saveTravelSession(arrived); + return arrived; +} diff --git a/src/prototypes/game-slice/sliceObjectives.ts b/src/prototypes/game-slice/sliceObjectives.ts new file mode 100644 index 0000000..acb37f0 --- /dev/null +++ b/src/prototypes/game-slice/sliceObjectives.ts @@ -0,0 +1,46 @@ +import type { GameSliceSession, SliceObjectiveId } from "./types"; + +export const SLICE_OBJECTIVES: Array<{ id: SliceObjectiveId; title: string; detail: string }> = [ + { id: "undock", title: "Undock", detail: "Launch from Jita IV - Moon 4." }, + { id: "navigate_to_belt", title: "Navigate to belt", detail: "Use Movement to reach Sol Asteroid Belt." }, + { id: "mine_ore", title: "Mine ore", detail: "Run one mining laser cycle for Veldspar." }, + { id: "dock_at_station", title: "Dock at station", detail: "Return to Jita IV - Moon 4 and dock." }, + { id: "refine_ore", title: "Refine ore", detail: "Convert Veldspar into minerals." }, + { id: "fit_module", title: "Fit module", detail: "Fit or confirm at least one module." }, + { id: "sell_goods", title: "Sell goods", detail: "Sell minerals or cargo on the market." }, + { id: "combat_trial", title: "Combat trial", detail: "Optional NPC bounty and loot branch." }, +]; + +export const REQUIRED_OBJECTIVES: SliceObjectiveId[] = [ + "undock", + "navigate_to_belt", + "mine_ore", + "dock_at_station", + "refine_ore", + "fit_module", + "sell_goods", +]; + +export function getNextObjective(completedObjectives: SliceObjectiveId[]): SliceObjectiveId { + return REQUIRED_OBJECTIVES.find((id) => !completedObjectives.includes(id)) ?? "combat_trial"; +} + +export function withCompletedObjective(session: GameSliceSession, objectiveId: SliceObjectiveId): GameSliceSession { + const completedObjectives = session.completedObjectives.includes(objectiveId) + ? session.completedObjectives + : [...session.completedObjectives, objectiveId]; + return { + ...session, + completedObjectives, + activeObjectiveId: getNextObjective(completedObjectives), + }; +} + +export function getObjectiveProgress(session: GameSliceSession) { + const requiredDone = REQUIRED_OBJECTIVES.filter((id) => session.completedObjectives.includes(id)).length; + return { + requiredDone, + requiredTotal: REQUIRED_OBJECTIVES.length, + percent: Math.round((requiredDone / REQUIRED_OBJECTIVES.length) * 100), + }; +} diff --git a/src/prototypes/game-slice/sliceWorld.ts b/src/prototypes/game-slice/sliceWorld.ts new file mode 100644 index 0000000..5d23cb5 --- /dev/null +++ b/src/prototypes/game-slice/sliceWorld.ts @@ -0,0 +1,25 @@ +export const SLICE_SYSTEM_ID = "sol"; + +export const SLICE_STATION = { + systemId: SLICE_SYSTEM_ID, + poiId: "sol-station-0", + name: "Jita IV - Moon 4", +} as const; + +export const SLICE_BELT = { + systemId: SLICE_SYSTEM_ID, + poiId: "sol-belt-0", + name: "Sol Asteroid Belt", +} as const; + +export const SLICE_DURATIONS = { + undock: 1000, + localTravel: 1800, + docking: 1000, + mining: 1200, +} as const; + +export const SLICE_POI_NAMES: Record = { + [SLICE_STATION.poiId]: SLICE_STATION.name, + [SLICE_BELT.poiId]: SLICE_BELT.name, +}; diff --git a/src/prototypes/game-slice/types.ts b/src/prototypes/game-slice/types.ts new file mode 100644 index 0000000..2345062 --- /dev/null +++ b/src/prototypes/game-slice/types.ts @@ -0,0 +1,94 @@ +export type GameSliceMode = + | "station" + | "undocking" + | "flight" + | "travel" + | "mining" + | "docking" + | "services" + | "combat"; + +export type LegacyGameSliceMode = + | GameSliceMode + | "map" + | "warp" + | "combat" + | "market" + | "fitting" + | "refining"; + +export type SliceService = "refining" | "fitting" | "market"; + +export type SliceOperation = + | { kind: "undocking"; startedAt: number; durationMs: number } + | { kind: "travel"; targetSystemId: string; targetPoiId: string; targetPoiName: string; startedAt: number; durationMs: number } + | { kind: "docking"; stationPoiId: string; stationName: string; startedAt: number; durationMs: number } + | { kind: "mining"; ore: string; quantity: number; startedAt: number; durationMs: number }; + +export type SliceObjectiveId = + | "undock" + | "navigate_to_belt" + | "mine_ore" + | "dock_at_station" + | "refine_ore" + | "fit_module" + | "sell_goods" + | "combat_trial"; + +export type SliceCargoItem = { + item: string; + category: "ore" | "mineral" | "loot" | "module"; + quantity: number; + unitPrice: number; +}; + +export type SliceFittedModule = { + id: string; + name: string; + type: string; + slot: "high" | "med" | "low"; + cpu: number; + power: number; + active?: boolean; +}; + +export type SliceEvent = + | { type: "station.undockStarted"; systemId: string; startedAt: number; durationMs: number } + | { type: "station.dockStarted"; stationPoiId: string; stationName: string; startedAt: number; durationMs: number } + | { type: "navigation.started"; targetSystemId: string; targetPoiId: string; targetPoiName: string; startedAt: number; durationMs: number } + | { type: "navigation.arrived"; systemId: string; poiId: string; poiName: string } + | { type: "station.docked"; systemId: string; stationPoiId: string; stationName: string } + | { type: "station.undocked"; systemId: string } + | { type: "mining.started"; ore: string; quantity: number; startedAt: number; durationMs: number } + | { type: "mining.completed"; ore: string; quantity: number } + | { type: "station.serviceOpened"; service: SliceService } + | { type: "station.serviceClosed" } + | { type: "refining.completed"; ore: string; inputQuantity: number; minerals: SliceCargoItem[] } + | { type: "market.sold"; item: string; quantity: number; isk: number } + | { type: "fitting.changed"; modules: SliceFittedModule[] } + | { type: "combat.started" } + | { type: "combat.victory"; targetName: string; bounty: number; loot: SliceCargoItem[] } + | { type: "xp.awarded"; skill: string; amount: number } + | { type: "zora.observed"; trigger: string }; + +export type GameSliceSession = { + id: string; + mode: GameSliceMode; + currentSystemId: string; + currentPoiId: string | null; + dockedStationPoiId: string | null; + dockedStationName: string | null; + activeTravelSessionId: string | null; + activeService: SliceService | null; + activeOperation: SliceOperation | null; + wallet: number; + cargoCapacity: number; + cargo: SliceCargoItem[]; + fittedModules: SliceFittedModule[]; + zoraModules: string[]; + skills: Record; + completedObjectives: SliceObjectiveId[]; + activeObjectiveId: SliceObjectiveId; + eventLog: Array<{ id: string; at: number; event: SliceEvent; message: string }>; + updatedAt: number; +}; diff --git a/src/prototypes/game-slice/ui/SliceActionRail.tsx b/src/prototypes/game-slice/ui/SliceActionRail.tsx new file mode 100644 index 0000000..5bd1601 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceActionRail.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { slicePanel } from "./sliceStyles"; + +export function SliceActionRail({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceCargoPanel.tsx b/src/prototypes/game-slice/ui/SliceCargoPanel.tsx new file mode 100644 index 0000000..5195d1a --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceCargoPanel.tsx @@ -0,0 +1,26 @@ +import type { GameSliceSession } from "../types"; +import { getCargoUsed } from "../sliceEconomy"; +import { metricLabel, slicePanel } from "./sliceStyles"; + +export function SliceCargoPanel({ session }: { session: GameSliceSession }) { + const cargoUsed = getCargoUsed(session); + return ( +
+
+ Cargo + {cargoUsed}/{session.cargoCapacity} +
+
+ {Array.from({ length: 5 }).map((_, index) => { + const item = session.cargo[index]; + return ( +
+ {item?.item ?? "Empty"} + {item?.quantity.toLocaleString() ?? "-"} +
+ ); + })} +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceCombatStage.tsx b/src/prototypes/game-slice/ui/SliceCombatStage.tsx new file mode 100644 index 0000000..ee055af --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceCombatStage.tsx @@ -0,0 +1,65 @@ +import { useEffect, useReducer, useRef } from "react"; +import { CombatScene } from "../../r3f/combat/CombatScene"; +import { combatReducer, initialCombatState } from "../../r3f/combat/combatState"; +import type { SliceEvent } from "../types"; +import { metricLabel, sliceButton, slicePanel } from "./sliceStyles"; + +function MiniBar({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+ {label} +
+
+
+ {value.toFixed(0)}% +
+ ); +} + +export function SliceCombatStage({ emit }: { emit: (event: SliceEvent) => void }) { + const [state, dispatch] = useReducer(combatReducer, initialCombatState); + const victoryEmitted = useRef(false); + + const emitVictory = () => { + if (victoryEmitted.current) return; + victoryEmitted.current = true; + emit({ type: "combat.victory", targetName: "Guristas Pirata", bounty: 7500, loot: [{ item: "Damaged Railgun", category: "loot", quantity: 1, unitPrice: 1800 }] }); + emit({ type: "xp.awarded", skill: "Gunnery", amount: 45 }); + }; + + useEffect(() => { + if (state.enemy.hull > 0) return; + emitVictory(); + // emitVictory intentionally closes over the stable ref and emit callback. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [emit, state.enemy.hull]); + + return ( +
+ +
+
+
USS ENTERPRISE
+ + + +
+
+
Guristas Pirata
+ + + + + +
+
+ {state.modules.map((module) => ( + + ))} +
+
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceDemoLinks.tsx b/src/prototypes/game-slice/ui/SliceDemoLinks.tsx new file mode 100644 index 0000000..f186928 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceDemoLinks.tsx @@ -0,0 +1,34 @@ +import { withGameSliceSession } from "../gameSliceState"; +import type { GameSliceSession } from "../types"; +import { sliceButton, slicePanel } from "./sliceStyles"; + +const links = [ + ["/docs/demos/game-loop", "Loop"], + ["/docs/demos/starmap", "Star Map"], + ["/docs/demos/movement", "Movement"], + ["/docs/demos/warp", "Warp"], + ["/docs/demos/game-hud", "HUD"], + ["/docs/demos/refining", "Refining"], + ["/docs/demos/fitting", "Fitting"], + ["/docs/demos/market", "Market"], + ["/docs/demos/combat", "Combat"], + ["/docs/demos/progression", "XP"], + ["/docs/demos/bounty", "Bounty"], + ["/docs/demos/chat", "Chat"], + ["/docs/demos/zora", "Zora"], +]; + +export function SliceDemoLinks({ session, compact = false }: { session: GameSliceSession; compact?: boolean }) { + return ( +
+ {!compact && Connected Demos} +
+ {links.map(([href, label]) => ( + + ))} +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceEventLog.tsx b/src/prototypes/game-slice/ui/SliceEventLog.tsx new file mode 100644 index 0000000..bd75a33 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceEventLog.tsx @@ -0,0 +1,20 @@ +import type { GameSliceSession } from "../types"; +import { slicePanel } from "./sliceStyles"; + +export function SliceEventLog({ session }: { session: GameSliceSession }) { + return ( +
+ Event Log +
+ {Array.from({ length: 8 }).map((_, index) => { + const entry = session.eventLog[index]; + return ( +
+ {entry ? `${new Date(entry.at).toLocaleTimeString("en", { hour12: false })} ${entry.message}` : "No event"} +
+ ); + })} +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceFittingService.tsx b/src/prototypes/game-slice/ui/SliceFittingService.tsx new file mode 100644 index 0000000..4efae68 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceFittingService.tsx @@ -0,0 +1,74 @@ +import { useMemo, useState } from "react"; +import type { GameSliceSession, SliceFittedModule } from "../types"; +import { DEFAULT_SLICE_MODULES } from "../gameSliceState"; +import { metricLabel, sliceButton, slicePrimaryButton } from "./sliceStyles"; + +const SHIP_LIMITS = { + high: 3, + med: 3, + low: 2, + cpu: 120, + power: 80, +} as const; + +const AVAILABLE_MODULES: SliceFittedModule[] = [ + ...DEFAULT_SLICE_MODULES, + { id: "zora-comms", name: "Comms Uplink", type: "ai", slot: "med", power: 10, cpu: 12, active: false }, + { id: "zora-market", name: "Market Scanner", type: "ai", slot: "med", power: 12, cpu: 18, active: false }, + { id: "zora-event", name: "Event Logger", type: "ai", slot: "low", power: 4, cpu: 10, active: false }, + { id: "railgun1", name: "150mm Railgun", type: "weapon", slot: "high", power: 18, cpu: 35, active: false }, +]; + +export function SliceFittingService({ + session, + onUpdate, +}: { + session: GameSliceSession; + onUpdate: (modules: SliceFittedModule[]) => void; +}) { + const [draft, setDraft] = useState(session.fittedModules); + const usage = useMemo(() => ({ + cpu: draft.reduce((sum, module) => sum + module.cpu, 0), + power: draft.reduce((sum, module) => sum + module.power, 0), + }), [draft]); + + const slotCount = (slot: SliceFittedModule["slot"]) => draft.filter((module) => module.slot === slot).length; + const canFit = (module: SliceFittedModule) => { + const slotLimit = SHIP_LIMITS[module.slot]; + return slotCount(module.slot) < slotLimit && usage.cpu + module.cpu <= SHIP_LIMITS.cpu && usage.power + module.power <= SHIP_LIMITS.power; + }; + const addModule = (module: SliceFittedModule) => { + if (!canFit(module)) return; + setDraft((prev) => [...prev, { ...module, id: `${module.id}-${Date.now().toString(36)}` }]); + }; + + return ( +
+
+
Merlin Fitting Console
+

Fitting

+
+
+ SHIP_LIMITS.cpu ? "#ef4444" : "#22d3ee" }}>CPU {usage.cpu}/{SHIP_LIMITS.cpu} + SHIP_LIMITS.power ? "#ef4444" : "#22c55e" }}>PWR {usage.power}/{SHIP_LIMITS.power} +
+
+
Fitted Modules
+ {draft.map((module, index) => ( + + ))} +
+
+
Available
+ {AVAILABLE_MODULES.map((module) => ( + + ))} +
+ +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceFlightStage.tsx b/src/prototypes/game-slice/ui/SliceFlightStage.tsx new file mode 100644 index 0000000..df5f3cc --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceFlightStage.tsx @@ -0,0 +1,66 @@ +import { useEffect, useMemo, useState } from "react"; +import { loadGalaxyData } from "../../r3f/shared/galaxyData"; +import { getSystemPoiPosition } from "../../r3f/shared/poiOrbit"; +import type { GalaxySystem, Vec3 } from "../../r3f/shared/types"; +import { MOVEMENT_SYSTEM_SCALE, MovementScene } from "../../r3f/movement/MovementScene"; +import type { LocalEntity, LocalWaypoint } from "../../r3f/movement/movementState"; +import type { GameSliceSession } from "../types"; +import { SLICE_BELT, SLICE_POI_NAMES, SLICE_STATION } from "../sliceWorld"; +import { metricLabel, slicePanel } from "./sliceStyles"; + +const fallbackEntities: LocalEntity[] = [ + { id: "slice-belt", name: SLICE_BELT.name, type: "asteroid", x: 620, y: 320, distance: 42 }, + { id: "slice-station", name: SLICE_STATION.name, type: "station", x: 430, y: 260, distance: 8 }, +]; + +function fallbackPosition(poiId: string | null): Vec3 { + if (poiId === SLICE_BELT.poiId) return [26, 0, -8]; + if (poiId === SLICE_STATION.poiId) return [-10, 0, 10]; + return [0, 0, 0]; +} + +export function SliceFlightStage({ session }: { session: GameSliceSession }) { + const [currentSystem, setCurrentSystem] = useState(null); + + useEffect(() => { + loadGalaxyData().then(({ systems }) => { + setCurrentSystem(systems.find((system) => system.id === session.currentSystemId) ?? systems.find((system) => system.id === "sol") ?? null); + }); + }, [session.currentSystemId]); + + const shipPosition = useMemo(() => { + if (!currentSystem || !session.currentPoiId) return fallbackPosition(session.currentPoiId); + return getSystemPoiPosition({ + systemId: currentSystem.id, + planets: currentSystem.planets, + pointsOfInterest: currentSystem.pointsOfInterest, + poiId: session.currentPoiId, + scale: MOVEMENT_SYSTEM_SCALE, + expanded: true, + }); + }, [currentSystem, session.currentPoiId]); + + const waypoint: LocalWaypoint[] = session.currentPoiId + ? [{ id: session.currentPoiId, name: SLICE_POI_NAMES[session.currentPoiId] ?? session.currentPoiId, type: "poi", systemId: session.currentSystemId, poiId: session.currentPoiId, position: shipPosition, arrived: true }] + : []; + + return ( +
+ {}} + onWaypointPick={() => {}} + onPoiPick={() => {}} + /> +
+ Flight Mode + {SLICE_POI_NAMES[session.currentPoiId ?? ""] ?? "Open space"} + Use the primary action rail to plot local travel. +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceMarketService.tsx b/src/prototypes/game-slice/ui/SliceMarketService.tsx new file mode 100644 index 0000000..95b5c6d --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceMarketService.tsx @@ -0,0 +1,35 @@ +import type { GameSliceSession } from "../types"; +import { sellableCargo } from "../sliceEconomy"; +import { metricLabel, sliceButton } from "./sliceStyles"; + +export function SliceMarketService({ + session, + onSell, +}: { + session: GameSliceSession; + onSell: (item: string, quantity: number, unitPrice: number) => void; +}) { + const cargo = sellableCargo(session.cargo); + return ( +
+
+
Commodities Exchange
+

Market

+
+ {cargo.length === 0 ? ( +

No sellable cargo is available.

+ ) : cargo.map((item) => ( +
+
+ {item.item} + {(item.quantity * item.unitPrice).toLocaleString()} ISK +
+
+ {item.quantity.toLocaleString()} units · {item.unitPrice} ISK/unit + +
+
+ ))} +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceMiningStage.tsx b/src/prototypes/game-slice/ui/SliceMiningStage.tsx new file mode 100644 index 0000000..419a9a9 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceMiningStage.tsx @@ -0,0 +1,23 @@ +import type { GameSliceSession } from "../types"; +import { SLICE_BELT } from "../sliceWorld"; +import { metricLabel, slicePanel } from "./sliceStyles"; +import { SliceProgressBar } from "./SliceProgressBar"; + +export function SliceMiningStage({ session, progress }: { session: GameSliceSession; progress: number }) { + const operation = session.activeOperation?.kind === "mining" ? session.activeOperation : null; + return ( +
+
+
Mining Operation
+

{SLICE_BELT.name}

+

+ Mining laser cycle in progress. Cargo transfer will post to the event log when the cycle finishes. +

+ +
+ {operation ? `${Math.round(progress * 100)}% · ${operation.quantity.toLocaleString()} ${operation.ore}` : `${session.cargo.length} cargo stacks`} +
+
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceModuleRack.tsx b/src/prototypes/game-slice/ui/SliceModuleRack.tsx new file mode 100644 index 0000000..85240ee --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceModuleRack.tsx @@ -0,0 +1,18 @@ +import type { GameSliceSession } from "../types"; +import { slicePanel } from "./sliceStyles"; + +export function SliceModuleRack({ session }: { session: GameSliceSession }) { + const modules = session.fittedModules.slice(0, 8); + return ( +
+ {Array.from({ length: 8 }).map((_, index) => { + const module = modules[index]; + return ( + + ); + })} +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx b/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx new file mode 100644 index 0000000..c465449 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx @@ -0,0 +1,33 @@ +import type { GameSliceSession } from "../types"; +import { SLICE_OBJECTIVES, getObjectiveProgress } from "../sliceObjectives"; +import { metricLabel, slicePanel } from "./sliceStyles"; + +export function SliceObjectiveTracker({ session }: { session: GameSliceSession }) { + const progress = getObjectiveProgress(session); + return ( +
+
+ Objectives + {progress.percent}% +
+
+
+
+
+ {SLICE_OBJECTIVES.map((objective) => { + const done = session.completedObjectives.includes(objective.id); + const active = session.activeObjectiveId === objective.id; + return ( +
+ {done ? "✓" : active ? ">" : "·"} +
+
{objective.title}
+
{objective.detail}
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceProgressBar.tsx b/src/prototypes/game-slice/ui/SliceProgressBar.tsx new file mode 100644 index 0000000..396aaef --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceProgressBar.tsx @@ -0,0 +1,7 @@ +export function SliceProgressBar({ value, color = "#f0a030" }: { value: number; color?: string }) { + return ( +
+
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceRefiningService.tsx b/src/prototypes/game-slice/ui/SliceRefiningService.tsx new file mode 100644 index 0000000..77223ab --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceRefiningService.tsx @@ -0,0 +1,45 @@ +import type { GameSliceSession } from "../types"; +import { getCargoItem, refineVeldspar } from "../sliceEconomy"; +import { metricLabel, sliceButton, slicePrimaryButton } from "./sliceStyles"; + +export function SliceRefiningService({ + session, + onRefine, +}: { + session: GameSliceSession; + onRefine: (quantity: number) => void; +}) { + const ore = getCargoItem(session, "Veldspar"); + const maxBatches = Math.floor((ore?.quantity ?? 0) / 333); + const inputQuantity = maxBatches * 333; + const output = inputQuantity > 0 ? refineVeldspar(inputQuantity) : []; + + return ( +
+
+
Reprocessing Plant
+

Refining

+
+
+
+ Veldspar + {(ore?.quantity ?? 0).toLocaleString()} +
+
+ {maxBatches > 0 ? `${maxBatches} batch${maxBatches === 1 ? "" : "es"} ready · ${inputQuantity.toLocaleString()} units` : "Needs at least 333 units."} +
+
+
+ {output.map((item) => ( +
+ {item.item} + {item.quantity.toLocaleString()} +
+ ))} +
+ +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceServicesStage.tsx b/src/prototypes/game-slice/ui/SliceServicesStage.tsx new file mode 100644 index 0000000..1924142 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceServicesStage.tsx @@ -0,0 +1,32 @@ +import type { GameSliceSession } from "../types"; +import { sliceButton, slicePanel } from "./sliceStyles"; +import { SliceRefiningService } from "./SliceRefiningService"; +import { SliceFittingService } from "./SliceFittingService"; +import { SliceMarketService } from "./SliceMarketService"; + +export function SliceServicesStage({ + session, + onClose, + onRefine, + onUpdateFit, + onSell, +}: { + session: GameSliceSession; + onClose: () => void; + onRefine: (quantity: number) => void; + onUpdateFit: Parameters[0]["onUpdate"]; + onSell: (item: string, quantity: number, unitPrice: number) => void; +}) { + return ( +
+
+ {session.dockedStationName ?? "Station Services"} + +
+ {session.activeService === "refining" && } + {session.activeService === "fitting" && } + {session.activeService === "market" && } + {!session.activeService &&

Select a station service from the action rail.

} +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceShell.tsx b/src/prototypes/game-slice/ui/SliceShell.tsx new file mode 100644 index 0000000..ff44252 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceShell.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; + +export function SliceShell({ + top, + left, + center, + right, + bottom, +}: { + top: ReactNode; + left: ReactNode; + center: ReactNode; + right: ReactNode; + bottom: ReactNode; +}) { + return ( +
+ +
+
{top}
+
{left}
+
{center}
+
{right}
+
{bottom}
+
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceShipStatus.tsx b/src/prototypes/game-slice/ui/SliceShipStatus.tsx new file mode 100644 index 0000000..b8ff2e7 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceShipStatus.tsx @@ -0,0 +1,35 @@ +import type { GameSliceSession } from "../types"; +import { getCargoUsed } from "../sliceEconomy"; +import { metricLabel, slicePanel } from "./sliceStyles"; + +function Bar({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{label}{value}%
+
+
+
+
+ ); +} + +export function SliceShipStatus({ session }: { session: GameSliceSession }) { + const cargoUsed = getCargoUsed(session); + return ( +
+ Ship Status +
+ + + + +
+
+ Cargo + {cargoUsed.toLocaleString()} / {session.cargoCapacity.toLocaleString()} + Modules + {session.fittedModules.length} +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceStage.tsx b/src/prototypes/game-slice/ui/SliceStage.tsx new file mode 100644 index 0000000..05e93b2 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceStage.tsx @@ -0,0 +1,58 @@ +import type { SliceFacts } from "../sliceController"; +import type { GameSliceSession, SliceEvent, SliceFittedModule } from "../types"; +import { metricLabel, slicePanel } from "./sliceStyles"; +import { SliceProgressBar } from "./SliceProgressBar"; +import { SliceStationStage } from "./SliceStationStage"; +import { SliceFlightStage } from "./SliceFlightStage"; +import { SliceTravelStage } from "./SliceTravelStage"; +import { SliceMiningStage } from "./SliceMiningStage"; +import { SliceServicesStage } from "./SliceServicesStage"; +import { SliceCombatStage } from "./SliceCombatStage"; + +function OperationStage({ session, progress }: { session: GameSliceSession; progress: number }) { + const operation = session.activeOperation; + const title = operation?.kind === "undocking" ? "Undocking" : operation?.kind === "docking" ? "Docking" : "Ship Operation"; + const detail = operation?.kind === "docking" ? `Approaching ${operation.stationName}.` : "Station traffic control is sequencing the ship."; + return ( +
+
+
Traffic Control
+

{title}

+

{detail}

+ +
+
+ ); +} + +export function SliceStage({ + session, + facts, + operationProgress, + emit, + onOpenService, + onCloseService, + onRefine, + onUpdateFit, + onSell, +}: { + session: GameSliceSession; + facts: SliceFacts; + operationProgress: number; + emit: (event: SliceEvent) => void; + onOpenService: (service: "refining" | "fitting" | "market") => void; + onCloseService: () => void; + onRefine: (quantity: number) => void; + onUpdateFit: (modules: SliceFittedModule[]) => void; + onSell: (item: string, quantity: number, unitPrice: number) => void; +}) { + if (session.mode === "services") { + return ; + } + if (session.mode === "travel") return ; + if (session.mode === "mining") return ; + if (session.mode === "undocking" || session.mode === "docking") return ; + if (session.mode === "combat") return ; + if (session.mode === "station") return ; + return ; +} diff --git a/src/prototypes/game-slice/ui/SliceStationPanel.tsx b/src/prototypes/game-slice/ui/SliceStationPanel.tsx new file mode 100644 index 0000000..92126e5 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceStationPanel.tsx @@ -0,0 +1,18 @@ +import type { GameSliceSession } from "../types"; +import { metricLabel, slicePanel } from "./sliceStyles"; + +export function SliceStationPanel({ session }: { session: GameSliceSession }) { + return ( +
+ Station +
+ Status + {session.dockedStationName ? "Docked" : "Undocked"} + Facility + {session.dockedStationName ?? "None"} + Services + {session.dockedStationName ? "Market / Fitting / Refining" : "Unavailable"} +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceStationStage.tsx b/src/prototypes/game-slice/ui/SliceStationStage.tsx new file mode 100644 index 0000000..828c0cb --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceStationStage.tsx @@ -0,0 +1,45 @@ +import type { GameSliceSession } from "../types"; +import type { SliceFacts } from "../sliceController"; +import { SLICE_STATION } from "../sliceWorld"; +import { metricLabel, sliceButton, slicePanel } from "./sliceStyles"; + +export function SliceStationStage({ + session, + facts, + onOpenService, +}: { + session: GameSliceSession; + facts: SliceFacts; + onOpenService: (service: "refining" | "fitting" | "market") => void; +}) { + return ( +
+
+
Docked Facility
+

{session.dockedStationName ?? SLICE_STATION.name}

+

+ Station services are live in this shell. Undock, work the belt, then return here to refine, fit, and sell without leaving the loop. +

+
+ +
+ {[ + ["LOCATION", facts.activePoiName], + ["CARGO", `${facts.cargoUsed.toLocaleString()} / ${session.cargoCapacity.toLocaleString()}`], + ["WALLET", `${session.wallet.toLocaleString()} ISK`], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ +
+ + + +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceTopBar.tsx b/src/prototypes/game-slice/ui/SliceTopBar.tsx new file mode 100644 index 0000000..9f9853b --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceTopBar.tsx @@ -0,0 +1,21 @@ +import type { GameSliceSession } from "../types"; +import { SLICE_OBJECTIVES } from "../sliceObjectives"; +import { sliceButton, slicePanel } from "./sliceStyles"; + +const SYSTEM_NAMES: Record = { sol: "Sol", amarr: "Amarr", heinoo: "Hek", rens: "Rens", dodixie: "Dodixie" }; + +export function SliceTopBar({ session, onReset }: { session: GameSliceSession; onReset: () => void }) { + const objective = SLICE_OBJECTIVES.find((item) => item.id === session.activeObjectiveId); + return ( +
+ + MVP LOOP SLICE + {SYSTEM_NAMES[session.currentSystemId] ?? session.currentSystemId} + {session.dockedStationName ? `Docked: ${session.dockedStationName}` : "In space"} + Mode: {session.mode.toUpperCase()} + ISK {session.wallet.toLocaleString()} + {objective?.title ?? session.activeObjectiveId} + +
+ ); +} diff --git a/src/prototypes/game-slice/ui/SliceTravelStage.tsx b/src/prototypes/game-slice/ui/SliceTravelStage.tsx new file mode 100644 index 0000000..5ea4fa7 --- /dev/null +++ b/src/prototypes/game-slice/ui/SliceTravelStage.tsx @@ -0,0 +1,75 @@ +import { useEffect, useMemo, useState } from "react"; +import { loadGalaxyData } from "../../r3f/shared/galaxyData"; +import { getSystemPoiPosition } from "../../r3f/shared/poiOrbit"; +import type { GalaxySystem, Vec3 } from "../../r3f/shared/types"; +import { MOVEMENT_SYSTEM_SCALE, MovementScene } from "../../r3f/movement/MovementScene"; +import type { LocalEntity, LocalWaypoint } from "../../r3f/movement/movementState"; +import type { GameSliceSession } from "../types"; +import { SLICE_BELT, SLICE_POI_NAMES, SLICE_STATION } from "../sliceWorld"; +import { metricLabel, slicePanel } from "./sliceStyles"; +import { SliceProgressBar } from "./SliceProgressBar"; + +const entities: LocalEntity[] = [ + { id: "slice-belt", name: SLICE_BELT.name, type: "asteroid", x: 620, y: 320, distance: 42 }, + { id: "slice-station", name: SLICE_STATION.name, type: "station", x: 430, y: 260, distance: 8 }, +]; + +function fallbackPosition(poiId: string | null): Vec3 { + if (poiId === SLICE_BELT.poiId) return [26, 0, -8]; + if (poiId === SLICE_STATION.poiId) return [-10, 0, 10]; + return [0, 0, 0]; +} + +function lerp(a: Vec3, b: Vec3, t: number): Vec3 { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +export function SliceTravelStage({ session, progress }: { session: GameSliceSession; progress: number }) { + const [currentSystem, setCurrentSystem] = useState(null); + const operation = session.activeOperation?.kind === "travel" ? session.activeOperation : null; + + useEffect(() => { + loadGalaxyData().then(({ systems }) => { + setCurrentSystem(systems.find((system) => system.id === session.currentSystemId) ?? systems.find((system) => system.id === "sol") ?? null); + }); + }, [session.currentSystemId]); + + const targetPoiId = operation?.targetPoiId ?? session.currentPoiId; + const targetName = operation?.targetPoiName ?? SLICE_POI_NAMES[targetPoiId ?? ""] ?? "Local waypoint"; + const targetPosition = useMemo(() => { + if (!currentSystem || !targetPoiId) return fallbackPosition(targetPoiId); + return getSystemPoiPosition({ + systemId: currentSystem.id, + planets: currentSystem.planets, + pointsOfInterest: currentSystem.pointsOfInterest, + poiId: targetPoiId, + scale: MOVEMENT_SYSTEM_SCALE, + expanded: true, + }); + }, [currentSystem, targetPoiId]); + const startPosition = fallbackPosition(session.currentPoiId); + const shipPosition = operation ? lerp(startPosition, targetPosition, progress) : startPosition; + const waypoints: LocalWaypoint[] = operation ? [{ id: targetPoiId ?? "target", name: targetName, type: "poi", systemId: operation.targetSystemId, poiId: targetPoiId ?? undefined, position: targetPosition }] : []; + + return ( +
+ {}} + onWaypointPick={() => {}} + onPoiPick={() => {}} + /> +
+
+ Local Travel + {targetName} +
+ +
+
+ ); +} diff --git a/src/prototypes/game-slice/ui/sliceStyles.ts b/src/prototypes/game-slice/ui/sliceStyles.ts new file mode 100644 index 0000000..b9fc4ed --- /dev/null +++ b/src/prototypes/game-slice/ui/sliceStyles.ts @@ -0,0 +1,33 @@ +import type { CSSProperties } from "react"; + +export const slicePanel: CSSProperties = { + background: "rgba(9,15,27,0.88)", + border: "1px solid rgba(148,163,184,0.22)", + borderRadius: 8, + backdropFilter: "blur(10px)", +}; + +export const sliceButton: CSSProperties = { + border: "1px solid rgba(34,211,238,0.38)", + background: "rgba(34,211,238,0.08)", + color: "#d4dce8", + borderRadius: 6, + padding: "7px 10px", + fontFamily: "var(--font-mono)", + fontSize: 11, + cursor: "pointer", +}; + +export const slicePrimaryButton: CSSProperties = { + ...sliceButton, + border: "1px solid rgba(240,160,48,0.72)", + background: "rgba(240,160,48,0.18)", + color: "#f8fafc", +}; + +export const metricLabel: CSSProperties = { + color: "#94a3b8", + fontFamily: "var(--font-mono)", + fontSize: 10, + textTransform: "uppercase", +}; diff --git a/src/prototypes/game-slice/useGameSliceSession.ts b/src/prototypes/game-slice/useGameSliceSession.ts new file mode 100644 index 0000000..60ad84b --- /dev/null +++ b/src/prototypes/game-slice/useGameSliceSession.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from "react"; +import { + applySliceEvent, + createGameSliceSession, + getGameSliceSessionIdFromUrl, + loadGameSliceSession, + resetGameSliceSession, + saveGameSliceSession, + withGameSliceSession, +} from "./gameSliceState"; +import type { GameSliceSession, SliceEvent } from "./types"; + +export function useGameSliceSession(options: { createIfMissing?: boolean } = {}) { + const { createIfMissing = false } = options; + const [session, setSession] = useState(null); + const [missingRequestedSession, setMissingRequestedSession] = useState(null); + + useEffect(() => { + const id = getGameSliceSessionIdFromUrl(); + const loaded = id ? loadGameSliceSession(id) : null; + if (loaded) { + setSession(loaded); + return; + } + if (id) setMissingRequestedSession(id); + if (!id && createIfMissing) { + const created = createGameSliceSession(); + saveGameSliceSession(created); + window.history.replaceState(null, "", withGameSliceSession(`${window.location.pathname}${window.location.search}`, created.id)); + setSession(created); + } + }, [createIfMissing]); + + const persist = useCallback((updater: GameSliceSession | ((session: GameSliceSession) => GameSliceSession)) => { + setSession((prev) => { + if (!prev) return prev; + const next = typeof updater === "function" ? updater(prev) : updater; + saveGameSliceSession(next); + return next; + }); + }, []); + + const emit = useCallback((event: SliceEvent) => { + setSession((prev) => { + if (!prev) return prev; + const next = applySliceEvent(prev, event); + saveGameSliceSession(next); + return next; + }); + }, []); + + const reset = useCallback(() => { + const id = session?.id ?? getGameSliceSessionIdFromUrl() ?? undefined; + const next = id ? resetGameSliceSession(id) : createGameSliceSession(); + if (!id) saveGameSliceSession(next); + window.history.replaceState(null, "", withGameSliceSession(window.location.pathname, next.id)); + setMissingRequestedSession(null); + setSession(next); + return next; + }, [session?.id]); + + return { session, setSession: persist, emit, reset, missingRequestedSession }; +} diff --git a/src/prototypes/game-slice/useSliceController.ts b/src/prototypes/game-slice/useSliceController.ts new file mode 100644 index 0000000..f25c508 --- /dev/null +++ b/src/prototypes/game-slice/useSliceController.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { refineVeldspar } from "./sliceEconomy"; +import { commandForObjective, getAvailableSliceCommands, getBlockedReason, getSliceFacts, type SliceCommand } from "./sliceController"; +import { useGameSliceSession } from "./useGameSliceSession"; +import { SLICE_BELT, SLICE_DURATIONS, SLICE_STATION } from "./sliceWorld"; +import type { GameSliceSession, SliceEvent, SliceFittedModule, SliceService } from "./types"; + +function completionEventFor(session: GameSliceSession): SliceEvent | null { + const operation = session.activeOperation; + if (!operation) return null; + if (Date.now() - operation.startedAt < operation.durationMs) return null; + + if (operation.kind === "undocking") return { type: "station.undocked", systemId: session.currentSystemId }; + if (operation.kind === "travel") return { type: "navigation.arrived", systemId: operation.targetSystemId, poiId: operation.targetPoiId, poiName: operation.targetPoiName }; + if (operation.kind === "docking") return { type: "station.docked", systemId: session.currentSystemId, stationPoiId: operation.stationPoiId, stationName: operation.stationName }; + if (operation.kind === "mining") return { type: "mining.completed", ore: operation.ore, quantity: operation.quantity }; + return null; +} + +export function getOperationProgress(session: GameSliceSession, now: number): number { + const operation = session.activeOperation; + if (!operation) return 0; + return Math.max(0, Math.min(1, (now - operation.startedAt) / operation.durationMs)); +} + +export function useSliceController() { + const { session, emit, reset, missingRequestedSession } = useGameSliceSession({ createIfMissing: true }); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 100); + return () => window.clearInterval(id); + }, []); + + useEffect(() => { + if (!session?.activeOperation) return; + const completion = completionEventFor(session); + if (completion) { + emit(completion); + if (completion.type === "mining.completed") emit({ type: "xp.awarded", skill: "Mining", amount: 25 }); + return; + } + const remaining = Math.max(0, session.activeOperation.startedAt + session.activeOperation.durationMs - Date.now()); + const timeout = window.setTimeout(() => { + const nextCompletion = completionEventFor(session); + if (nextCompletion) { + emit(nextCompletion); + if (nextCompletion.type === "mining.completed") emit({ type: "xp.awarded", skill: "Mining", amount: 25 }); + } + }, remaining + 20); + return () => window.clearTimeout(timeout); + }, [emit, session]); + + const facts = useMemo(() => session ? getSliceFacts(session) : null, [session]); + const availableCommands = useMemo(() => session ? getAvailableSliceCommands(session) : null, [session]); + const primaryCommand = useMemo(() => session ? commandForObjective(session) : null, [session]); + const operationProgress = useMemo(() => session ? getOperationProgress(session, now) : 0, [now, session]); + + const blockedReason = useCallback((command: SliceCommand) => session ? getBlockedReason(session, command) : "Session is not ready.", [session]); + + const guarded = useCallback((command: SliceCommand, run: (session: GameSliceSession) => void) => { + if (!session || getBlockedReason(session, command)) return; + run(session); + }, [session]); + + const startUndock = useCallback(() => guarded("undock", (current) => { + emit({ type: "station.undockStarted", systemId: current.currentSystemId, startedAt: Date.now(), durationMs: SLICE_DURATIONS.undock }); + }), [emit, guarded]); + + const travelToBelt = useCallback(() => guarded("travelToBelt", () => { + emit({ type: "navigation.started", targetSystemId: SLICE_BELT.systemId, targetPoiId: SLICE_BELT.poiId, targetPoiName: SLICE_BELT.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel }); + }), [emit, guarded]); + + const travelToStation = useCallback(() => guarded("travelToStation", () => { + emit({ type: "navigation.started", targetSystemId: SLICE_STATION.systemId, targetPoiId: SLICE_STATION.poiId, targetPoiName: SLICE_STATION.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel }); + }), [emit, guarded]); + + const dock = useCallback(() => guarded("dock", () => { + emit({ type: "station.dockStarted", stationPoiId: SLICE_STATION.poiId, stationName: SLICE_STATION.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.docking }); + }), [emit, guarded]); + + const mine = useCallback(() => guarded("mine", (current) => { + const facts = getSliceFacts(current); + emit({ type: "mining.started", ore: "Veldspar", quantity: Math.min(1000, facts.freeCargo), startedAt: Date.now(), durationMs: SLICE_DURATIONS.mining }); + }), [emit, guarded]); + + const openService = useCallback((service: SliceService) => { + const commandByService: Record = { refining: "openRefining", fitting: "openFitting", market: "openMarket" }; + guarded(commandByService[service], () => emit({ type: "station.serviceOpened", service })); + }, [emit, guarded]); + + const closeService = useCallback(() => { + if (session?.activeService) emit({ type: "station.serviceClosed" }); + }, [emit, session?.activeService]); + + const startCombat = useCallback(() => { + if (!session || getBlockedReason(session, "startCombat")) return; + emit({ type: "combat.started" }); + emit({ type: "zora.observed", trigger: "combat.started" }); + }, [emit, session]); + + const refineVeldsparStack = useCallback((inputQuantity: number) => { + if (!session) return; + const ore = session.cargo.find((item) => item.item === "Veldspar"); + const quantity = Math.min(inputQuantity, ore?.quantity ?? 0); + if (quantity < 333) return; + const used = Math.floor(quantity / 333) * 333; + emit({ type: "refining.completed", ore: "Veldspar", inputQuantity: used, minerals: refineVeldspar(used) }); + emit({ type: "xp.awarded", skill: "Industry", amount: 35 }); + }, [emit, session]); + + const updateFitting = useCallback((modules: SliceFittedModule[]) => { + emit({ type: "fitting.changed", modules }); + }, [emit]); + + const sellStack = useCallback((item: string, quantity: number, unitPrice: number) => { + if (!session?.dockedStationPoiId || quantity <= 0) return; + emit({ type: "market.sold", item, quantity, isk: quantity * unitPrice }); + emit({ type: "xp.awarded", skill: "Trade", amount: 30 }); + emit({ type: "zora.observed", trigger: "market.sold" }); + }, [emit, session?.dockedStationPoiId]); + + return { + session, + emit, + reset, + missingRequestedSession, + facts, + availableCommands, + primaryCommand, + operationProgress, + blockedReason, + startUndock, + travelToBelt, + travelToStation, + dock, + mine, + openService, + closeService, + startCombat, + refineVeldsparStack, + updateFitting, + sellStack, + }; +} diff --git a/src/prototypes/r3f/combat/CombatScene.tsx b/src/prototypes/r3f/combat/CombatScene.tsx new file mode 100644 index 0000000..d8d59df --- /dev/null +++ b/src/prototypes/r3f/combat/CombatScene.tsx @@ -0,0 +1,103 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import type { CombatState, CombatAction } from "./combatState"; +import { orbitPosition } from "./combatMath"; +import { SpaceCanvas } from "../shared/SpaceCanvas"; +import { SpaceEnvironment } from "../shared/SpaceEnvironment"; +import { ShipMesh } from "../shared/ShipMesh"; +import { Projectiles } from "../shared/Projectiles"; +import { RouteLine } from "../shared/RouteLine"; +import type { Vec3 } from "../shared/types"; + +type CombatSceneProps = { + state: CombatState; + dispatch: React.Dispatch; +}; + +function LockBrackets({ visible }: { visible: boolean }) { + if (!visible) return null; + return ( + + {[0, Math.PI / 2, Math.PI, Math.PI * 1.5].map((rot) => ( + + + + + ))} + + ); +} + +function CombatWorld({ state, dispatch }: CombatSceneProps) { + const playerRef = useRef(null); + const enemyRef = useRef(null); + const tickRef = useRef(0); + const playerAngle = useRef(Math.PI); + const enemyAngle = useRef(0); + + useFrame((_, dt) => { + const elapsed = _.clock.elapsedTime; + const afterburn = elapsed < state.afterburnUntil; + const locked = state.lockState === "locked"; + const locking = state.lockState === "locking"; + + if (locked || locking) { + playerAngle.current += dt * (0.45 + state.power.engines * 0.12) * (afterburn ? 1.9 : 1); + enemyAngle.current += dt * (0.42 * Math.max(0.15, state.enemy.engines / 100)); + } + + const playerRadius = locked ? 15 + state.power.engines * 2.2 : 14; + const enemyRadius = locked || locking ? 15 * Math.max(0.2, state.enemy.engines / 100) : 0; + const playerPos = locked + ? orbitPosition(playerAngle.current, playerRadius, 4, 0.65, -2) + : ([-16, Math.sin(elapsed * 1.3) * 0.6, 2] as Vec3); + const enemyPos = locked || locking + ? orbitPosition(enemyAngle.current, enemyRadius, 3, 0.7, 10) + : ([18, Math.cos(elapsed * 1.1) * 0.5, -2] as Vec3); + + playerRef.current?.position.set(...playerPos); + enemyRef.current?.position.set(...enemyPos); + if (enemyRef.current && playerRef.current) { + playerRef.current.lookAt(enemyRef.current.position); + enemyRef.current.lookAt(playerRef.current.position); + } + + tickRef.current += dt; + while (tickRef.current >= 0.05) { + tickRef.current -= 0.05; + dispatch({ type: "tick", payload: { dt: 0.05, playerPosition: playerPos, enemyPosition: enemyPos, elapsed } }); + } + }); + + const playerPosition = (playerRef.current?.position.toArray() ?? [-16, 0, 2]) as Vec3; + const enemyPosition = (enemyRef.current?.position.toArray() ?? [18, 0, -2]) as Vec3; + + return ( + <> + + + + + + + + + + + 8} /> + + + {state.lockState === "locked" && } + + + ); +} + +export function CombatScene(props: CombatSceneProps) { + return ( + + + + ); +} diff --git a/src/prototypes/r3f/combat/combatMath.ts b/src/prototypes/r3f/combat/combatMath.ts new file mode 100644 index 0000000..d5404b9 --- /dev/null +++ b/src/prototypes/r3f/combat/combatMath.ts @@ -0,0 +1,16 @@ +import type { Vec3 } from "../shared/types"; + +export function orbitPosition(angle: number, radius: number, yScale: number, zScale: number, centerX = 0): Vec3 { + return [ + centerX + Math.cos(angle) * radius, + Math.sin(angle * 1.37) * yScale, + Math.sin(angle) * radius * zScale, + ]; +} + +export function vecDistance(a: Vec3, b: Vec3) { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} diff --git a/src/prototypes/r3f/combat/combatState.ts b/src/prototypes/r3f/combat/combatState.ts new file mode 100644 index 0000000..edaf4cf --- /dev/null +++ b/src/prototypes/r3f/combat/combatState.ts @@ -0,0 +1,227 @@ +import type { CombatLockState, Vec3 } from "../shared/types"; +import type { ImpactView, ProjectileView } from "../shared/Projectiles"; + +export type CombatModule = { + id: string; + key: string; + name: string; + type: string; + cooldown: number; + maxCooldown: number; + cost: number; + damage: number; + color: string; +}; + +export type CombatState = { + player: { shields: number; armor: number; hull: number; energy: number; maxEnergy: number; speed: number }; + enemy: { shields: number; armor: number; hull: number; weapons: number; engines: number }; + power: { weapons: number; shields: number; engines: number; aux: number }; + lockState: CombatLockState; + lockProgress: number; + subsystem: "shields" | "hull" | "weapons" | "engines"; + modules: CombatModule[]; + projectiles: Array; + impacts: ImpactView[]; + log: Array<{ time: string; msg: string; color: string }>; + overloadedUntil: number; + afterburnUntil: number; + playerFireClock: number; + enemyFireClock: number; +}; + +type TickPayload = { + dt: number; + playerPosition: Vec3; + enemyPosition: Vec3; + elapsed: number; +}; + +export type CombatAction = + | { type: "startLock" } + | { type: "adjustPower"; system: keyof CombatState["power"]; delta: number } + | { type: "setSubsystem"; subsystem: CombatState["subsystem"] } + | { type: "castModule"; id: string; playerPosition: Vec3; enemyPosition: Vec3; elapsed: number } + | { type: "tick"; payload: TickPayload }; + +const nowStamp = () => new Date().toLocaleTimeString("en", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + +function addLog(state: CombatState, msg: string, color = "#94a3b8"): CombatState { + return { ...state, log: [...state.log.slice(-50), { time: nowStamp(), msg, color }] }; +} + +const clamp100 = (value: number) => Math.max(0, Math.min(100, value)); +const uid = () => Math.random().toString(36).slice(2); + +export const initialCombatState: CombatState = { + player: { shields: 100, armor: 100, hull: 100, energy: 100, maxEnergy: 100, speed: 0 }, + enemy: { shields: 100, armor: 100, hull: 100, weapons: 100, engines: 100 }, + power: { weapons: 3, shields: 2, engines: 2, aux: 1 }, + lockState: "idle", + lockProgress: 0, + subsystem: "hull", + modules: [ + { id: "q", key: "1", name: "Railgun", type: "weapon", cooldown: 0, maxCooldown: 3, cost: 12, damage: 18, color: "#ef4444" }, + { id: "w", key: "2", name: "Shield Bst", type: "shield", cooldown: 0, maxCooldown: 8, cost: 20, damage: 0, color: "#22d3ee" }, + { id: "e", key: "3", name: "EM Pulse", type: "ewar", cooldown: 0, maxCooldown: 12, cost: 30, damage: 0, color: "#a78bfa" }, + { id: "r", key: "4", name: "Overload", type: "reactor", cooldown: 0, maxCooldown: 30, cost: 45, damage: 0, color: "#f0a030" }, + { id: "d", key: "5", name: "Afterburn", type: "engine", cooldown: 0, maxCooldown: 6, cost: 10, damage: 0, color: "#22c55e" }, + { id: "f", key: "6", name: "Hull Patch", type: "repair", cooldown: 0, maxCooldown: 15, cost: 25, damage: 0, color: "#fb923c" }, + ], + projectiles: [], + impacts: [], + log: [{ time: nowStamp(), msg: "Combat systems online.", color: "#22d3ee" }], + overloadedUntil: 0, + afterburnUntil: 0, + playerFireClock: 0, + enemyFireClock: 0, +}; + +function damageEnemy(state: CombatState, damage: number, subsystem: CombatState["subsystem"]) { + const enemy = { ...state.enemy }; + if (subsystem === "shields") enemy.shields = clamp100(enemy.shields - damage); + if (subsystem === "weapons") enemy.weapons = clamp100(enemy.weapons - damage); + if (subsystem === "engines") enemy.engines = clamp100(enemy.engines - damage); + if (subsystem === "hull") { + if (enemy.shields > 0) enemy.shields = clamp100(enemy.shields - damage * 0.5); + else if (enemy.armor > 0) enemy.armor = clamp100(enemy.armor - damage * 0.55); + else enemy.hull = clamp100(enemy.hull - damage); + } + return enemy; +} + +export function combatReducer(state: CombatState, action: CombatAction): CombatState { + if (action.type === "startLock") { + return addLog({ ...state, lockState: "locking", lockProgress: 0 }, "Initiating target lock...", "#f0a030"); + } + + if (action.type === "adjustPower") { + const next = Math.max(0, state.power[action.system] + action.delta); + const total = Object.entries(state.power).reduce((sum, [key, value]) => sum + (key === action.system ? next : value), 0); + if (total > 8) return state; + return { ...state, power: { ...state.power, [action.system]: next } }; + } + + if (action.type === "setSubsystem") return { ...state, subsystem: action.subsystem }; + + if (action.type === "castModule") { + const module = state.modules.find((item) => item.id === action.id); + if (!module) return state; + if (state.lockState !== "locked") return addLog(state, "Target not locked yet.", "#ef4444"); + if (module.cooldown > 0) return addLog(state, `${module.name} recharging.`, "#ef4444"); + if (state.player.energy < module.cost) return addLog(state, "Insufficient energy.", "#ef4444"); + + const auxReduction = Math.min(0.6, state.power.aux * 0.06); + let next: CombatState = { + ...state, + player: { ...state.player, energy: state.player.energy - module.cost }, + modules: state.modules.map((item) => item.id === module.id ? { ...item, cooldown: Math.max(0.5, module.maxCooldown * (1 - auxReduction)) } : item), + }; + + if (module.id === "q") { + next = { + ...next, + projectiles: [...next.projectiles, { + id: uid(), from: action.playerPosition, to: action.enemyPosition, progress: 0, color: module.color, + owner: "player", damage: module.damage * (1 + state.power.weapons * 0.2), subsystem: state.subsystem, + }], + }; + return addLog(next, `Railgun -> ${state.subsystem.toUpperCase()}`, module.color); + } + if (module.id === "w") { + return addLog({ ...next, player: { ...next.player, shields: clamp100(next.player.shields + 18 + state.power.shields * 5) } }, "Shield boost cycled.", module.color); + } + if (module.id === "e") { + return addLog({ + ...next, + enemy: { ...next.enemy, weapons: clamp100(next.enemy.weapons - 18 - state.power.aux * 3), engines: clamp100(next.enemy.engines - 14 - state.power.aux * 3) }, + impacts: [...next.impacts, { id: uid(), position: action.enemyPosition, color: module.color, age: 0 }], + }, "EM pulse disrupted enemy weapons and engines.", module.color); + } + if (module.id === "r") return addLog({ ...next, overloadedUntil: action.elapsed + 8 }, "OVERLOAD: fire rate increased.", module.color); + if (module.id === "d") return addLog({ ...next, afterburnUntil: action.elapsed + 3 }, "Afterburners engaged.", module.color); + if (module.id === "f") return addLog({ ...next, player: { ...next.player, hull: clamp100(next.player.hull + 14 + state.power.aux * 4) } }, "Hull patch applied.", module.color); + return next; + } + + if (action.type === "tick") { + const { dt, playerPosition, enemyPosition, elapsed } = action.payload; + const overloaded = elapsed < state.overloadedUntil; + const locked = state.lockState === "locked"; + let next: CombatState = { + ...state, + player: { + ...state.player, + energy: clamp100(state.player.energy + dt * (2 + state.power.aux * 2) - (overloaded ? dt * 3 : 0)), + shields: clamp100(state.player.shields + dt * state.power.shields * 1.2), + }, + modules: state.modules.map((module) => ({ ...module, cooldown: Math.max(0, module.cooldown - dt) })), + projectiles: state.projectiles.map((projectile) => ({ ...projectile, progress: projectile.progress + dt * 1.8 })), + impacts: state.impacts.map((impact) => ({ ...impact, age: impact.age + dt * 8 })).filter((impact) => impact.age < 7), + }; + + if (state.lockState === "locking") { + const progress = Math.min(1, state.lockProgress + dt / 3); + next = { ...next, lockProgress: progress, lockState: progress >= 1 ? "locked" : "locking" }; + if (progress >= 1 && state.lockProgress < 1) next = addLog(next, "TARGET LOCKED", "#22c55e"); + } + + if (locked && state.enemy.hull > 0) { + const playerInterval = Math.max(0.22, (overloaded ? 0.55 : 1.1) - state.power.weapons * 0.08); + const enemyInterval = Math.max(0.45, 1.35 + (100 - state.enemy.weapons) * 0.02); + next.playerFireClock += dt; + next.enemyFireClock += dt; + if (next.playerFireClock >= playerInterval) { + next.playerFireClock = 0; + next.projectiles.push({ + id: uid(), + from: playerPosition, + to: enemyPosition, + progress: 0, + color: overloaded ? "#fbbf24" : "#f0a030", + owner: "player", + damage: 4 + state.power.weapons * 1.8, + subsystem: state.subsystem, + }); + } + if (next.enemyFireClock >= enemyInterval && state.enemy.weapons > 10) { + next.enemyFireClock = 0; + next.projectiles.push({ + id: uid(), + from: enemyPosition, + to: playerPosition, + progress: 0, + color: "#ef4444", + owner: "enemy", + damage: 4 * (state.enemy.weapons / 100), + subsystem: "hull", + }); + } + } + + const activeProjectiles = []; + for (const projectile of next.projectiles) { + if (projectile.progress < 1) { + activeProjectiles.push(projectile); + continue; + } + next.impacts.push({ id: uid(), position: projectile.to, color: projectile.color, age: 0 }); + if (projectile.owner === "player") { + next.enemy = damageEnemy(next, projectile.damage, projectile.subsystem as CombatState["subsystem"]); + } else { + const absorbed = Math.min(next.player.shields, projectile.damage * (0.5 + state.power.shields * 0.08)); + const bleed = projectile.damage - absorbed; + next.player = { + ...next.player, + shields: clamp100(next.player.shields - absorbed), + armor: clamp100(next.player.armor - Math.max(0, bleed * 0.6)), + hull: clamp100(next.player.hull - Math.max(0, bleed * 0.2)), + }; + } + } + next.projectiles = activeProjectiles; + return next; + } + + return state; +} diff --git a/src/prototypes/r3f/galaxy/GalaxyScene.tsx b/src/prototypes/r3f/galaxy/GalaxyScene.tsx new file mode 100644 index 0000000..2650710 --- /dev/null +++ b/src/prototypes/r3f/galaxy/GalaxyScene.tsx @@ -0,0 +1,272 @@ +import { useMemo } from "react"; +import { Line } from "@react-three/drei"; +import { SpaceCanvas } from "../shared/SpaceCanvas"; +import { SpaceEnvironment } from "../shared/SpaceEnvironment"; +import { CameraRig } from "../shared/CameraRig"; +import { RouteLine } from "../shared/RouteLine"; +import { StarSystemContents } from "../shared/StarSystemContents"; +import { systemToGalaxyWorld } from "../shared/galaxyMap"; +import type { GalaxyConnection, GalaxySystem, SystemPlanet, SystemPointOfInterest, Vec3 } from "../shared/types"; + +export type GeneratedGalaxySystem = GalaxySystem; +export type GeneratedGalaxyConnection = GalaxyConnection; + +function createRng(seed: number) { + let state = seed >>> 0 || 1; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +const planetTypes = ["Rocky", "Terrestrial", "Ice", "Oceanic", "Barren", "Gas Giant", "Lava"] as const; +const poiTypes = [ + { type: "station", label: "Trade Station", description: "Docking, repair, market, and agent services." }, + { type: "asteroid_belt", label: "Asteroid Belt", description: "Mineable belt seeded with local ore classes." }, + { type: "anomaly", label: "Sensor Anomaly", description: "Scan signature with time-limited exploration rewards." }, + { type: "site", label: "Relic Site", description: "Salvageable ruins and hacking containers." }, +] as const; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +function generateSystemContents(systemId: string, systemName: string, security: number, rand: () => number) { + const planetCount = 1 + Math.floor(rand() * 8); + const planets: SystemPlanet[] = Array.from({ length: planetCount }, (_, index) => { + const type = planetTypes[Math.floor(rand() * planetTypes.length)]; + return { + id: `${systemId}-planet-${index}`, + name: `${systemName} ${index + 1}`, + type, + orbit: Math.round((5 + index * (4 + rand() * 5)) * 10) / 10, + moons: type === "Gas Giant" ? 1 + Math.floor(rand() * 8) : Math.floor(rand() * 3), + }; + }); + + const poiCount = 1 + Math.floor(rand() * 4) + (security < 0.25 ? 1 : 0); + const pointsOfInterest: SystemPointOfInterest[] = Array.from({ length: poiCount }, (_, index) => { + const template = index === 0 && security > 0.45 ? poiTypes[0] : poiTypes[Math.floor(rand() * poiTypes.length)]; + return { + id: `${systemId}-poi-${index}`, + name: `${systemName} ${template.label}`, + type: template.type, + description: template.description, + }; + }); + + return { planets, pointsOfInterest }; +} + +export function generateGalaxy(seed: number, params: { count: number; arms: number; verticalArms: number; size: number; twist: number; verticalTwist: number }): GeneratedGalaxySystem[] { + const rand = createRng(seed); + const systems: GeneratedGalaxySystem[] = []; + const baseSpacing = clamp((params.size / Math.sqrt(params.count)) * 1.25, 9, 24); + const horizontalArms = Math.max(1, params.arms); + const verticalArms = Math.max(0, params.verticalArms); + const totalArmSlots = horizontalArms + verticalArms; + const factions = [ + ["Concord", "#22d3ee"], + ["Amarr", "#f59e0b"], + ["Minmatar", "#ef4444"], + ["Gallente", "#a855f7"], + ["Caldari", "#38bdf8"], + ] as const; + for (let index = 0; index < params.count; index += 1) { + const core = index < 7; + const armSlot = index % totalArmSlots; + const vertical = !core && armSlot >= horizontalArms; + const arm = vertical ? armSlot - horizontalArms : armSlot; + const [faction, color] = core ? factions[0] : factions[(armSlot % (factions.length - 1)) + 1]; + let r = 0; + let angle = 0; + let x = 0; + let y = 0; + let z = 0; + + for (let attempt = 0; attempt < 180; attempt += 1) { + r = core ? 8 + rand() * 30 : Math.pow(rand(), 0.62) * params.size; + const armCount = vertical ? verticalArms : horizontalArms; + const armTwist = vertical ? params.verticalTwist : params.twist; + angle = core ? rand() * Math.PI * 2 : (Math.PI * 2 * arm) / armCount + (r / params.size) * armTwist + (rand() - 0.5) * 0.72; + if (vertical) { + const planeAngle = (Math.PI * 2 * arm) / Math.max(1, verticalArms); + const armSweep = Math.cos(angle) * r; + x = Math.cos(planeAngle) * armSweep; + z = Math.sin(planeAngle) * armSweep; + y = Math.sin(angle) * r * 0.72 + (rand() - 0.5) * 12; + } else { + x = Math.cos(angle) * r; + z = Math.sin(angle) * r; + y = (rand() - 0.5) * 20; + } + const relaxedSpacing = baseSpacing * (attempt > 120 ? 0.68 : attempt > 60 ? 0.82 : 1); + const localSpacing = core ? relaxedSpacing * 0.9 : relaxedSpacing; + const clear = systems.every((system) => Math.hypot(system.x - x, system.y - y, system.z - z) >= localSpacing); + if (clear) break; + } + + const security = Math.round((1 - (r / params.size) * 2) * 100) / 100; + const name = core ? `COR-${100 + index}` : `${faction.slice(0, 3).toUpperCase()}-${100 + index}`; + const contents = generateSystemContents(`g-${index}`, name, security, rand); + systems.push({ + id: `g-${index}`, + name, + x, + z, + y, + mapProjection: "world3d", + security, + faction, + type: faction, + color, + planetCount: contents.planets.length, + planets: contents.planets, + stations: contents.pointsOfInterest.filter((poi) => poi.type === "station").map((poi) => poi.name), + pointsOfInterest: contents.pointsOfInterest, + }); + } + + return systems; +} + +export function buildGalaxyConnections(systems: GeneratedGalaxySystem[]): GeneratedGalaxyConnection[] { + return systems.flatMap((system) => { + const [sx, sy, sz] = systemToGalaxyWorld(system); + const nearest = systems + .filter((candidate) => candidate.id !== system.id) + .map((candidate) => { + const [cx, cy, cz] = systemToGalaxyWorld(candidate); + return { id: candidate.id, d: Math.hypot(sx - cx, sy - cy, sz - cz) }; + }) + .sort((a, b) => a.d - b.d) + .slice(0, 2); + return nearest.map((candidate) => [system.id, candidate.id] as GeneratedGalaxyConnection); + }).filter(([a, b], index, all) => all.findIndex(([x, y]) => (x === a && y === b) || (x === b && y === a)) === index); +} + +export function findGalaxyRoute(originId: string, destinationId: string, connections: GeneratedGalaxyConnection[]) { + if (originId === destinationId) return [originId]; + const graph = new Map(); + connections.forEach(([a, b]) => { + graph.set(a, [...(graph.get(a) ?? []), b]); + graph.set(b, [...(graph.get(b) ?? []), a]); + }); + const queue = [originId]; + const prev = new Map([[originId, null]]); + for (let i = 0; i < queue.length; i += 1) { + const id = queue[i]; + for (const next of graph.get(id) ?? []) { + if (prev.has(next)) continue; + prev.set(next, id); + queue.push(next); + } + } + if (!prev.has(destinationId)) return []; + const route: string[] = []; + let cursor: string | null = destinationId; + while (cursor) { + route.unshift(cursor); + cursor = prev.get(cursor) ?? null; + } + return route; +} + +type GalaxySceneProps = { + systems: GeneratedGalaxySystem[]; + connections: GeneratedGalaxyConnection[]; + selectedId: string | null; + selectedPoiId?: string | null; + routeIds: string[]; + onSelect: (system: GeneratedGalaxySystem) => void; + onPoiSelect: (system: GeneratedGalaxySystem, poi: SystemPointOfInterest) => void; + onRoutePick: (system: GeneratedGalaxySystem) => void; +}; + +export function GalaxyScene({ systems, connections, selectedId, selectedPoiId, routeIds, onSelect, onPoiSelect, onRoutePick }: GalaxySceneProps) { + const byId = useMemo(() => new Map(systems.map((system) => [system.id, system])), [systems]); + const routePoints = routeIds.map((id) => byId.get(id)).filter(Boolean).map((system) => systemToGalaxyWorld(system!) as Vec3); + const nearestSystem = (point: { x: number; z: number }) => { + return systems.reduce((best, system) => { + const [x, , z] = systemToGalaxyWorld(system); + const distance = Math.hypot(x - point.x, z - point.z); + if (!best || distance < best.distance) return { system, distance }; + return best; + }, null as null | { system: GeneratedGalaxySystem; distance: number })?.system; + }; + + return ( + + + + { + event.stopPropagation(); + const system = nearestSystem({ x: event.point.x, z: event.point.z }); + if (system) onRoutePick(system); + }} + > + + + + + + { + const [x, y, z] = systemToGalaxyWorld(system); + return [x, y - 2, z]; + })), 3]} + /> + + + + {connections.map(([a, b]) => { + const from = byId.get(a); + const to = byId.get(b); + if (!from || !to) return null; + const onRoute = routeIds.includes(a) && routeIds.includes(b); + return ; + })} + + {systems.map((system) => { + const selected = system.id === selectedId; + const color = routeIds.includes(system.id) ? "#22d3ee" : selected ? "#f0a030" : system.color; + + return ( + + { + event.stopPropagation(); + onPoiSelect(system, poi); + }} + /> + { + event.stopPropagation(); + onSelect(system); + }} + onContextMenu={(event) => { + event.stopPropagation(); + onRoutePick(system); + }} + > + + + + + ); + })} + + ); +} diff --git a/src/prototypes/r3f/hud/HudSpaceScene.tsx b/src/prototypes/r3f/hud/HudSpaceScene.tsx new file mode 100644 index 0000000..da128a2 --- /dev/null +++ b/src/prototypes/r3f/hud/HudSpaceScene.tsx @@ -0,0 +1,65 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { SpaceCanvas } from "../shared/SpaceCanvas"; +import { SpaceEnvironment } from "../shared/SpaceEnvironment"; +import { ShipMesh } from "../shared/ShipMesh"; +import { AsteroidMesh } from "../shared/AsteroidMesh"; +import { StationMesh } from "../shared/StationMesh"; +import { RouteLine } from "../shared/RouteLine"; + +function HudSpaceWorld({ targetLocked = true, afterburner = false }: { targetLocked?: boolean; afterburner?: boolean }) { + const playerRef = useRef(null); + const enemyRef = useRef(null); + useFrame((state) => { + const t = state.clock.elapsedTime; + if (playerRef.current) { + playerRef.current.position.y = -1 + Math.sin(t * 1.5) * 0.18; + playerRef.current.rotation.z = Math.sin(t * 0.4) * 0.03; + } + if (enemyRef.current) { + enemyRef.current.position.x = 5 + Math.sin(t * 1.1) * 1.2; + enemyRef.current.position.y = 1 + Math.cos(t * 0.8) * 0.5; + enemyRef.current.rotation.z = Math.sin(t * 0.6) * 0.04; + } + }); + + return ( + <> + + + + + + + + + + + {targetLocked && ( + + + + + )} + + {targetLocked && } + {[-25, -20, -30, 30, 35, -15, 20].map((x, index) => ( + + + + ))} + + + + + ); +} + +export function HudSpaceScene(props: { targetLocked?: boolean; afterburner?: boolean }) { + return ( + + + + ); +} diff --git a/src/prototypes/r3f/movement/MovementScene.tsx b/src/prototypes/r3f/movement/MovementScene.tsx new file mode 100644 index 0000000..c62749d --- /dev/null +++ b/src/prototypes/r3f/movement/MovementScene.tsx @@ -0,0 +1,116 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { SpaceCanvas } from "../shared/SpaceCanvas"; +import { SpaceEnvironment } from "../shared/SpaceEnvironment"; +import { CameraRig } from "../shared/CameraRig"; +import { ShipMesh } from "../shared/ShipMesh"; +import { StationMesh } from "../shared/StationMesh"; +import { AsteroidMesh } from "../shared/AsteroidMesh"; +import { RouteLine } from "../shared/RouteLine"; +import { getSystemPoiPosition } from "../shared/poiOrbit"; +import { StarSystemContents } from "../shared/StarSystemContents"; +import type { GalaxySystem, SystemPointOfInterest, Vec3 } from "../shared/types"; +import type { LocalEntity, LocalWaypoint } from "./movementState"; + +type MovementSceneProps = { + currentSystemId: string; + currentSystem: GalaxySystem | null; + localEntities: LocalEntity[]; + localWaypoints: LocalWaypoint[]; + shipPosition: Vec3; + onFrame: (dt: number, elapsedTime: number) => void; + onWaypointPick: (waypoint: LocalWaypoint) => void; + onPoiPick: (poi: SystemPointOfInterest, position: Vec3) => void; +}; + +const entityPosition = (entity: LocalEntity): Vec3 => [(entity.x - 400) * 0.15, 0, (entity.y - 300) * 0.15]; +export const MOVEMENT_SYSTEM_SCALE = 3.2; + +function MovementWorld({ currentSystemId, currentSystem, localEntities, localWaypoints, shipPosition, onFrame, onWaypointPick, onPoiPick }: MovementSceneProps) { + const shipRef = useRef(null); + useFrame((state, dt) => { + onFrame(dt, state.clock.elapsedTime); + if (shipRef.current) { + shipRef.current.position.lerp(new THREE.Vector3(...shipPosition), Math.min(1, dt * 8)); + shipRef.current.position.y += Math.sin(state.clock.elapsedTime * 1.6) * 0.005; + } + }); + + return ( + <> + + + + { + event.stopPropagation(); + onWaypointPick({ + id: `click-${Date.now()}`, + name: "Manual nav point", + type: "manual", + position: [event.point.x, 0, event.point.z], + }); + }} + > + + + + {currentSystem && ( + { + event.stopPropagation(); + const position = getSystemPoiPosition({ + systemId: currentSystem.id, + planets: currentSystem.planets, + pointsOfInterest: currentSystem.pointsOfInterest, + poiId: poi.id, + scale: MOVEMENT_SYSTEM_SCALE, + expanded: true, + }); + onPoiPick(poi, position); + }} + /> + )} + + !waypoint.arrived)} /> + + {localEntities.map((entity) => ( + + {entity.type === "asteroid" ? : entity.type === "station" ? : } + + ))} + {localWaypoints.length > 0 && waypoint.position)]} color="#f0a030" width={2} />} + {localWaypoints.map((waypoint) => ( + + + + + ))} + {currentSystem && ( + + + + + )} + + ); +} + +export function MovementScene(props: MovementSceneProps) { + return ( + + + + ); +} diff --git a/src/prototypes/r3f/movement/movementState.ts b/src/prototypes/r3f/movement/movementState.ts new file mode 100644 index 0000000..dc587c7 --- /dev/null +++ b/src/prototypes/r3f/movement/movementState.ts @@ -0,0 +1,43 @@ +import type { GalaxyConnection, GalaxySystem, Vec3 } from "../shared/types"; + +export type LocalEntity = { + id: string; + name: string; + type: string; + x: number; + y: number; + distance?: number; +}; + +export type LocalWaypoint = { + id: string; + name: string; + type: "manual" | "entity" | "poi"; + position: Vec3; + systemId?: string; + poiId?: string; + followOrbit?: boolean; + arrived?: boolean; +}; + +export type MovementState = { + systems: GalaxySystem[]; + connections: GalaxyConnection[]; + currentSystemId: string; + shipPosition: Vec3; + shipSpeed: number; + localEntities: LocalEntity[]; + localWaypoints: LocalWaypoint[]; + arrivalMessage: string | null; +}; + +export const createMovementState = (systems: GalaxySystem[], connections: GalaxyConnection[], localEntities: LocalEntity[]): MovementState => ({ + systems, + connections, + currentSystemId: "sol", + shipPosition: [0, 0, 0], + shipSpeed: 0, + localEntities, + localWaypoints: [], + arrivalMessage: null, +}); diff --git a/src/prototypes/r3f/navigation/travelSession.ts b/src/prototypes/r3f/navigation/travelSession.ts new file mode 100644 index 0000000..47b21d5 --- /dev/null +++ b/src/prototypes/r3f/navigation/travelSession.ts @@ -0,0 +1,225 @@ +import type { NavigationRoute, PoiNavigationTarget } from "../shared/types"; + +export type TravelMode = + | "local_impulse" + | "star_map_planning" + | "jumping" + | "warp_bubble" + | "star_map_tracking" + | "arrived"; + +export type InterstellarTravelStatus = "idle" | "planned" | "in_transit" | "arrived"; + +export type TravelSession = { + id: string; + currentSystemId: string; + destinationSystemId: string | null; + waypointSystemIds: string[]; + route: NavigationRoute | null; + status: InterstellarTravelStatus; + activeSegmentIndex: number; + segmentProgress: number; + totalProgress: number; + mode: TravelMode; + arrivalMessage: string | null; + destinationPoi: PoiNavigationTarget | null; + updatedAt: number; +}; + +type CreateTravelSessionArgs = { + id?: string; + currentSystemId?: string; + destinationSystemId?: string | null; + waypointSystemIds?: string[]; + route?: NavigationRoute | null; + status?: InterstellarTravelStatus; + mode?: TravelMode; + destinationPoi?: PoiNavigationTarget | null; +}; + +const STORAGE_PREFIX = "void-nav.travelSession."; +const SEGMENT_TRAVEL_RATE = 0.34; + +const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); + +const storageKey = (id: string) => `${STORAGE_PREFIX}${id}`; + +const safeLocalStorage = () => { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +}; + +const withUpdatedAt = (session: TravelSession): TravelSession => ({ ...session, updatedAt: Date.now() }); + +export function createTravelSessionId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `nav-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function createTravelSession({ + id = createTravelSessionId(), + currentSystemId = "sol", + destinationSystemId = null, + waypointSystemIds = [], + route = null, + status = route ? "planned" : "idle", + mode = "local_impulse", + destinationPoi = null, +}: CreateTravelSessionArgs = {}): TravelSession { + return { + id, + currentSystemId, + destinationSystemId, + waypointSystemIds, + route, + status, + activeSegmentIndex: 0, + segmentProgress: 0, + totalProgress: 0, + mode, + arrivalMessage: null, + destinationPoi, + updatedAt: Date.now(), + }; +} + +export function loadTravelSession(id: string): TravelSession | null { + const storage = safeLocalStorage(); + if (!storage) return null; + try { + const raw = storage.getItem(storageKey(id)); + if (!raw) return null; + const parsed = JSON.parse(raw) as TravelSession; + if (!parsed || parsed.id !== id || !parsed.currentSystemId) return null; + return { + ...parsed, + destinationSystemId: parsed.destinationSystemId ?? null, + waypointSystemIds: parsed.waypointSystemIds ?? [], + route: parsed.route ?? null, + activeSegmentIndex: parsed.activeSegmentIndex ?? 0, + segmentProgress: clamp01(parsed.segmentProgress ?? 0), + totalProgress: clamp01(parsed.totalProgress ?? 0), + arrivalMessage: parsed.arrivalMessage ?? null, + destinationPoi: parsed.destinationPoi ?? null, + updatedAt: parsed.updatedAt ?? Date.now(), + }; + } catch { + return null; + } +} + +export function saveTravelSession(session: TravelSession) { + const storage = safeLocalStorage(); + if (!storage) return; + storage.setItem(storageKey(session.id), JSON.stringify(withUpdatedAt(session))); +} + +export function clearTravelSession(id: string) { + const storage = safeLocalStorage(); + storage?.removeItem(storageKey(id)); +} + +export function getTravelSessionIdFromUrl() { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("navSession"); +} + +export function withTravelSession(url: string, id: string) { + const base = typeof window === "undefined" ? "http://void-nav.local" : window.location.origin; + const next = new URL(url, base); + next.searchParams.set("navSession", id); + return `${next.pathname}${next.search}${next.hash}`; +} + +export function completeTravelSession(session: TravelSession): TravelSession { + const destination = session.route?.destination; + return withUpdatedAt({ + ...session, + currentSystemId: destination?.id ?? session.currentSystemId, + destinationSystemId: null, + waypointSystemIds: [], + status: "arrived", + mode: "arrived", + activeSegmentIndex: 0, + segmentProgress: 1, + totalProgress: 1, + arrivalMessage: destination ? `Arrived in ${destination.name}` : "Arrived", + }); +} + +export function advanceTravelSession(session: TravelSession, dt: number): TravelSession { + if (session.status !== "in_transit" || !session.route) return session; + if (session.route.segments.length === 0) return completeTravelSession(session); + + const segmentCount = session.route.segments.length; + const activeSegmentIndex = Math.min(session.activeSegmentIndex, segmentCount - 1); + let nextSegmentIndex = activeSegmentIndex; + let nextSegmentProgress = clamp01(session.segmentProgress + dt * SEGMENT_TRAVEL_RATE); + + if (nextSegmentProgress >= 1) { + if (activeSegmentIndex >= segmentCount - 1) return completeTravelSession(session); + nextSegmentIndex = activeSegmentIndex + 1; + nextSegmentProgress = 0; + } + + const totalProgress = clamp01((nextSegmentIndex + nextSegmentProgress) / segmentCount); + return withUpdatedAt({ + ...session, + activeSegmentIndex: nextSegmentIndex, + segmentProgress: nextSegmentProgress, + totalProgress, + currentSystemId: session.route.systems[nextSegmentIndex]?.id ?? session.currentSystemId, + }); +} + +export function planRouteForSession( + session: TravelSession, + route: NavigationRoute | null, + destinationSystemId: string | null, + waypointSystemIds: string[] = session.waypointSystemIds, +): TravelSession { + if (route && route.segments.length === 0) { + return completeTravelSession({ + ...session, + route, + destinationSystemId, + waypointSystemIds, + destinationPoi: session.destinationPoi, + }); + } + + return withUpdatedAt({ + ...session, + destinationSystemId, + waypointSystemIds, + route, + status: route ? "planned" : "idle", + activeSegmentIndex: 0, + segmentProgress: 0, + totalProgress: 0, + mode: "star_map_planning", + arrivalMessage: null, + }); +} + +export function startJump(session: TravelSession): TravelSession { + if (!session.route) return session; + if (session.route.segments.length === 0) return completeTravelSession(session); + return withUpdatedAt({ + ...session, + status: "in_transit", + mode: "jumping", + activeSegmentIndex: Math.min(session.activeSegmentIndex, session.route.segments.length - 1), + segmentProgress: clamp01(session.segmentProgress), + totalProgress: clamp01(session.totalProgress), + arrivalMessage: null, + }); +} + +export function setTravelMode(session: TravelSession, mode: TravelMode): TravelSession { + return withUpdatedAt({ ...session, mode }); +} diff --git a/src/prototypes/r3f/shared/AsteroidMesh.tsx b/src/prototypes/r3f/shared/AsteroidMesh.tsx new file mode 100644 index 0000000..07a1c25 --- /dev/null +++ b/src/prototypes/r3f/shared/AsteroidMesh.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import * as THREE from "three"; + +export function AsteroidMesh({ scale = 1, color = "#4b3a5f" }: { scale?: number; color?: string }) { + const geometry = useMemo(() => { + const geo = new THREE.IcosahedronGeometry(scale, 1); + const pos = geo.attributes.position; + for (let i = 0; i < pos.count; i += 1) { + const n = 0.75 + Math.random() * 0.55; + pos.setXYZ(i, pos.getX(i) * n, pos.getY(i) * n, pos.getZ(i) * n); + } + geo.computeVertexNormals(); + return geo; + }, [scale]); + + return ( + + + + ); +} diff --git a/src/prototypes/r3f/shared/CameraRig.tsx b/src/prototypes/r3f/shared/CameraRig.tsx new file mode 100644 index 0000000..5fddd60 --- /dev/null +++ b/src/prototypes/r3f/shared/CameraRig.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import { OrbitControls } from "@react-three/drei"; +import * as THREE from "three"; +import type { Vec3 } from "./types"; + +type CameraRigProps = { + target?: Vec3 | null; + orbit?: boolean; + distance?: number; +}; + +export function CameraRig({ target, orbit = false, distance = 90 }: CameraRigProps) { + const controlsRef = useRef(null); + const { camera } = useThree(); + const desired = useRef(new THREE.Vector3(0, 0, 0)); + + useEffect(() => { + if (target) desired.current.set(target[0], target[1], target[2]); + }, [target]); + + useFrame((_, dt) => { + if (!target) return; + const current = controlsRef.current?.target; + if (current) { + current.lerp(desired.current, Math.min(1, dt * 3)); + controlsRef.current.update(); + } else { + camera.lookAt(desired.current); + } + }); + + if (!orbit) return null; + return ; +} diff --git a/src/prototypes/r3f/shared/PoiApproachShip.tsx b/src/prototypes/r3f/shared/PoiApproachShip.tsx new file mode 100644 index 0000000..ea2d1a3 --- /dev/null +++ b/src/prototypes/r3f/shared/PoiApproachShip.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { getSystemPoiPositionAtTime } from "./poiOrbit"; +import { RouteLine } from "./RouteLine"; +import { ShipMesh } from "./ShipMesh"; +import type { GalaxySystem, PoiNavigationStatus, Vec3 } from "./types"; + +type PoiApproachShipProps = { + system: GalaxySystem; + poiId: string; + scale: number; + expanded: boolean; + holdRadius?: number; + speed?: number; + onStatusChange?: (status: PoiNavigationStatus) => void; +}; + +const toVec3 = (value: THREE.Vector3): Vec3 => [value.x, value.y, value.z]; + +export function PoiApproachShip({ + system, + poiId, + scale, + expanded, + holdRadius = 1.25 * scale, + speed = 9 * scale, + onStatusChange, +}: PoiApproachShipProps) { + const shipRef = useRef(null); + const shipPosition = useRef(new THREE.Vector3(0, 0.8 * scale, -2.8 * scale)); + const status = useRef("approaching"); + const onStatusChangeRef = useRef(onStatusChange); + const [linePoints, setLinePoints] = useState([toVec3(shipPosition.current), [0, 0, 0]]); + const [arrived, setArrived] = useState(false); + + useEffect(() => { + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); + + useEffect(() => { + shipPosition.current.set(0, 0.8 * scale, -2.8 * scale); + status.current = "approaching"; + setArrived(false); + setLinePoints([toVec3(shipPosition.current), [0, 0, 0]]); + onStatusChangeRef.current?.("approaching"); + }, [poiId, scale, system.id]); + + useFrame(({ clock }, dt) => { + const target = new THREE.Vector3(...getSystemPoiPositionAtTime({ + systemId: system.id, + planets: system.planets, + pointsOfInterest: system.pointsOfInterest, + poiId, + elapsedTime: clock.elapsedTime, + scale, + expanded, + })); + const desired = status.current === "arrived" + ? target.clone().add(new THREE.Vector3(holdRadius * 0.8, 0.12 * scale, holdRadius * 0.35)) + : target; + const delta = desired.clone().sub(shipPosition.current); + const distance = delta.length(); + + if (status.current === "approaching" && distance <= holdRadius) { + status.current = "arrived"; + setArrived(true); + onStatusChangeRef.current?.("arrived"); + } + + if (status.current === "arrived") { + shipPosition.current.lerp(desired, Math.min(1, dt * 7)); + } else if (distance > 0.001) { + const step = Math.min(distance, speed * dt); + shipPosition.current.add(delta.normalize().multiplyScalar(step)); + } + + if (shipRef.current) { + shipRef.current.position.copy(shipPosition.current); + const heading = desired.clone().sub(shipPosition.current); + if (heading.lengthSq() > 0.0001) { + shipRef.current.rotation.y = -Math.atan2(heading.z, heading.x); + } + } + + setLinePoints([toVec3(shipPosition.current), toVec3(target)]); + }); + + return ( + + {!arrived && } + + + + + + + + + ); +} diff --git a/src/prototypes/r3f/shared/Projectiles.tsx b/src/prototypes/r3f/shared/Projectiles.tsx new file mode 100644 index 0000000..468f896 --- /dev/null +++ b/src/prototypes/r3f/shared/Projectiles.tsx @@ -0,0 +1,47 @@ +import type { Vec3 } from "./types"; + +export type ProjectileView = { + id: string; + from: Vec3; + to: Vec3; + progress: number; + color: string; + size?: number; +}; + +export type ImpactView = { + id: string; + position: Vec3; + color: string; + age: number; +}; + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +export function Projectiles({ projectiles, impacts }: { projectiles: ProjectileView[]; impacts?: ImpactView[] }) { + return ( + <> + {projectiles.map((projectile) => { + const t = Math.min(1, Math.max(0, projectile.progress)); + const position: Vec3 = [ + lerp(projectile.from[0], projectile.to[0], t), + lerp(projectile.from[1], projectile.to[1], t), + lerp(projectile.from[2], projectile.to[2], t), + ]; + return ( + + + + + + ); + })} + {impacts?.map((impact) => ( + + + + + ))} + + ); +} diff --git a/src/prototypes/r3f/shared/RouteLine.tsx b/src/prototypes/r3f/shared/RouteLine.tsx new file mode 100644 index 0000000..348218d --- /dev/null +++ b/src/prototypes/r3f/shared/RouteLine.tsx @@ -0,0 +1,18 @@ +import { Line } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { useRef } from "react"; +import type { Vec3 } from "./types"; + +export function RouteLine({ points, color = "#22d3ee", width = 2 }: { points: Vec3[]; color?: string; width?: number }) { + const pulseRef = useRef(null); + useFrame((_, dt) => { + if (pulseRef.current) pulseRef.current.material.dashOffset -= dt * 0.8; + }); + if (points.length < 2) return null; + return ( + <> + + + + ); +} diff --git a/src/prototypes/r3f/shared/ShipMesh.tsx b/src/prototypes/r3f/shared/ShipMesh.tsx new file mode 100644 index 0000000..dbfaf32 --- /dev/null +++ b/src/prototypes/r3f/shared/ShipMesh.tsx @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import * as THREE from "three"; + +type ShipMeshProps = { + color?: string; + emissive?: string; + scale?: number; + hostile?: boolean; + engineActive?: boolean; +}; + +export function ShipMesh({ + color = "#c8d6e5", + emissive = "#f0a030", + scale = 1, + hostile = false, + engineActive = true, +}: ShipMeshProps) { + const geometry = useMemo(() => { + const s = scale; + const vertices = new Float32Array([ + 14 * s, 0, 0, -8 * s, 0, -6 * s, -8 * s, 0, 6 * s, + 14 * s, -2 * s, 0, -8 * s, -2 * s, 6 * s, -8 * s, -2 * s, -6 * s, + 14 * s, 0, 0, 14 * s, -2 * s, 0, -8 * s, -2 * s, 6 * s, + 14 * s, 0, 0, -8 * s, -2 * s, 6 * s, -8 * s, 0, 6 * s, + 14 * s, 0, 0, -8 * s, 0, -6 * s, 14 * s, -2 * s, 0, + 14 * s, -2 * s, 0, -8 * s, 0, -6 * s, -8 * s, -2 * s, -6 * s, + -8 * s, 0, -6 * s, -8 * s, 0, 6 * s, -8 * s, -2 * s, 6 * s, + -8 * s, 0, -6 * s, -8 * s, -2 * s, 6 * s, -8 * s, -2 * s, -6 * s, + -8 * s, 0, 6 * s, -8 * s, -2 * s, 6 * s, 14 * s, -2 * s, 0, + -8 * s, 0, 6 * s, 14 * s, -2 * s, 0, 14 * s, 0, 0, + 14 * s, 0, 0, 14 * s, -2 * s, 0, -8 * s, -2 * s, -6 * s, + 14 * s, 0, 0, -8 * s, -2 * s, -6 * s, -8 * s, 0, -6 * s, + ]); + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); + geo.computeVertexNormals(); + return geo; + }, [scale]); + + return ( + + + + + + + + + + + ); +} diff --git a/src/prototypes/r3f/shared/SpaceCanvas.tsx b/src/prototypes/r3f/shared/SpaceCanvas.tsx new file mode 100644 index 0000000..7140f7d --- /dev/null +++ b/src/prototypes/r3f/shared/SpaceCanvas.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; +import { Canvas } from "@react-three/fiber"; + +type SpaceCanvasProps = { + children: ReactNode; + camera?: { + position?: [number, number, number]; + fov?: number; + near?: number; + far?: number; + }; + className?: string; +}; + +export function SpaceCanvas({ children, camera, className }: SpaceCanvasProps) { + return ( + + {children} + + ); +} diff --git a/src/prototypes/r3f/shared/SpaceEnvironment.tsx b/src/prototypes/r3f/shared/SpaceEnvironment.tsx new file mode 100644 index 0000000..6635730 --- /dev/null +++ b/src/prototypes/r3f/shared/SpaceEnvironment.tsx @@ -0,0 +1,66 @@ +import { useMemo, useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; + +type SpaceEnvironmentProps = { + density?: number; + spread?: number; + fog?: boolean; + accents?: [string, string, string?]; +}; + +export function SpaceEnvironment({ + density = 1800, + spread = 900, + fog = true, + accents = ["#22d3ee", "#a78bfa", "#f0a030"], +}: SpaceEnvironmentProps) { + const pointsRef = useRef(null); + const positions = useMemo(() => { + const array = new Float32Array(density * 3); + for (let i = 0; i < density; i += 1) { + array[i * 3] = (Math.random() - 0.5) * spread; + array[i * 3 + 1] = (Math.random() - 0.5) * spread; + array[i * 3 + 2] = (Math.random() - 0.5) * spread; + } + return array; + }, [density, spread]); + + useFrame((_, dt) => { + if (pointsRef.current) { + pointsRef.current.rotation.y += dt * 0.01; + pointsRef.current.rotation.x += dt * 0.003; + } + }); + + return ( + <> + {fog && } + + + + + + + + + + + + + + + + + + + + {accents[2] && ( + + + + + )} + + ); +} diff --git a/src/prototypes/r3f/shared/StarSystemContents.tsx b/src/prototypes/r3f/shared/StarSystemContents.tsx new file mode 100644 index 0000000..4cbc814 --- /dev/null +++ b/src/prototypes/r3f/shared/StarSystemContents.tsx @@ -0,0 +1,210 @@ +import { useRef, type ReactNode } from "react"; +import { Billboard, Line, Text } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import type { ThreeEvent } from "@react-three/fiber"; +import type * as THREE from "three"; +import type { SystemPlanet, SystemPointOfInterest } from "./types"; +import { getOrbitalSpeed, getSystemPoiOrbitSpec, hashString } from "./poiOrbit"; +export { getSystemPoiPosition } from "./poiOrbit"; + +type StarSystemContentsProps = { + systemId: string; + systemName: string; + starColor: string; + planets: SystemPlanet[]; + pointsOfInterest: SystemPointOfInterest[]; + selectedPoiId?: string | null; + expanded?: boolean; + scale?: number; + onPoiSelect?: (poi: SystemPointOfInterest, event: ThreeEvent) => void; +}; + +const poiColors: Record = { + station: "#22d3ee", + asteroid_belt: "#94a3b8", + anomaly: "#f0a030", + stargate: "#a78bfa", + site: "#22c55e", +}; + +const planetColors: Record = { + Barren: "#8a7a6a", + Gas: "#d4a560", + "Gas Giant": "#d4a560", + Ice: "#93c5fd", + Lava: "#ef4444", + Oceanic: "#38bdf8", + Rocky: "#a8a29e", + Terrestrial: "#22c55e", +}; + +function circlePoints(radius: number, segments = 56) { + return Array.from({ length: segments + 1 }, (_, index) => { + const angle = (index / segments) * Math.PI * 2; + return [Math.cos(angle) * radius, 0, Math.sin(angle) * radius] as [number, number, number]; + }); +} + +function OrbitingGroup({ + radius, + initialAngle, + speed, + children, +}: { + radius: number; + initialAngle: number; + speed: number; + children: ReactNode; +}) { + const orbitRef = useRef(null); + + useFrame(({ clock }) => { + if (orbitRef.current) { + orbitRef.current.rotation.y = initialAngle + clock.elapsedTime * speed; + } + }); + + return ( + + {children} + + ); +} + +function PoiMarker({ + poi, + position, + size, + expanded, + selected, + onSelect, +}: { + poi: SystemPointOfInterest; + position: [number, number, number]; + size: number; + expanded?: boolean; + selected?: boolean; + onSelect?: (poi: SystemPointOfInterest, event: ThreeEvent) => void; +}) { + const color = poiColors[poi.type]; + const markerRef = useRef(null); + + useFrame((_, delta) => { + if (markerRef.current) { + markerRef.current.rotation.y += delta * (poi.type === "asteroid_belt" ? 0.55 : 0.22); + } + }); + + return ( + + {selected && ( + + + + + )} + { + event.stopPropagation(); + onSelect?.(poi, event); + }} + onPointerOver={(event) => { + event.stopPropagation(); + document.body.style.cursor = "pointer"; + }} + onPointerOut={() => { + document.body.style.cursor = ""; + }} + > + {poi.type === "station" ? : null} + {poi.type === "asteroid_belt" ? : null} + {poi.type === "anomaly" ? : null} + {poi.type === "stargate" ? : null} + {poi.type === "site" ? : null} + + + {(expanded || selected) && ( + + + {poi.name} + + + )} + + ); +} + +export function StarSystemContents({ + systemId, + systemName, + starColor, + planets, + pointsOfInterest, + selectedPoiId = null, + expanded = false, + scale = 1, + onPoiSelect, +}: StarSystemContentsProps) { + const displayPlanets = expanded ? planets : planets.slice(0, 5); + const displayPois = expanded ? pointsOfInterest : pointsOfInterest.slice(0, 4); + const maxOrbit = Math.max(4.8, ...displayPlanets.map((planet) => 2 + planet.orbit * 0.42)) * scale; + const poiRingStart = maxOrbit + (expanded ? 2.2 : 1.45) * scale; + const poiRingGap = (expanded ? 1.25 : 0.95) * scale; + const outerPoiRadius = displayPois.length ? poiRingStart + (displayPois.length - 1) * poiRingGap : maxOrbit; + const gravityRadius = Math.max(maxOrbit, outerPoiRadius) + (expanded ? 1.4 : 0.9) * scale; + + return ( + + + {displayPlanets.map((planet, index) => { + const orbitRadius = (2.4 + planet.orbit * 0.42) * scale; + const seed = hashString(`${systemId}-${planet.id}`); + const angle = ((seed % 360) / 360) * Math.PI * 2; + const direction = seed % 5 === 0 ? -1 : 1; + const planetSize = (planet.type.includes("Gas") ? 0.42 : 0.28) * scale * (expanded ? 1.15 : 0.85); + const color = planetColors[planet.type] ?? "#cbd5e1"; + + return ( + + {(expanded || index < 3) && } + + + + + + {expanded && ( + + + {planet.name} + + + )} + + + ); + })} + {displayPois.map((poi) => { + const orbit = getSystemPoiOrbitSpec({ systemId, planets, pointsOfInterest, poiId: poi.id, scale, expanded }); + return ( + + + + ); + })} + {expanded && ( + + + {systemName} gravity well + + + )} + + ); +} diff --git a/src/prototypes/r3f/shared/StarSystemNode.tsx b/src/prototypes/r3f/shared/StarSystemNode.tsx new file mode 100644 index 0000000..bfe1ce1 --- /dev/null +++ b/src/prototypes/r3f/shared/StarSystemNode.tsx @@ -0,0 +1,94 @@ +import { Billboard, Text } from "@react-three/drei"; +import type { ThreeEvent } from "@react-three/fiber"; +import { PoiApproachShip } from "./PoiApproachShip"; +import { StarSystemContents } from "./StarSystemContents"; +import type { GalaxySystem, PoiNavigationStatus, PoiNavigationTarget, SystemPointOfInterest } from "./types"; + +type StarSystemNodeProps = { + system: GalaxySystem; + position: [number, number, number]; + selected?: boolean; + hovered?: boolean; + current?: boolean; + destination?: boolean; + waypoint?: boolean; + selectedPoiId?: string | null; + poiNavigationTarget?: PoiNavigationTarget | null; + onPoiNavigationStatusChange?: (status: PoiNavigationStatus) => void; + onClick?: (system: GalaxySystem, event: ThreeEvent) => void; + onDoubleClick?: (system: GalaxySystem, event: ThreeEvent) => void; + onContextMenu?: (system: GalaxySystem, event: ThreeEvent) => void; + onPoiSelect?: (system: GalaxySystem, poi: SystemPointOfInterest, event: ThreeEvent) => void; + onPointerOver?: (system: GalaxySystem, event: ThreeEvent) => void; + onPointerOut?: (system: GalaxySystem, event: ThreeEvent) => void; +}; + +export function StarSystemNode({ + system, + position, + selected, + hovered, + current, + destination, + waypoint, + selectedPoiId, + poiNavigationTarget, + onPoiNavigationStatusChange, + onClick, + onDoubleClick, + onContextMenu, + onPoiSelect, + onPointerOver, + onPointerOut, +}: StarSystemNodeProps) { + const color = destination ? "#22d3ee" : selected ? "#f0a030" : waypoint ? "#a78bfa" : current ? "#22c55e" : system.color; + const size = selected ? 2.4 : hovered || destination || current ? 1.9 : 1.35; + const showLabel = selected || hovered || destination || current || waypoint; + const expanded = selected || hovered; + const contentScale = expanded ? 1.1 : 0.72; + return ( + + onPoiSelect?.(system, poi, event)} + /> + {poiNavigationTarget && poiNavigationTarget.systemId === system.id && ( + + )} + onClick?.(system, event)} + onDoubleClick={(event) => onDoubleClick?.(system, event)} + onContextMenu={(event) => onContextMenu?.(system, event)} + onPointerOver={(event) => onPointerOver?.(system, event)} + onPointerOut={(event) => onPointerOut?.(system, event)} + > + + + + + + + + {showLabel && ( + + + {system.name} + + + )} + + ); +} diff --git a/src/prototypes/r3f/shared/StationMesh.tsx b/src/prototypes/r3f/shared/StationMesh.tsx new file mode 100644 index 0000000..edbb904 --- /dev/null +++ b/src/prototypes/r3f/shared/StationMesh.tsx @@ -0,0 +1,15 @@ +export function StationMesh({ scale = 1, color = "#22d3ee" }: { scale?: number; color?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/prototypes/r3f/shared/galaxyData.ts b/src/prototypes/r3f/shared/galaxyData.ts new file mode 100644 index 0000000..c258fba --- /dev/null +++ b/src/prototypes/r3f/shared/galaxyData.ts @@ -0,0 +1,91 @@ +import { api, CONSTANTS } from "../../../data/fakeBackend"; +import type { GalaxyConnection, GalaxySystem, SystemPlanet, SystemPointOfInterest } from "./types"; + +type CelestialBody = { + name: string; + type: string; + orbit?: number; + innerOrbit?: number; + outerOrbit?: number; + moons?: unknown[]; +}; + +const formatType = (value: string) => value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + +function getSystemPlanets(systemId: string): SystemPlanet[] { + const detail = CONSTANTS.CELESTIAL_BODIES[systemId as keyof typeof CONSTANTS.CELESTIAL_BODIES]; + if (!detail) return []; + + return (detail.bodies as CelestialBody[]) + .filter((body) => body.type !== "belt") + .map((body, index) => ({ + id: `${systemId}-planet-${index}`, + name: body.name, + type: formatType(body.type), + orbit: body.orbit ?? 0, + moons: body.moons?.length ?? 0, + })); +} + +function getSystemPointsOfInterest(system: { id: string; name: string; security: number; stations: string[] }): SystemPointOfInterest[] { + const detail = CONSTANTS.CELESTIAL_BODIES[system.id as keyof typeof CONSTANTS.CELESTIAL_BODIES]; + const bodies = (detail?.bodies ?? []) as CelestialBody[]; + const stationPois = system.stations.map((station, index) => ({ + id: `${system.id}-station-${index}`, + name: station, + type: "station" as const, + description: "Docking, repair, market access, and mission services.", + })); + const beltPois = bodies + .filter((body) => body.type === "belt") + .map((body, index) => ({ + id: `${system.id}-belt-${index}`, + name: body.name, + type: "asteroid_belt" as const, + description: `Mining field spanning orbit ${body.innerOrbit ?? "?"}-${body.outerOrbit ?? "?"}.`, + })); + const routePois: SystemPointOfInterest[] = [ + { + id: `${system.id}-stargate`, + name: `${system.name} Stargate Network`, + type: "stargate", + description: "Linked gates to neighboring systems on the regional route graph.", + }, + ]; + const anomalyPois: SystemPointOfInterest[] = system.security < 0.4 + ? [{ + id: `${system.id}-anomaly`, + name: system.security < 0.2 ? "Unstable Deadspace Rift" : "Hidden Combat Site", + type: "anomaly", + description: "Scan signature with elevated risk and better rewards.", + }] + : []; + + return [...stationPois, ...beltPois, ...routePois, ...anomalyPois]; +} + +export async function loadGalaxyData(): Promise<{ systems: GalaxySystem[]; connections: GalaxyConnection[] }> { + const [systems, connections] = await Promise.all([api.getSystems(), api.getConnections()]); + return { + systems: systems.map((system) => { + const planets = getSystemPlanets(system.id); + return { + id: system.id, + name: system.name, + security: system.security, + x: system.x, + y: system.y, + z: 0, + mapProjection: "backend2d", + faction: system.type, + type: system.type, + planetCount: planets.length || system.planets, + planets, + stations: [...system.stations], + pointsOfInterest: getSystemPointsOfInterest(system), + color: system.color, + }; + }), + connections: connections.map(([a, b]) => [a, b]), + }; +} diff --git a/src/prototypes/r3f/shared/galaxyMap.ts b/src/prototypes/r3f/shared/galaxyMap.ts new file mode 100644 index 0000000..186896c --- /dev/null +++ b/src/prototypes/r3f/shared/galaxyMap.ts @@ -0,0 +1,14 @@ +import type { GalaxySystem, Vec3 } from "./types"; + +export const BACKEND_GALAXY_CENTER = { x: 390, y: 290 }; +export const GALAXY_MAP_SCALE = 0.48; + +export function systemToGalaxyWorld(system: GalaxySystem): Vec3 { + if (system.mapProjection === "world3d") return [system.x, system.y, system.z]; + + return [ + (system.x - BACKEND_GALAXY_CENTER.x) * GALAXY_MAP_SCALE, + system.z, + (system.y - BACKEND_GALAXY_CENTER.y) * GALAXY_MAP_SCALE, + ]; +} diff --git a/src/prototypes/r3f/shared/poiOrbit.ts b/src/prototypes/r3f/shared/poiOrbit.ts new file mode 100644 index 0000000..0584cbc --- /dev/null +++ b/src/prototypes/r3f/shared/poiOrbit.ts @@ -0,0 +1,92 @@ +import type { SystemPlanet, SystemPointOfInterest, Vec3 } from "./types"; + +export type PoiOrbitSpec = { + radius: number; + y: number; + initialAngle: number; + speed: number; +}; + +export function hashString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function getOrbitalSpeed(radius: number, direction = 1) { + return direction * (0.42 / Math.pow(Math.max(radius, 1), 1.5)); +} + +export function getSystemPoiOrbitSpec({ + systemId, + planets, + pointsOfInterest, + poiId, + scale = 1, + expanded = true, +}: { + systemId: string; + planets: SystemPlanet[]; + pointsOfInterest: SystemPointOfInterest[]; + poiId: string; + scale?: number; + expanded?: boolean; +}): PoiOrbitSpec { + const displayPlanets = expanded ? planets : planets.slice(0, 5); + const displayPois = expanded ? pointsOfInterest : pointsOfInterest.slice(0, 4); + const displayIndex = displayPois.findIndex((poi) => poi.id === poiId); + const fullIndex = pointsOfInterest.findIndex((poi) => poi.id === poiId); + const poiIndex = Math.max(0, displayIndex >= 0 ? displayIndex : fullIndex); + const maxOrbit = Math.max(4.8, ...displayPlanets.map((planet) => 2 + planet.orbit * 0.42)) * scale; + const poiRingStart = maxOrbit + (expanded ? 2.2 : 1.45) * scale; + const poiRingGap = (expanded ? 1.25 : 0.95) * scale; + const poi = displayPois[displayIndex] ?? pointsOfInterest[fullIndex] ?? pointsOfInterest[poiIndex]; + const seed = hashString(`${systemId}-${poi?.id ?? poiId}`); + const direction = seed % 7 === 0 ? -1 : 1; + const baseAngle = ((hashString(`${systemId}-poi-ring`) % 360) / 360) * Math.PI * 2; + const initialAngle = baseAngle + poiIndex * 2.399963229728653 + ((seed % 41) - 20) * 0.002; + const radius = poiRingStart + poiIndex * poiRingGap; + + return { + radius, + y: 0.35 * scale, + initialAngle, + speed: getOrbitalSpeed(radius, direction) * 0.72, + }; +} + +export function getSystemPoiPositionAtTime({ + systemId, + planets, + pointsOfInterest, + poiId, + elapsedTime, + scale = 1, + expanded = true, +}: { + systemId: string; + planets: SystemPlanet[]; + pointsOfInterest: SystemPointOfInterest[]; + poiId: string; + elapsedTime: number; + scale?: number; + expanded?: boolean; +}): Vec3 { + const orbit = getSystemPoiOrbitSpec({ systemId, planets, pointsOfInterest, poiId, scale, expanded }); + const angle = orbit.initialAngle + elapsedTime * orbit.speed; + + return [Math.cos(angle) * orbit.radius, orbit.y, -Math.sin(angle) * orbit.radius]; +} + +export function getSystemPoiPosition(args: { + systemId: string; + planets: SystemPlanet[]; + pointsOfInterest: SystemPointOfInterest[]; + poiId: string; + scale?: number; + expanded?: boolean; +}): Vec3 { + return getSystemPoiPositionAtTime({ ...args, elapsedTime: 0 }); +} diff --git a/src/prototypes/r3f/shared/routing.ts b/src/prototypes/r3f/shared/routing.ts new file mode 100644 index 0000000..91322be --- /dev/null +++ b/src/prototypes/r3f/shared/routing.ts @@ -0,0 +1,88 @@ +import type { GalaxyConnection, GalaxySystem, NavigationRoute, RouteSegment } from "./types"; + +const distanceBetween = (a: GalaxySystem, b: GalaxySystem) => { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +}; + +export function findShortestRoute( + originId: string, + destinationId: string, + systems: GalaxySystem[], + connections: GalaxyConnection[], +): NavigationRoute | null { + const byId = new Map(systems.map((system) => [system.id, system])); + const origin = byId.get(originId); + const destination = byId.get(destinationId); + if (!origin || !destination) return null; + + if (originId === destinationId) { + return { origin, destination, systems: [origin], segments: [], totalDistance: 0 }; + } + + const graph = new Map(); + for (const system of systems) graph.set(system.id, []); + for (const [a, b] of connections) { + graph.get(a)?.push(b); + graph.get(b)?.push(a); + } + + const queue = [originId]; + const previous = new Map([[originId, null]]); + for (let i = 0; i < queue.length; i += 1) { + const current = queue[i]; + if (current === destinationId) break; + for (const next of graph.get(current) ?? []) { + if (previous.has(next)) continue; + previous.set(next, current); + queue.push(next); + } + } + + if (!previous.has(destinationId)) return null; + + const ids: string[] = []; + let cursor: string | null = destinationId; + while (cursor) { + ids.unshift(cursor); + cursor = previous.get(cursor) ?? null; + } + + const routeSystems = ids.map((id) => byId.get(id)).filter(Boolean) as GalaxySystem[]; + const segments: RouteSegment[] = []; + for (let i = 0; i < routeSystems.length - 1; i += 1) { + const from = routeSystems[i]; + const to = routeSystems[i + 1]; + segments.push({ from, to, distance: distanceBetween(from, to) }); + } + + return { + origin, + destination, + systems: routeSystems, + segments, + totalDistance: segments.reduce((sum, segment) => sum + segment.distance, 0), + }; +} + +export function combineRoutes(routes: Array): NavigationRoute | null { + const valid = routes.filter(Boolean) as NavigationRoute[]; + if (valid.length === 0) return null; + const systems: GalaxySystem[] = []; + const segments: RouteSegment[] = []; + for (const route of valid) { + route.systems.forEach((system, index) => { + if (systems.length === 0 || index > 0) systems.push(system); + }); + segments.push(...route.segments); + } + return { + origin: systems[0], + destination: systems[systems.length - 1], + systems, + segments, + totalDistance: segments.reduce((sum, segment) => sum + segment.distance, 0), + }; +} diff --git a/src/prototypes/r3f/shared/types.ts b/src/prototypes/r3f/shared/types.ts new file mode 100644 index 0000000..c8dc5ea --- /dev/null +++ b/src/prototypes/r3f/shared/types.ts @@ -0,0 +1,73 @@ +export type Vec3 = [number, number, number]; + +export type SystemPlanet = { + id: string; + name: string; + type: string; + orbit: number; + moons: number; +}; + +export type SystemPointOfInterest = { + id: string; + name: string; + type: "station" | "asteroid_belt" | "anomaly" | "stargate" | "site"; + description: string; +}; + +export type GalaxySystem = { + id: string; + name: string; + security: number; + x: number; + y: number; + z: number; + mapProjection?: "backend2d" | "world3d"; + faction?: string; + type: string; + planetCount: number; + planets: SystemPlanet[]; + stations: string[]; + pointsOfInterest: SystemPointOfInterest[]; + color: string; +}; + +export type GalaxyConnection = [string, string]; + +export type RouteSegment = { + from: GalaxySystem; + to: GalaxySystem; + distance: number; +}; + +export type NavigationRoute = { + origin: GalaxySystem; + destination: GalaxySystem; + systems: GalaxySystem[]; + segments: RouteSegment[]; + totalDistance: number; +}; + +export type PoiNavigationStatus = "approaching" | "arrived"; + +export type PoiNavigationTarget = { + systemId: string; + poiId: string; + poiName: string; + status: PoiNavigationStatus; +}; + +export type ShipTransform = { + position: Vec3; + rotation: Vec3; +}; + +export type CombatLockState = "idle" | "locking" | "locked"; + +export type CombatDogfightState = { + lockState: CombatLockState; + playerOrbitAngle: number; + enemyOrbitAngle: number; + playerPosition: Vec3; + enemyPosition: Vec3; +}; diff --git a/src/prototypes/r3f/shared/useAnimationClock.ts b/src/prototypes/r3f/shared/useAnimationClock.ts new file mode 100644 index 0000000..a667753 --- /dev/null +++ b/src/prototypes/r3f/shared/useAnimationClock.ts @@ -0,0 +1,10 @@ +import { useFrame } from "@react-three/fiber"; +import { useRef } from "react"; + +export function useAnimationClock(callback: (dt: number, elapsed: number) => void) { + const elapsedRef = useRef(0); + useFrame((_, dt) => { + elapsedRef.current += dt; + callback(dt, elapsedRef.current); + }); +} diff --git a/src/prototypes/r3f/starmap/StarMapScene.tsx b/src/prototypes/r3f/starmap/StarMapScene.tsx new file mode 100644 index 0000000..a4db0a9 --- /dev/null +++ b/src/prototypes/r3f/starmap/StarMapScene.tsx @@ -0,0 +1,157 @@ +import { Line } from "@react-three/drei"; +import type { ThreeEvent } from "@react-three/fiber"; +import { SpaceCanvas } from "../shared/SpaceCanvas"; +import { SpaceEnvironment } from "../shared/SpaceEnvironment"; +import { CameraRig } from "../shared/CameraRig"; +import { RouteLine } from "../shared/RouteLine"; +import { StarSystemNode } from "../shared/StarSystemNode"; +import { systemToGalaxyWorld } from "../shared/galaxyMap"; +import type { GalaxyConnection, GalaxySystem, NavigationRoute, PoiNavigationStatus, PoiNavigationTarget, SystemPointOfInterest, Vec3 } from "../shared/types"; +import type { InterstellarTravelStatus } from "../navigation/travelSession"; + +const toWorld = systemToGalaxyWorld; + +type StarMapSceneProps = { + systems: GalaxySystem[]; + connections: GalaxyConnection[]; + selectedSystemId: string | null; + hoveredSystemId: string | null; + currentSystemId: string; + destinationSystemId: string | null; + selectedPoiId?: string | null; + poiNavigationTarget?: PoiNavigationTarget | null; + waypointSystemIds: string[]; + route: NavigationRoute | null; + travelStatus?: InterstellarTravelStatus; + activeSegmentIndex?: number; + segmentProgress?: number; + focusTarget?: Vec3 | null; + onSelect: (system: GalaxySystem) => void; + onPoiSelect: (system: GalaxySystem, poi: SystemPointOfInterest) => void; + onPoiNavigationStatusChange?: (status: PoiNavigationStatus) => void; + onHover: (system: GalaxySystem | null) => void; + onDoubleClick: (system: GalaxySystem) => void; + onContextMenu: (system: GalaxySystem, event: ThreeEvent) => void; +}; + +export function StarMapScene({ + systems, + connections, + selectedSystemId, + hoveredSystemId, + currentSystemId, + destinationSystemId, + selectedPoiId, + poiNavigationTarget, + waypointSystemIds, + route, + travelStatus = "idle", + activeSegmentIndex = 0, + segmentProgress = 0, + focusTarget, + onSelect, + onPoiSelect, + onPoiNavigationStatusChange, + onHover, + onDoubleClick, + onContextMenu, +}: StarMapSceneProps) { + const byId = new Map(systems.map((system) => [system.id, system])); + const routePoints = route?.systems.map(toWorld) ?? []; + const activeSegment = route?.segments[activeSegmentIndex] ?? null; + const activeFrom = activeSegment ? toWorld(activeSegment.from) : null; + const activeTo = activeSegment ? toWorld(activeSegment.to) : null; + const shipMarkerPosition: Vec3 | null = activeFrom && activeTo + ? [ + activeFrom[0] + (activeTo[0] - activeFrom[0]) * segmentProgress, + activeFrom[1] + (activeTo[1] - activeFrom[1]) * segmentProgress + 3, + activeFrom[2] + (activeTo[2] - activeFrom[2]) * segmentProgress, + ] + : null; + + return ( + + + + + {connections.map(([a, b]) => { + const from = byId.get(a); + const to = byId.get(b); + if (!from || !to) return null; + const onRoute = route?.segments.some((segment) => (segment.from.id === a && segment.to.id === b) || (segment.from.id === b && segment.to.id === a)); + return ( + + ); + })} + + {route?.segments.map((segment, index) => { + const completed = travelStatus === "in_transit" && index < activeSegmentIndex; + const active = travelStatus === "in_transit" && index === activeSegmentIndex; + if (!completed && !active) return null; + return ( + + ); + })} + {shipMarkerPosition && travelStatus === "in_transit" && ( + + + + + )} + {systems.map((system) => ( + { + event.stopPropagation(); + onSelect(sys); + }} + onPoiSelect={(sys, poi, event) => { + event.stopPropagation(); + onPoiSelect(sys, poi); + }} + onDoubleClick={(sys, event) => { + event.stopPropagation(); + onDoubleClick(sys); + }} + onContextMenu={(sys, event) => { + event.stopPropagation(); + onContextMenu(sys, event); + }} + onPointerOver={(sys, event) => { + event.stopPropagation(); + onHover(sys); + document.body.style.cursor = "pointer"; + }} + onPointerOut={() => { + onHover(null); + document.body.style.cursor = ""; + }} + /> + ))} + + ); +} + +export { toWorld as starMapToWorld }; diff --git a/src/prototypes/r3f/starmap/starMapState.ts b/src/prototypes/r3f/starmap/starMapState.ts new file mode 100644 index 0000000..b5c67c7 --- /dev/null +++ b/src/prototypes/r3f/starmap/starMapState.ts @@ -0,0 +1,36 @@ +import type { GalaxyConnection, GalaxySystem, NavigationRoute } from "../shared/types"; +import { combineRoutes, findShortestRoute } from "../shared/routing"; + +export type StarMapState = { + systems: GalaxySystem[]; + connections: GalaxyConnection[]; + selectedSystemId: string | null; + hoveredSystemId: string | null; + currentSystemId: string; + destinationSystemId: string | null; + waypointSystemIds: string[]; + route: NavigationRoute | null; + searchQuery: string; + securityFilter: "all" | "highsec" | "lowsec" | "nullsec"; +}; + +export function computeWaypointRoute(state: StarMapState, destinationId = state.destinationSystemId, waypointIds = state.waypointSystemIds) { + const stops = [state.currentSystemId, ...waypointIds, ...(destinationId ? [destinationId] : [])]; + if (stops.length < 2) return null; + return combineRoutes(stops.slice(0, -1).map((id, index) => findShortestRoute(id, stops[index + 1], state.systems, state.connections))); +} + +export function createStarMapState(systems: GalaxySystem[], connections: GalaxyConnection[]): StarMapState { + return { + systems, + connections, + selectedSystemId: null, + hoveredSystemId: null, + currentSystemId: "sol", + destinationSystemId: null, + waypointSystemIds: [], + route: null, + searchQuery: "", + securityFilter: "all", + }; +} diff --git a/src/prototypes/r3f/warp/WarpBubbleScene.tsx b/src/prototypes/r3f/warp/WarpBubbleScene.tsx new file mode 100644 index 0000000..b519fb1 --- /dev/null +++ b/src/prototypes/r3f/warp/WarpBubbleScene.tsx @@ -0,0 +1,107 @@ +import { useMemo, useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { SpaceCanvas } from "../shared/SpaceCanvas"; +import { SpaceEnvironment } from "../shared/SpaceEnvironment"; +import { CameraRig } from "../shared/CameraRig"; +import { ShipMesh } from "../shared/ShipMesh"; +import { RouteLine } from "../shared/RouteLine"; +import type { NavigationRoute, Vec3 } from "../shared/types"; + +type WarpBubbleSceneProps = { + route: NavigationRoute | null; + activeSegmentIndex: number; + segmentProgress: number; + totalProgress: number; +}; + +const routePoint = (index: number, count: number): Vec3 => { + const offset = index - (count - 1) / 2; + return [offset * 14, 14 + Math.sin(index * 0.9) * 3, -34]; +}; + +function WarpWorld({ route, activeSegmentIndex, segmentProgress, totalProgress }: WarpBubbleSceneProps) { + const tunnelRef = useRef(null); + const shipRef = useRef(null); + const streaks = useMemo(() => Array.from({ length: 88 }, (_, index) => { + const angle = index * 2.399; + const radius = 12 + (index % 9) * 2.1; + return { + id: index, + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + z: -90 + (index % 22) * 8, + length: 12 + (index % 5) * 6, + color: index % 4 === 0 ? "#f0a030" : "#22d3ee", + }; + }), []); + + useFrame((state, dt) => { + if (tunnelRef.current) { + tunnelRef.current.rotation.z += dt * 0.35; + tunnelRef.current.children.forEach((child, index) => { + child.position.z += dt * (42 + (index % 7) * 5); + if (child.position.z > 28) child.position.z = -120; + }); + } + if (shipRef.current) { + shipRef.current.rotation.z = Math.sin(state.clock.elapsedTime * 1.7) * 0.035; + shipRef.current.position.y = Math.sin(state.clock.elapsedTime * 2.2) * 0.2; + } + }); + + const routePoints = route?.systems.map((_, index) => routePoint(index, route.systems.length)) ?? []; + const active = route?.segments[activeSegmentIndex] ?? null; + + return ( + <> + + + + + + {streaks.map((streak) => ( + + + + + ))} + + + + + + + + + + + + + {routePoints.length > 1 && } + {routePoints.map((point, index) => ( + + + + + ))} + {active && ( + + + + + + + + )} + + ); +} + +export function WarpBubbleScene(props: WarpBubbleSceneProps) { + return ( + + + + ); +} diff --git a/src/prototypes/standalone-huds/GameHudPrototype.tsx b/src/prototypes/standalone-huds/GameHudPrototype.tsx new file mode 100644 index 0000000..ff4f7d2 --- /dev/null +++ b/src/prototypes/standalone-huds/GameHudPrototype.tsx @@ -0,0 +1,10 @@ +import { GameHudDemo } from "../existing-demos/GameHudDemo"; +import { PrototypeFrame } from "./PrototypeFrame"; + +export function GameHudPrototype() { + return ( + + + + ); +} diff --git a/src/prototypes/standalone-huds/PrototypeFrame.tsx b/src/prototypes/standalone-huds/PrototypeFrame.tsx new file mode 100644 index 0000000..488e042 --- /dev/null +++ b/src/prototypes/standalone-huds/PrototypeFrame.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; + +type PrototypeFrameProps = { + title: string; + sourceFile: string; + children: ReactNode; +}; + +export function PrototypeFrame({ title, sourceFile, children }: PrototypeFrameProps) { + return ( +
+
+ HUD +

{title}

+
+
+ Kept from {sourceFile} as the visual reference for future demos. +
+ {children} +
+ ); +} diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css new file mode 100644 index 0000000..709a171 --- /dev/null +++ b/src/styles/tailwind.css @@ -0,0 +1,200 @@ +@import "tailwindcss"; + +@theme { + --color-bg: #080c14; + --color-bg-subtle: #0b1120; + --color-surface: #0f1623; + --color-surface-raised: #162032; + --color-surface-hover: #1c2d45; + --color-fg: #d4dce8; + --color-fg-bright: #f1f5f9; + --color-fg-dim: #94a3b8; + --color-muted: #5a6b82; + --color-border: #1c2a3f; + --color-border-light: #253550; + --color-accent: #f0a030; + --color-accent-hover: #fbbf24; + --color-cyan: #22d3ee; + --color-red: #ef4444; + --color-green: #22c55e; + --color-purple: #a78bfa; + + --font-display: "SF Pro Display", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-body: "SF Pro Text", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, Menlo, monospace; + + --spacing-sidebar: 260px; + --spacing-sidebar-collapsed: 60px; + --spacing-topbar: 48px; + --spacing-content: 1100px; +} + +@layer base { + :root { + --bg: var(--color-bg); + --bg-subtle: var(--color-bg-subtle); + --surface: var(--color-surface); + --surface-base: var(--color-bg); + --surface-sunken: var(--color-bg-subtle); + --surface-raised: var(--color-surface-raised); + --surface-hover: var(--color-surface-hover); + --fg: var(--color-fg); + --fg-bright: var(--color-fg-bright); + --fg-dim: var(--color-fg-dim); + --muted: var(--color-muted); + --border: var(--color-border); + --border-light: var(--color-border-light); + --accent: var(--color-accent); + --amber: var(--color-accent); + --accent-hover: var(--color-accent-hover); + --accent-dim: #b47818; + --accent-bg: rgba(240, 160, 48, 0.08); + --accent-border: rgba(240, 160, 48, 0.25); + --cyan: var(--color-cyan); + --cyan-dim: #0891b2; + --cyan-bg: rgba(34, 211, 238, 0.08); + --red: var(--color-red); + --red-dim: #dc2626; + --red-bg: rgba(239, 68, 68, 0.08); + --green: var(--color-green); + --green-dim: #16a34a; + --green-bg: rgba(34, 197, 94, 0.08); + --purple: var(--color-purple); + --purple-dim: #8b5cf6; + --purple-bg: rgba(167, 139, 250, 0.08); + --font-display: "SF Pro Display", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-body: "SF Pro Text", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, Menlo, monospace; + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + --sp-12: 48px; + --sp-16: 64px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 20px; + --radius-pill: 9999px; + } + + *, *::before, *::after { + box-sizing: border-box; + } + + html { + font-size: 14px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + @apply h-screen overflow-hidden bg-bg font-body text-fg; + line-height: 1.6; + } + + #root { + @apply flex h-screen; + } + + a { + @apply text-cyan no-underline transition-colors duration-150 hover:text-accent; + } + + h1, h2, h3, h4, h5, h6 { + @apply font-display text-fg-bright; + line-height: 1.25; + letter-spacing: 0; + } + + h1 { + @apply text-[2rem] font-bold; + } + + h2 { + @apply mb-4 text-2xl font-semibold; + } + + h3 { + @apply mb-3 text-[1.2rem] font-semibold; + } + + h4 { + @apply mb-2 text-[1.05rem] font-semibold; + } + + p { + @apply mb-4; + } + + code { + @apply rounded bg-surface-raised px-1.5 py-0.5 font-mono text-[0.9em] text-accent; + } + + pre { + @apply mb-4 overflow-x-auto rounded-lg border border-border bg-surface p-4 font-mono text-[0.85rem]; + line-height: 1.5; + } + + ul, ol { + @apply mb-4 pl-6; + } + + li { + @apply mb-1; + } + + button, + input, + select, + textarea { + font: inherit; + } + + input, + select, + textarea { + @apply rounded border border-border bg-surface-raised px-3 py-2 text-fg focus:border-cyan focus:outline-none focus:ring-2 focus:ring-cyan/10; + } + + ::selection { + @apply bg-accent text-bg; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--color-border-light) transparent; + } + + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + @apply rounded-full bg-border-light; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-muted; + } + + @keyframes pulse { + 0%, 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5ec64a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..3adda81 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b0044df --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [tailwindcss()], + esbuild: { + jsx: "automatic", + jsxImportSource: "react", + }, +}); diff --git a/void-nav-game-hud.html b/void-nav-game-hud.html deleted file mode 100644 index a844f06..0000000 --- a/void-nav-game-hud.html +++ /dev/null @@ -1,831 +0,0 @@ - - - - - - - VOID::NAV — Game HUD - - - -
- - - - - -