chore: sync codebase remediation, gameplay systems, and docs

Security & infrastructure:
- Remove unused services/ (auth, spacetimedb) and auth.db
- Add .env.example template, expand .gitignore for env/db files
- Add GitHub Actions CI + commitlint config and workflows
- Add manual vendor chunking and source maps to docs/site vite configs

Shared UI & docs app:
- Add ARIA props and focus-visible rings to Button/Panel
- Add ButtonAsLink primitive; use shared Button in NotFound
- Wire @void-nav/ui into docs app; refresh content pages
- Replace Todo page with Kanban board

Gameplay (Bevy):
- Add ai module (behavior, faction, navigation, perception, spawning, states)
- Add narrative module (events, history, synthesis, ui)
- Refine galaxy contents and in-system flight/scene systems
This commit is contained in:
2026-06-16 11:49:13 -04:00
parent 98c2ba59df
commit 57633addfe
60 changed files with 5084 additions and 2473 deletions

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.17.10",
"@void-nav/ui": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",

View File

@@ -17,7 +17,7 @@ import { DemoGalleryPage } from "./pages/docs/DemoGalleryPage";
import { GapAnalysisPage } from "./pages/docs/GapAnalysisPage";
import { VerticalSliceEvaluationPage } from "./pages/docs/VerticalSliceEvaluationPage";
import { DesignDocPage } from "./pages/docs/DesignDocPage";
import { TodoPage } from "./pages/docs/TodoPage";
import { KanbanBoardPage } from "./pages/docs/KanbanBoardPage";
import { StarMapDemo } from "./prototypes/existing-demos/StarMapDemo";
import { ShipMovementDemo } from "./prototypes/existing-demos/ShipMovementDemo";
import { WarpTravelDemo } from "./prototypes/existing-demos/WarpTravelDemo";
@@ -57,7 +57,7 @@ export function App() {
<Route path="gap-analysis" element={<GapAnalysisPage />} />
<Route path="vertical-slice-evaluation" element={<VerticalSliceEvaluationPage />} />
<Route path="design-doc" element={<DesignDocPage />} />
<Route path="todo" element={<TodoPage />} />
<Route path="kanban-board" element={<KanbanBoardPage />} />
<Route path="demos/starmap" element={<StarMapDemo />} />
<Route path="demos/game-loop" element={<GameLoopSliceDemo />} />

View File

@@ -1,11 +1,11 @@
import { Link } from "react-router-dom";
import { ButtonAsLink } from "@void-nav/ui";
export function NotFound() {
return (
<div className="mx-auto max-w-content">
<h1>Page Not Found</h1>
<p className="text-fg-dim">The requested route does not exist.</p>
<Link className="inline-flex items-center gap-2 rounded-lg border border-accent bg-accent px-4 py-2 text-[0.8rem] font-semibold text-bg transition-all duration-150 hover:bg-accent-hover" to="/docs">Back to documentation</Link>
<ButtonAsLink to="/docs" tone="primary">Back to documentation</ButtonAsLink>
</div>
);
}

View File

@@ -24,7 +24,7 @@ export const navSections: NavSection[] = [
{ path: "/docs/social", icon: "✧", label: "Progression & World" },
{ path: "/docs/ship-ai", icon: "◈", label: "Ship AI - Zora" },
{ path: "/docs/roadmap", icon: "⊞", label: "Roadmap" },
{ path: "/docs/todo", icon: "", label: "Implementation Status" },
{ path: "/docs/kanban-board", icon: "", label: "Implementation Board" },
{ path: "/docs/risks", icon: "◬", label: "Risks & Questions" },
{ path: "/docs/gap-analysis", icon: "□", label: "Gap Analysis" },
{ path: "/docs/vertical-slice-evaluation", icon: "▤", label: "Slice Evaluation" },

View File

@@ -6,9 +6,9 @@ export function ArchitecturePage() {
<div className="mx-auto max-w-content">
<h1 style={{ marginBottom: '8px' }}>Architecture Overview</h1>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '680px' }}>
<strong>Key principle:</strong> SpacetimeDB owns
authoritative game state. Everything else derives from it. There is no localStorage persistence is always through
SpacetimeDB, even in the single-player core game where SpacetimeDB runs locally.
<strong>Key principle:</strong> Local-first architecture for single-player narrative experience.
The game client manages all state locally with persistent storage. Your story lives on your machine
no cloud required, no always-online, no subscriptions.
</p>
{/* Architecture diagram */}
@@ -20,15 +20,16 @@ export function ArchitecturePage() {
</div>
<div style={{ padding: 'var(--sp-5)' }}>
{[
{ name: 'React UI', color: 'var(--cyan)', desc: 'Inventory, market, chat, station screens, ship status, debug panel. Owns UI layout and user workflow.' },
{ name: 'React UI', color: 'var(--cyan)', desc: 'Inventory, market, 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.' },
{ name: 'R3F Scene', color: 'var(--accent)', desc: 'Ships, stations, asteroids, anomalies, camera, targeting lines. Visual layer only.' },
{ name: 'AI Story Director', color: 'var(--purple)', desc: 'Narrative engine that tracks player actions and generates story events. Weaves choices into saga.' },
{ name: 'Ship AI (Zora)', color: 'var(--purple)', desc: 'Companion AI system with soul state, module gates, and autonomous behavior. See Ship AI page.' },
{ name: 'Game Client', color: 'var(--red)', desc: 'Core game logic, state management, and local persistence. Source of truth.' },
{ name: 'Local Storage', color: 'var(--cyan)', desc: 'Persistent state survives restart. Ships, inventory, story log, faction relations.' },
].map((layer, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'stretch', gap: 'var(--sp-3)', marginBottom: i < 5 ? '2px' : 0 }}>
<div key={i} style={{ display: 'flex', alignItems: 'stretch', gap: 'var(--sp-3)', marginBottom: i < 6 ? '2px' : 0 }}>
<div style={{ width: '3px', background: layer.color, borderRadius: '2px', flexShrink: 0 }} />
<div style={{ padding: 'var(--sp-2) 0', flex: 1, display: 'flex', gap: 'var(--sp-4)', alignItems: 'baseline' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: layer.color, minWidth: '140px', fontWeight: 600 }}>
@@ -49,16 +50,16 @@ export function ArchitecturePage() {
<h3>React UI Responsibilities</h3>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.9rem', marginBottom: 'var(--sp-5)' }}>
<li>Global shell, HUD layout, docking/station screens, and route-like page states.</li>
<li>Inventory table, market orders table, chat, selected target panel, ship status, system overview.</li>
<li>User commands that call reducers: mine, dock, sell, place order, send chat.</li>
<li>Inventory table, market orders table, selected target panel, ship status, system overview.</li>
<li>User commands that trigger game actions: mine, dock, sell, place order.</li>
<li>Local-only concerns: panel layout, sorting, filters, tabs, keyboard shortcuts, tooltips.</li>
</ul>
<h3>R3F Responsibilities</h3>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.9rem', marginBottom: 'var(--sp-5)' }}>
<li>Render star-system scene with ships, stations, asteroids, anomalies, waypoints, fauna, and event effects.</li>
<li>Render star-system scene with ships, stations, asteroids, anomalies, and waypoints.</li>
<li>Camera controls, entity picking, hover states, click-to-move command creation.</li>
<li>Interpolated movement between authoritative state updates.</li>
<li>Interpolated movement between state updates.</li>
<li>Expose renderer events upward never push game logic downward into the renderer.</li>
</ul>
@@ -73,13 +74,13 @@ export function ArchitecturePage() {
</thead>
<tbody>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Authoritative</span></td>
<td>SpacetimeDB tables/subscriptions</td>
<td>Ship position, inventory, market orders, asteroid resources.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Persistent</span></td>
<td>Local storage</td>
<td>Ship position, inventory, market orders, story log, faction relations.</td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Derived view</span></td>
<td>Client game store/view models</td>
<td>Client game store</td>
<td>Selected ship details, rendered position, distance to target.</td>
</tr>
<tr>
@@ -95,165 +96,32 @@ export function ArchitecturePage() {
</tbody>
</table>
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]'" style={{ marginTop: 'var(--sp-8)' }}>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-3</span>
<h2 style={{ margin: 0 }}>Renderer Replaceability</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Key principle:</strong> Use React Three Fiber for speed now, but prevent it from becoming
the game architecture. The renderer consumes view models and emits input events.
</div>
<div className="mb-5 overflow-x-auto rounded-lg border border-border bg-surface px-5 py-4 [&_code]:bg-transparent [&_code]:p-0 [&_code]:font-mono [&_code]:text-[0.82rem] [&_code]:leading-[1.6] [&_code]:text-fg-dim">
<code>
<span className="text-muted italic">// Renderer interface — implementation-agnostic</span><br/>
<span className="text-purple">type</span> <span className="text-cyan">GameRendererInput</span> = &#123;<br/>
&nbsp;&nbsp;ships: <span className="text-cyan">ShipViewModel</span>[];<br/>
&nbsp;&nbsp;stations: <span className="text-cyan">StationViewModel</span>[];<br/>
&nbsp;&nbsp;asteroids: <span className="text-cyan">AsteroidViewModel</span>[];<br/>
&nbsp;&nbsp;anomalies: <span className="text-cyan">AnomalyViewModel</span>[];<br/>
&nbsp;&nbsp;worldEvents: <span className="text-cyan">WorldEventViewModel</span>[];<br/>
&nbsp;&nbsp;selectedEntityId: <span className="text-cyan">string</span> | <span className="text-purple">null</span>;<br/>
&#125;;<br/>
<br/>
<span className="text-purple">type</span> <span className="text-cyan">GameRendererEvents</span> = &#123;<br/>
&nbsp;&nbsp;<span className="text-accent">onSelectEntity</span>(entityId: <span className="text-cyan">string</span>): <span className="text-cyan">void</span>;<br/>
&nbsp;&nbsp;<span className="text-accent">onMoveCommand</span>(position: <span className="text-cyan">Vec3</span>): <span className="text-cyan">void</span>;<br/>
&nbsp;&nbsp;<span className="text-accent">onMineCommand</span>(asteroidId: <span className="text-cyan">string</span>): <span className="text-cyan">void</span>;<br/>
&nbsp;&nbsp;<span className="text-accent">onDockCommand</span>(stationId: <span className="text-cyan">string</span>): <span className="text-cyan">void</span>;<br/>
&#125;;
</code>
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginTop: 'var(--sp-5)' }}>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4 border-l-[3px] border-l-accent">
<h4 style={{ color: 'var(--green)' }}>Keep renderer-specific</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li>Meshes, materials, lights, particles</li>
<li>Camera controls</li>
<li>Raycasting and pointer interactions</li>
<li>Visual interpolation implementation</li>
</ul>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4 border-l-[3px] border-l-accent">
<h4 style={{ color: 'var(--cyan)' }}>Keep renderer-independent</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li>Game types, reducers, subscriptions</li>
<li>Domain rules: mining, docking, selling</li>
<li>Inventory logic and market validation</li>
<li>Shared movement math and view models</li>
</ul>
</div>
</div>
{/* ═══ RECONNECTION & ERROR HANDLING ═══ */}
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-4</span>
<h2 style={{ margin: 0 }}>Error Handling & Reconnection</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Players should never lose progress due to a disconnect.</strong>
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.
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Disconnection Scenarios</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Scenario</th><th>Server Behavior</th><th>On Reconnect</th><th>Player Impact</th></tr>
</thead>
<tbody>
{[
{ 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 co-op)', 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) => (
<tr key={i}>
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.scene}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.server}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.reconnect}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impact}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
<h4 style={{ color: 'var(--cyan)' }}>Reconnection Flow</h4>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', color: 'var(--fg-dim)', lineHeight: 2 }}>
1. <span style={{ color: 'var(--red)' }}>Detect disconnect</span> — WebSocket close event or heartbeat timeout (10s no response)<br/>
2. <span style={{ color: 'var(--accent)' }}>Show reconnect banner</span> — "Connection lost. Reconnecting..." with spinning indicator. UI freezes input but continues rendering last known state.<br/>
3. <span style={{ color: 'var(--green)' }}>Auto-retry</span> — Exponential backoff: 1s, 2s, 4s, 8s, max 30s. Up to 10 attempts over ~5 minutes.<br/>
4. <span style={{ color: 'var(--cyan)' }}>Re-establish subscription</span> — On reconnect, re-subscribe to all relevant SpacetimeDB tables. Server sends full state diff.<br/>
5. <span style={{ color: 'var(--purple)' }}>State reconciliation</span> — Client merges server state into local state. Visual positions snap to authoritative positions. Inventory, market, chat all refresh.<br/>
6. <span style={{ color: 'var(--green)' }}>Resume gameplay</span> — Banner disappears. All inputs re-enabled. Player is back in the game.
</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--red)' }}>
<h4 style={{ color: 'var(--red)' }}>Failed Reconnection</h4>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', color: 'var(--fg-dim)', lineHeight: 2 }}>
<p>If all 10 attempts fail (5+ minutes of no connection):</p>
<p>
<span style={{ color: 'var(--accent)' }}>{'Core game (local SpacetimeDB):'}</span>
{' This should never happen. If it does, it\'s a bug. Show error screen with "restart game" button. Local SpacetimeDB state is intact.'}
</p>
<p>
<span style={{ color: 'var(--cyan)' }}>{'Co-op server (remote SpacetimeDB):'}</span>
{' Show "Connection lost" screen with two options: [Retry Now] (immediate reconnect attempt) and [Return to Login]. Ship persists on server. No data lost.'}
</p>
<p>
<span style={{ color: 'var(--muted)' }}>{'The ship never vanishes on disconnect. It stays in the world, obeying server rules. This is intentional — on co-op servers, disconnecting to escape combat is not allowed.'}</span>
</p>
</div>
</div>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-6)' }}>
<strong>Anti-exploit: combat disconnect.</strong> On co-op servers, 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."
</div>
{/* ═══ SESSION PERSISTENCE ═══ */}
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-5</span>
<h2 style={{ margin: 0 }}>Session Persistence & Save/Load</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>There is no save button and no manual save/load.</strong>
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.
The game client persists state continuously every action, position change, and story event is saved locally.
"Saving" is not a player action; it's the natural consequence of playing the game.
Closing the game and returning tomorrow restores you to exactly where you left off.
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>What Gets Persisted</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Category</th><th>Tables</th><th>Persistence Guarantee</th></tr>
<tr><th>Category</th><th>Data</th><th>Persistence Guarantee</th></tr>
</thead>
<tbody>
{[
{ 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.' },
{ cat: 'Player Identity', tables: 'Captain name, skills, standing, story progress', guarantee: 'Permanent. Never lost.' },
{ cat: 'Ship State', tables: 'Position, fitting, AI companion state, cargo', guarantee: 'Permanent. If destroyed, death state persists until respawn.' },
{ cat: 'Economy', tables: 'Inventory items, market orders, manufacturing jobs', guarantee: 'Permanent. Items and orders survive restart.' },
{ cat: 'Navigation', tables: 'Bookmarks, waypoints', guarantee: 'Permanent. Saved locations survive indefinitely.' },
{ cat: 'Story Log', tables: 'AI Story events, discoveries, choices, faction relations', guarantee: 'Permanent. Your saga is preserved.' },
{ cat: 'World State', tables: 'Factions, world events, galaxy state', guarantee: 'Saved with your campaign. Continues where you left off.' },
].map((row, i) => (
<tr key={i}>
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.cat}</td>
@@ -266,339 +134,177 @@ export function ArchitecturePage() {
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
<h4 style={{ color: 'var(--green)' }}>Offline Progression</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-3) 0' }}>
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.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>
"Come back tomorrow" is always a valid answer your manufacturing job finishes whether you watch it or not.
</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--accent)' }}>
<h4 style={{ color: 'var(--accent)' }}>What Does NOT Persist</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li><strong style={{ color: 'var(--fg)' }}>UI layout:</strong> Panel positions, sorting preferences, open tabs. These reset on reload. (Future: saved layout profiles.)</li>
<li><strong style={{ color: 'var(--fg)' }}>Camera state:</strong> Zoom level, orbit angle. Reset to default on reconnection.</li>
<li><strong style={{ color: 'var(--fg)' }}>In-progress inputs:</strong> Half-typed chat messages, unfinalized market orders. Lost on disconnect.</li>
<li><strong style={{ color: 'var(--fg)' }}>Transient effects:</strong> Active weapon animations, explosion particles, HUD flash effects. Visual only not gameplay state.</li>
<li><strong style={{ color: 'var(--fg)' }}>UI layout:</strong> Panel positions, sorting preferences, open tabs. These reset on reload.</li>
<li><strong style={{ color: 'var(--fg)' }}>Camera state:</strong> Zoom level, orbit angle. Reset to default on load.</li>
<li><strong style={{ color: 'var(--fg)' }}>In-progress inputs:</strong> Half-typed messages, unfinalized orders. Lost on exit.</li>
<li><strong style={{ color: 'var(--fg)' }}>Transient effects:</strong> Active weapon animations, explosion particles. Visual only.</li>
</ul>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
<h4 style={{ color: 'var(--purple)' }}>Story-Driven Persistence</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-3) 0' }}>
Every choice you make feeds the AI Story Director. Your discoveries, alliances, conflicts, and triumphs
are woven into an ongoing narrative that persists across sessions. The story log is your personal
chronicle — preserved locally, always accessible, never lost.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>
"Come back tomorrow" continues your story where you left off.
</div>
</div>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
<strong>Persistence model:</strong> The core game and co-op servers both use SpacetimeDB exclusively there is no localStorage.
In the core game, SpacetimeDB runs locally on the player's machine. "Persistence" means the local database file survives browser restart.
On co-op servers, SpacetimeDB runs on a remote server. Persistence is permanent and shared. The only difference is where the database
process runs; the persistence model is identical.
<strong>Future multiplayer considerations:</strong> If multiplayer servers are added post-V1, the architecture will
extend to support remote state synchronization. The single-player foundation ensures all systems work locally first —
multiplayer would be an optional enhancement, not a requirement.
</div>
{/* ═══ SOUND & AUDIO ═══ */}
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-6</span>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-4</span>
<h2 style={{ margin: 0 }}>Sound & Audio Design</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Audio reinforces the spreadsheet.</strong> 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.
<strong>Audio reinforces the narrative.</strong> 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 sounds that make actions feel satisfying. Audio is always optional.
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Audio Categories</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Category</th><th>Purpose</th><th>Examples</th><th>Priority</th></tr>
<tr><th>Category</th><th>Purpose</th><th>Examples</th></tr>
</thead>
<tbody>
{[
{ 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.' },
{ cat: 'Alerts', purpose: 'Critical state changes', examples: 'Shields low, target lock, incoming damage, story event notification' },
{ cat: 'UI Feedback', purpose: 'Confirm player actions', examples: 'Order placed, module fitted, skill leveled, ISK received' },
{ cat: 'Ambient', purpose: 'Atmosphere and immersion', examples: 'Station hum (docked), solar wind, mining laser, warp tunnel' },
{ cat: 'Combat', purpose: 'Combat state feedback', examples: 'Weapon firing, shield hit, armor hit, capacitor warning' },
{ cat: 'Story Events', purpose: 'Narrative moments', examples: 'Discovery ping, faction broadcast, AI Story Director narration' },
{ cat: 'Voice', purpose: 'Character dialogue', examples: 'Zora responses, NPC agent dialogue, story narration' },
].map((row, i) => (
<tr key={i}>
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.cat}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.purpose}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.examples}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.priority}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
<h4 style={{ color: 'var(--cyan)' }}>Volume Controls</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li><strong style={{ color: 'var(--fg)' }}>Master:</strong> 0100%. Controls all audio. Default 80%.</li>
<li><strong style={{ color: 'var(--fg)' }}>UI:</strong> Panel sounds, market dings, fitting clicks. Default 70%.</li>
<li><strong style={{ color: 'var(--fg)' }}>Combat:</strong> Weapons, impacts, Red Alert. Default 80%.</li>
<li><strong style={{ color: 'var(--fg)' }}>Ambient:</strong> Background atmosphere. Default 50%.</li>
<li><strong style={{ color: 'var(--fg)' }}>World:</strong> Event sounds, fauna, anomalies. Default 60%.</li>
<li><strong style={{ color: 'var(--fg)' }}>Voice:</strong> Zora and NPC dialogue. Default 90%. Has separate mute toggle.</li>
</ul>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
<h4 style={{ color: 'var(--green)' }}>Spatial Audio Rules</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li>Combat sounds are directional — weapon fire comes from the direction of the source</li>
<li>Distant events are muffled (low-pass filter scales with distance)</li>
<li>Station ambient sounds only play when docked (cross-fade on dock/undock)</li>
<li>Warp tunnel audio fades in during acceleration, peaks at cruise, fades out on deceleration</li>
<li>Zora's voice always comes from "center" she's inside your head, not in space</li>
<li>Audio never provides exclusive gameplay information — everything audible has a visual equivalent</li>
</ul>
</div>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
<strong>Implementation note:</strong> Audio is Phase 7 scope (Single-Player Polish). Phase 06 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.
</div>
{/* ═══ LOCALIZATION ═══ */}
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-7</span>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-5</span>
<h2 style={{ margin: 0 }}>Localization & Internationalization</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>MVP is English-only. This section documents the decision and the architecture that makes future localization non-breaking.</strong>
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.
<strong>MVP is English-only with day-one i18n architecture.</strong> 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.
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
<h4 style={{ color: 'var(--cyan)' }}>What Ships in English Only (MVP)</h4>
<h4 style={{ color: 'var(--cyan)' }}>i18n Architecture</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li>All UI labels, button text, and panel headers</li>
<li>Tutorial mission dialogue</li>
<li>Zora personality templates (Tier 0)</li>
<li>NPC agent dialogue</li>
<li>Error messages and status notifications</li>
<li>Item, module, and ship names</li>
<li><strong style={{ color: 'var(--fg)' }}>String keys:</strong> All user-facing text uses lookup keys: <code>t("market.order.placed")</code></li>
<li><strong style={{ color: 'var(--fg)' }}>Number formatting:</strong> ISK values use <code>Intl.NumberFormat</code></li>
<li><strong style={{ color: 'var(--fg)' }}>Date/time:</strong> Timestamps use <code>Intl.DateTimeFormat</code></li>
<li><strong style={{ color: 'var(--fg)' }}>Pluralization:</strong> ICU message format for count-aware strings</li>
<li><strong style={{ color: 'var(--fg)' }}>Layout:</strong> CSS flexbox/grid with logical properties for RTL readiness</li>
</ul>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
<h4 style={{ color: 'var(--green)' }}>i18n Architecture (Day-One Foundation)</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li><strong style={{ color: 'var(--fg)' }}>String keys:</strong> All user-facing text uses lookup keys, not hardcoded strings. <code>t("market.order.placed")</code> not <code>"Order placed"</code></li>
<li><strong style={{ color: 'var(--fg)' }}>Number formatting:</strong> ISK values, quantities, percentages use <code>Intl.NumberFormat</code> from day one</li>
<li><strong style={{ color: 'var(--fg)' }}>Date/time:</strong> Timestamps use <code>Intl.DateTimeFormat</code>. Relative time ("5 minutes ago") via <code>Intl.RelativeTimeFormat</code></li>
<li><strong style={{ color: 'var(--fg)' }}>Pluralization:</strong> Use ICU message format for count-aware strings ("1 item" vs "5 items")</li>
<li><strong style={{ color: 'var(--fg)' }}>Layout:</strong> UI components use CSS flexbox/grid. No hardcoded pixel widths that assume English string lengths</li>
<li><strong style={{ color: 'var(--fg)' }}>RTL ready:</strong> CSS logical properties (start/end, not left/right) for future Arabic/Hebrew support</li>
</ul>
<h4 style={{ color: 'var(--green)' }}>Post-MVP Priority</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-3) 0' }}>
First languages after English determined by player population. Estimated effort per language:
12 weeks for translation + 23 days for QA with RTL layout testing if applicable.
</p>
</div>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Not localized (by design):</strong> 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.
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
<strong>Post-MVP language priority:</strong> 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: 12 weeks for translation + 23 days for QA with RTL layout testing if applicable.
</div>
{/* ═══ ACCESSIBILITY ═══ */}
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-8</span>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-6</span>
<h2 style={{ margin: 0 }}>Accessibility</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>A spreadsheet game should be the most accessible genre in the world.</strong>
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).
<strong>A narrative-driven game should be the most accessible genre.</strong>
Reading tables, managing numbers, and clicking buttonsactivities browsers excel at supporting.
The following accessibility targets are baseline requirements.
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Accessibility Requirements</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Area</th><th>Requirement</th><th>Implementation</th><th>Phase</th></tr>
<tr><th>Area</th><th>Requirement</th></tr>
</thead>
<tbody>
{[
{ 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 on co-op servers, which is inherently competitive.", phase: '7' },
{ area: 'Color Blindness', req: 'All color-coded information has secondary indicator (icon, label, shape)' },
{ area: 'Keyboard Navigation', req: 'Every action reachable by keyboard. Tab order, Enter, Escape, arrow keys' },
{ area: 'Screen Reader', req: 'All panels and data tables announce state changes. ARIA labels on interactive elements' },
{ area: 'Text Scaling', req: 'UI remains usable at 200% browser zoom. All layouts use responsive sizing' },
{ area: 'Reduced Motion', req: 'Respect prefers-reduced-motion. Disable animations when requested' },
{ area: 'Contrast', req: 'All text meets WCAG AA contrast ratio (4.5:1 for normal text)' },
{ area: 'Cognitive Load', req: 'Collapsible panels. Summary vs detail view. Tutorial hints can be disabled' },
].map((row, i) => (
<tr key={i}>
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.area}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.req}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impl}</td>
<td className="font-mono tabular-nums">{row.phase}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>PvP accessibility note:</strong> PvP combat on co-op servers 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.
</div>
{/* ═══ MOVEMENT & COLLISION ═══ */}
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
<strong>Testing:</strong> Accessibility validation is part of Gate 4 (Core Game 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.
</div>
{/* ═══ CUSTOM MOVEMENT & COLLISION ═══ */}
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-9</span>
<h2 style={{ margin: 0 }}>Custom Movement & Collision (No Physics Engine)</h2>
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]'" style={{ marginTop: 'var(--sp-8)' }}>
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-7</span>
<h2 style={{ margin: 0 }}>Movement & Collision (No Physics Engine)</h2>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Decision:</strong> The Bevy game client uses a hand-rolled kinematic movement system and distance-based
collision detection. We do <strong>not</strong> integrate Rapier, Avian, XPBD, or any other rigid-body physics engine.
Raycasting and collision are implemented as pure geometry functions over circles in 2D.
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Why No Physics Engine</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Concern</th><th>Custom Kinematic</th><th>Physics Engine (Rapier/Avian)</th></tr>
</thead>
<tbody>
{[
{ c: 'Genre fit', custom: 'Native — FTL/Windward style is point-and-shoot, not rigid bodies.', engine: 'Overkill — solvers are for stacking, joints, real impulses.' },
{ c: 'Arcade feel', custom: 'Native — drag, snap turns, no inertia when undesired.', engine: 'Fight the solver — disabling bounce/friction is config hell.' },
{ c: 'Compile time', custom: '0 added', engine: '+3090s on every clean build' },
{ c: 'Binary size', custom: '0 added', engine: '+25 MB' },
{ c: 'Determinism', custom: 'Trivial — linear math, no solver iterations.', engine: 'Hard — solver iterations + float ops in unpredictable order.' },
{ c: 'Network prediction', custom: 'Linear extrapolation of owned values.', engine: 'Replaying the solver is a nightmare.' },
{ c: 'Bug surface', custom: '~50 lines you wrote.', engine: 'Tens of thousands of lines you didnt.' },
].map((row, i) => (
<tr key={i}>
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.c}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.custom}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.engine}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>What "Physics" Means in This Game</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Gameplay Need</th><th>Implementation</th><th>Math</th></tr>
</thead>
<tbody>
{[
{ need: 'Ship traversal', impl: 'Transform updates with Velocity, MaxSpeed, TurnRate, optional Drag.', math: 'pos += velocity * dt' },
{ need: 'Planet/station orbiting', impl: 'Orbit component on child entity of star system.', math: 'pos = center + r * (cos(θ), sin(θ))' },
{ need: 'Projectile vs ship hits', impl: 'Distance check each tick (no raycast needed for fast projectiles).', math: '‖a b‖ < r_a + r_b' },
{ need: 'Weapon targeting / LOS', impl: 'Ray-circle intersection, pick smallest t.', math: 'Quadratic: ‖o + t·d c‖² = r²' },
{ need: 'Ship-ship separation', impl: 'Push apart by overlap distance.', math: 'delta = (a b).normalize() * (r_a + r_b dist)' },
{ need: 'Docking / proximity triggers', impl: 'Distance check, fire event when crossing threshold.', math: '‖a b‖ < trigger_radius' },
].map((row, i) => (
<tr key={i}>
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.need}</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impl}</td>
<td style={{ color: 'var(--cyan)', fontSize: '0.8rem', fontFamily: 'var(--font-mono)' }}>{row.math}</td>
</tr>
))}
</tbody>
</table>
<strong>Decision:</strong> The Bevy game client uses hand-rolled kinematic movement and distance-based collision.
We do <strong>not</strong> use a physics engine — movement is point-and-shoot, not rigid bodies.
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
<h4 style={{ color: 'var(--cyan)' }}>Movement Module Layout</h4>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.7 }}>
<span style={{ color: 'var(--purple)' }}>apps/game/src/gameplay/movement/</span><br/>
&nbsp;&nbsp;mod.rs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// MovementPlugin</span><br/>
&nbsp;&nbsp;components.rs&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// Velocity, MaxSpeed, TurnRate, Drag</span><br/>
&nbsp;&nbsp;kinematic.rs&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// move + drag + clamp systems</span><br/>
&nbsp;&nbsp;orbit.rs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// Orbit component + update_orbits</span>
</div>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 'var(--sp-3) 0 0 0' }}>
All systems run on Bevys <code>Time&lt;Fixed&gt;</code> schedule for stable, deterministic ticks that
align cleanly with SpacetimeDB updates.
</p>
<h4 style={{ color: 'var(--cyan)' }}>Why No Physics Engine</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li>Native to FTL/Windward style — point-and-shoot, not rigid bodies</li>
<li>Arcade feel — drag, snap turns, no unwanted inertia</li>
<li>Zero compile time and binary size overhead</li>
<li>Trivial determinism — linear math, no solver iterations</li>
<li>Smaller bug surface — ~50 lines you wrote vs tens of thousands you didn't</li>
</ul>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
<h4 style={{ color: 'var(--green)' }}>Physics Module Layout</h4>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.7 }}>
<span style={{ color: 'var(--purple)' }}>apps/game/src/gameplay/physics/</span><br/>
&nbsp;&nbsp;mod.rs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// PhysicsPlugin</span><br/>
&nbsp;&nbsp;geometry.rs&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// ray_vs_circle, overlaps, separate</span><br/>
&nbsp;&nbsp;broad_phase.rs&nbsp;<span style={{ color: 'var(--muted)' }}>// (later) uniform grid</span><br/>
&nbsp;&nbsp;systems.rs&nbsp;&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// projectile_hits, ship_separation</span>
</div>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 'var(--sp-3) 0 0 0' }}>
Geometry functions are pure <code>pub fn</code>s. Systems are thin Bevy wrappers that query entities and
call them. No third-party deps.
</p>
<h4 style={{ color: 'var(--green)' }}>What Physics Means Here</h4>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
<li>Ship traversal: <code>pos += velocity * dt</code></li>
<li>Projectiles vs ships: Distance check <code>a b &lt; r_a + r_b</code></li>
<li>Targeting: Ray-circle intersection</li>
<li>Separation: Push apart by overlap distance</li>
<li>Triggers: Distance check for docking/proximity</li>
</ul>
</div>
</div>
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Scaling Tiers</h3>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr><th>Concurrent Entities</th><th>Strategy</th><th>Estimated Cost</th></tr>
</thead>
<tbody>
<tr><td>{'< 500'}</td><td>Iterate all entities, linear scan.</td><td style={{ color: 'var(--fg-dim)' }}>Trivial no broad-phase needed.</td></tr>
<tr><td>500 10,000</td><td>Flat array of (Entity, Vec2, radius), still linear.</td><td style={{ color: 'var(--fg-dim)' }}>Cache-friendly, single microsecond per query.</td></tr>
<tr><td>{'> 10,000'}</td><td>Add uniform grid or quadtree (50 LOC).</td><td style={{ color: 'var(--fg-dim)' }}>Out of scope for FTL-style combat density.</td></tr>
</tbody>
</table>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Escape hatch when to reconsider:</strong> If destructible ship chunks that tumble, rotate, and stack
become a core visual, Rapier can be introduced <em>only for debris entities</em> while ships and projectiles
stay kinematic. This is additive: the custom movement/collision code does not need to be rewritten. The three
things a real solver would buy stacking, joints, continuous CCD are explicitly out of scope for arcade
space combat.
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
<strong>Network determinism note:</strong> Because movement and collision are pure functions of position,
velocity, and dt, every client given the same inputs produces the same outputs. This makes client-side
prediction and rollback against SpacetimeDB straightforward a physics engines solver would make this
property very difficult to guarantee.
</div>
</div>
);
}

View File

@@ -7,10 +7,10 @@ export function BackendPage() {
return (
<div className="mx-auto max-w-content">
<h1 style={{ marginBottom: '8px' }}>SpacetimeDB Backend Model</h1>
<h1 style={{ marginBottom: '8px' }}>Game State & Data Model</h1>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '680px' }}>
The backend holds persistent, authoritative state and exposes server-side reducers for game
actions. Clients subscribe to the rows they need and update reactively.
The game client maintains persistent, authoritative state locally. Data structures define
all game entities and their relationships. State updates are processed immediately and saved to local storage.
</p>
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-6)' }}>

View File

@@ -0,0 +1,958 @@
// @ts-nocheck
import * as React from 'react';
const LAST_UPDATED = '2025-06-16';
const STORAGE_KEY = 'void-nav-kanban-data';
type Column = 'done' | 'in-progress' | 'todo' | 'backlog';
interface FeatureCard {
id: string;
title: string;
description: string;
details: string;
category: string;
files: string;
notes?: string;
comments?: Comment[];
tags?: string[];
status?: string;
}
interface Comment {
id: string;
text: string;
timestamp: number;
author: string;
}
interface ColumnData {
title: string;
color: string;
bgColor: string;
items: FeatureCard[];
}
// Initial static data
const INITIAL_COLUMN_DATA: Record<Column, ColumnData> = {
done: {
title: 'DONE',
color: 'var(--green)',
bgColor: 'rgba(34,197,94,0.05)',
items: [
{
id: 'G1',
title: 'Galaxy Generation & Viewer',
description: 'Complete procedural spiral galaxy with core cluster, disk layers, and beam structures. Interactive 3D viewer with star selection, parameter controls, and system connections.',
details: 'Fully functional: generates star systems with faction distribution, nearest-neighbor connections, and POI contents. Control panel allows real-time parameter adjustment.',
category: 'Onboarding',
files: 'apps/game/src/gameplay/galaxy/mod.rs, apps/game/src/gameplay/galaxy/contents.rs',
},
{
id: 'G2',
title: 'Character Creation',
description: 'Bannerlord-style character generation with name selection, 4 origin archetypes, 3 ship choices, and 5-step backstory questionnaire with stat bonuses.',
details: 'Fully functional: complete UI with scrollable panels, stat calculation display, and character draft persistence for campaign.',
category: 'Onboarding',
files: 'apps/game/src/gameplay/character_creation/mod.rs',
},
{
id: 'G3',
title: 'Starting Base Selection',
description: 'System selection from outer-galaxy candidates with 3D visualization, POI display, and interactive camera controls.',
details: 'Fully functional: shows candidate systems from generated galaxy, with camera focus on selection and system contents preview.',
category: 'Onboarding',
files: 'apps/game/src/gameplay/starting_base/mod.rs',
},
{
id: 'S1',
title: 'Game State Management',
description: 'AppState with clean transitions: MainMenu, Galaxy, CharacterCreation, StartingBaseSelection, InGame, Options.',
details: 'Fully functional: state system works properly with OnEnter/OnExit/Update schedules for each game phase.',
category: 'Core',
files: 'apps/game/src/state.rs',
},
{
id: 'S2',
title: 'Campaign Persistence',
description: 'Galaxy draft, character draft, and starting base selection persisted through game flow.',
details: 'Fully functional: Resources carry data through state transitions. New game properly clears previous state.',
category: 'Core',
files: 'apps/game/src/gameplay/campaign.rs',
},
{
id: 'C1',
title: 'Camera System',
description: 'Three camera modes: Orbit (for galaxy inspection), Follow (for flight), Cinematic (for docked views).',
details: 'Fully functional: orbit camera with mouse controls, camera transitions between modes, proper target following.',
category: 'Core',
files: 'apps/game/src/camera.rs',
},
{
id: 'P1',
title: 'POI Data Structures',
description: 'Complete POI type system: 7 planet types, asteroid belts, stations, stargates, anomalies, gas clouds.',
details: 'Fully functional: POI generation with orbital mechanics, faction-based biasing, and system context awareness.',
category: 'Content',
files: 'apps/game/src/gameplay/galaxy/poi.rs, apps/game/src/gameplay/galaxy/contents.rs',
},
],
},
'in-progress': {
title: 'IN PROGRESS',
color: 'var(--accent)',
bgColor: 'rgba(240,160,48,0.05)',
items: [
{
id: 'I1',
title: 'In-System Scene Framework',
description: 'Scene setup with star, POIs, and player ship. Docked/Flight state management exists.',
details: 'Framework exists: scene spawns correctly, state transitions work. But UI is commented out and most gameplay features are stubs.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/mod.rs, apps/game/src/gameplay/in_system/scene.rs',
notes: 'UI systems removed/commented. No flight HUD visible to player.',
},
{
id: 'I2',
title: 'Target Selection',
description: 'Click-to-select POIs (stations, asteroid belts) with selection tracking.',
details: 'Framework exists: target selection works, but no gameplay uses the target yet. No distance display or spatial grouping.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/target.rs',
notes: 'Selection system works but no gameplay features depend on it yet.',
},
{
id: 'I3',
title: 'Operations Framework',
description: 'Timed operations system with undock (3s), travel (5s). Framework for docking (4s) and mining (8s).',
details: 'Partial: undock and travel work. Docking and mining are skeleton operations marked TODO. No actual gameplay consequences.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/operations.rs, apps/game/src/gameplay/in_system/flight.rs',
notes: 'Only undock and approach are implemented. Docking and mining are TODO.',
},
{
id: 'M1',
title: 'Basic Movement',
description: 'Click-to-move navigation with kinematic steering and velocity integration.',
details: 'Partial: ship moves to target position, but no orbital mechanics, no physics-based movement, no collision avoidance.',
category: 'In-System',
files: 'apps/game/src/gameplay/movement/mod.rs',
notes: 'Basic steering works. No actual gameplay uses movement yet.',
},
{
id: 'U1',
title: 'Basic UI Shell',
description: 'Main menu, galaxy control panel, info panels. Foundation for UI exists.',
details: 'Partial: main menu and galaxy UI work. In-game flight UI is commented out. No station mode panel swap.',
category: 'UI',
files: 'apps/game/src/ui/',
notes: 'Flight HUD and station UI are removed/commented in code.',
},
],
},
todo: {
title: 'TODO',
color: 'var(--red)',
bgColor: 'rgba(239,68,68,0.05)',
items: [
{
id: 'I4',
title: 'Complete In-System Gameplay',
description: 'Docking operation, mining operation with output, cargo collection, station interactions.',
details: 'Missing: docking only undocks, mining has no resource output. No station services, no refueling, no repairs.',
category: 'In-System',
files: 'apps/game/src/gameplay/in_system/operations.rs',
notes: 'Docks and mines marked as TODO in operations.rs.',
},
{
id: 'U2',
title: 'Flight HUD / In-Game UI',
description: 'Target reticle, ship status display, operation progress bars, contextual action buttons.',
details: 'Missing: all in-game UI is commented out. Player has no visual feedback during flight.',
category: 'UI',
files: 'apps/game/src/gameplay/in_system/',
notes: 'UI setup and update systems are commented out in mod.rs.',
},
{
id: 'M2',
title: 'Star Map Plugin',
description: 'Galaxy-scale navigation showing all systems, hyperspace lanes, and current position.',
details: 'Missing: plugin structure exists but implementation is empty. No camera, no rendering, no interaction.',
category: 'Navigation',
files: 'apps/game/src/gameplay/star_map/mod.rs',
notes: 'File contains only TODO comments and empty plugin struct.',
},
{
id: 'P2',
title: 'Physics & Collision',
description: 'Mass-based interactions, collision detection between ships and objects, spatial queries.',
details: 'Missing: only basic geometry primitives exist. No collision system, no physics simulation.',
category: 'Core',
files: 'apps/game/src/gameplay/physics/mod.rs',
notes: 'Module exports marker components only. No actual physics systems.',
},
{
id: 'R1',
title: 'Resource System',
description: 'Ore types, cargo inventory, resource collection during mining.',
details: 'Missing: no resource types defined, no inventory system, no cargo hold.',
category: 'Economy',
files: 'N/A',
notes: 'No resource or inventory modules exist in codebase.',
},
{
id: 'E1',
title: 'Economy System',
description: 'Credits, market prices, trading, station markets.',
details: 'Missing: no economy simulation, no pricing, no trade mechanics.',
category: 'Economy',
files: 'N/A',
notes: 'No economy modules exist in codebase.',
},
{
id: 'C2',
title: 'Ship Components',
description: 'Shields, armor, hull, capacitor, power grid, CPU.',
details: 'Missing: ships have no stats beyond position. No component systems.',
category: 'Ships',
files: 'N/A',
notes: 'Ship is just a mesh with position. No stats or systems.',
},
],
},
backlog: {
title: 'BACKLOG',
color: 'var(--muted)',
bgColor: 'var(--surface-raised)',
items: [
{
id: 'C3',
title: 'Combat System',
description: 'Weapons, damage resolution, shield/armor/hull pools, combat AI, target locking.',
details: 'Not started: no weapon systems, no damage calculations, no combat state. FTL-style auto-combat planned.',
category: 'Combat',
files: 'N/A',
notes: 'No combat modules exist. Design specifies FTL-style power allocation combat.',
},
{
id: 'S3',
title: 'Multiple Ship Types',
description: 'Frigate, hauler, scout, warship, battleship classes with different stats.',
details: 'Not started: only generic starter ship mesh exists. No ship catalog or stat variations.',
category: 'Ships',
files: 'N/A',
notes: 'Single cone+box+sphere ship. No classes or variations.',
},
{
id: 'F1',
title: 'Module Fitting',
description: 'High/Med/Low slots, CPU/PG constraints, module catalog, fitting screen.',
details: 'Not started: no fitting system. Design specifies full fitting with drag-and-drop.',
category: 'Ships',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/FittingDemo.tsx',
},
{
id: 'N1',
title: 'NPC Ships & AI',
description: 'NPC spawning, behavior state machines, ambient traffic, hostile pirates.',
details: 'Not started: no NPC entities, no AI systems. Design describes full NPC AI.',
category: 'NPCs',
files: 'N/A',
notes: 'No NPC modules exist in codebase.',
},
{
id: 'Q1',
title: 'Quest & Mission System',
description: 'NPC agents, mission templates (kill, courier, mining), active mission tracking.',
details: 'Not started: no quest system. Design specifies 6 mission types with rewards.',
category: 'Content',
files: 'N/A',
notes: 'Reference: design docs describe faction quests and mission templates.',
},
{
id: 'A1',
title: 'Faction System',
description: 'Faction standing, loyalty points, reputation effects, faction-controlled space.',
details: 'Not started: factions are colors only. No standing or reputation tracking.',
category: 'Social',
files: 'N/A',
notes: 'Factions exist as labels only. No gameplay effects.',
},
{
id: 'W1',
title: 'Dynamic World Events',
description: 'Random events, anomalies, faction conflicts, world state changes.',
details: 'Not started: no event system. Design describes 6 event categories.',
category: 'Content',
files: 'N/A',
notes: 'No world event modules exist.',
},
{
id: 'PR1',
title: 'Skill & Progression',
description: 'XP awards, skill catalog (Mining, Industry, Trade, Gunnery, Navigation), level 0-5.',
details: 'Not started: no XP or skills. Design specifies 5 skills with tier bonuses.',
category: 'Progression',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ProgressionDemo.tsx',
},
{
id: 'MF1',
title: 'Manufacturing',
description: 'Blueprints, production jobs, material requirements, output queues.',
details: 'Not started: no manufacturing. Design: Ore→Mineral→Component→Module→Ship chain.',
category: 'Industry',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/RefiningDemo.tsx',
},
{
id: 'Z1',
title: 'Zora Ship AI',
description: 'AI companion with personality, soul depth, tier progression (0-5).',
details: 'Not started: no Zora implementation. Design specifies full AI companion system.',
category: 'Ships',
files: 'N/A',
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ZoraDemo.tsx',
},
{
id: 'ST1.1',
title: 'Event Logging System',
description: 'Track all player actions (mining, trading, combat, exploration) as structured events.',
details: 'Missing: no event logging exists. Need event types, event bus, persistence.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/events.rs',
notes: 'Foundation for narrative synthesis. Must capture all player actions.',
},
{
id: 'ST1.2',
title: 'Narrative Synthesis Engine',
description: 'LLM-based system that weaves events into coherent narrative text.',
details: 'Missing: no narrative generation. Need event aggregation, story templates, LLM integration.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/synthesis.rs',
notes: 'Core differentiator. Use Claude API for story generation.',
},
{
id: 'ST1.3',
title: 'Campaign History Tracking',
description: 'Persistent story log with chapter divisions and key moments.',
details: 'Missing: no campaign history. Need timeline, chapter detection, milestone tracking.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/history.rs',
notes: 'Player should access full campaign story at any time.',
},
{
id: 'ST1.4',
title: 'Story Log UI',
description: 'In-game interface for reading campaign narrative with chapter navigation.',
details: 'Missing: no UI for narrative. Need story viewer, chapter list, search.',
category: 'Narrative',
files: 'apps/game/src/gameplay/narrative/ui.rs',
notes: 'Integrates with existing UI system. Should support export/save.',
},
{
id: 'T1',
title: 'Tutorial & Onboarding',
description: 'Guided mission sequence, skip option, tutorial hints, objective tracking.',
details: 'Not started: no tutorial system. New players have no guidance.',
category: 'UX',
files: 'N/A',
notes: 'Design specifies 7-objective tutorial chain.',
},
{
id: 'MP1',
title: 'Multiplayer / Backend',
description: 'Server auth, multiplayer sessions, cloud persistence.',
details: 'Removed: multiplayer services deleted. Game is now single-player only with local persistence.',
category: 'Infrastructure',
files: 'Removed (services/spacetimedb, services/auth)',
notes: 'Multiplayer infrastructure has been removed to focus on single-player experience.',
},
],
},
};
const CATEGORIES = ['Onboarding', 'Core', 'Content', 'In-System', 'UI', 'Navigation', 'Economy', 'Ships', 'Combat', 'NPCs', 'Social', 'Progression', 'Industry', 'Narrative', 'UX', 'Infrastructure'];
export function KanbanBoardPage() {
// Load saved data from localStorage or use initial data
const [columnData, setColumnData] = React.useState<Record<Column, ColumnData>>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('Failed to parse saved kanban data:', e);
return INITIAL_COLUMN_DATA;
}
}
return INITIAL_COLUMN_DATA;
});
// Save to localStorage whenever columnData changes
React.useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnData));
}, [columnData]);
// Expanded card state
const [expandedCard, setExpandedCard] = React.useState<string | null>(null);
// Comment input state
const [commentInputs, setCommentInputs] = React.useState<Record<string, string>>({});
// New tag input state
const [tagInputs, setTagInputs] = React.useState<Record<string, string>>({});
const counts = React.useMemo(() => {
return {
done: columnData.done.items.length,
'in-progress': columnData['in-progress'].items.length,
todo: columnData.todo.items.length,
backlog: columnData.backlog.items.length,
total: Object.values(columnData).reduce((sum, col) => sum + col.items.length, 0),
};
}, [columnData]);
const completionRate = React.useMemo(() => {
const weighted = counts.done + (counts['in-progress'] * 0.3);
return Math.round((weighted / counts.total) * 100);
}, [counts]);
// Move card to different column
const moveCard = (cardId: string, fromColumn: Column, toColumn: Column) => {
setColumnData(prev => {
const newPrev = { ...prev };
const cardIndex = newPrev[fromColumn].items.findIndex(c => c.id === cardId);
if (cardIndex === -1) return prev;
const [card] = newPrev[fromColumn].items.splice(cardIndex, 1);
newPrev[toColumn].items.push(card);
return newPrev;
});
};
// Add comment to card
const addComment = (cardId: string, text: string) => {
if (!text.trim()) return;
setColumnData(prev => {
const newPrev = { ...prev };
for (const col of Object.keys(newPrev) as Column[]) {
const card = newPrev[col].items.find(c => c.id === cardId);
if (card) {
if (!card.comments) card.comments = [];
card.comments.push({
id: `comment-${Date.now()}`,
text: text.trim(),
timestamp: Date.now(),
author: 'User',
});
break;
}
}
return newPrev;
});
setCommentInputs(prev => ({ ...prev, [cardId]: '' }));
};
// Add tag to card
const addTag = (cardId: string, tag: string) => {
if (!tag.trim()) return;
setColumnData(prev => {
const newPrev = { ...prev };
for (const col of Object.keys(newPrev) as Column[]) {
const card = newPrev[col].items.find(c => c.id === cardId);
if (card) {
if (!card.tags) card.tags = [];
if (!card.tags.includes(tag.trim())) {
card.tags.push(tag.trim());
}
break;
}
}
return newPrev;
});
setTagInputs(prev => ({ ...prev, [cardId]: '' }));
};
// Remove tag from card
const removeTag = (cardId: string, tagToRemove: string) => {
setColumnData(prev => {
const newPrev = { ...prev };
for (const col of Object.keys(newPrev) as Column[]) {
const card = newPrev[col].items.find(c => c.id === cardId);
if (card && card.tags) {
card.tags = card.tags.filter(t => t !== tagToRemove);
break;
}
}
return newPrev;
});
};
// Reset to initial data
const resetData = () => {
if (confirm('Reset all kanban data to defaults? This will delete all comments and tags.')) {
setColumnData(INITIAL_COLUMN_DATA);
localStorage.removeItem(STORAGE_KEY);
}
};
// Export data
const exportData = () => {
const dataStr = JSON.stringify(columnData, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kanban-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="mx-auto max-w-content">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h1 style={{ marginBottom: '0' }}>Implementation Board</h1>
<div style={{ display: 'flex', gap: 'var(--sp-2)' }}>
<button
onClick={exportData}
style={{
padding: '4px 12px',
fontSize: '0.8rem',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Export Data
</button>
<button
onClick={resetData}
style={{
padding: '4px 12px',
fontSize: '0.8rem',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Reset Data
</button>
</div>
</div>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
Interactive kanban board. Updated on {LAST_UPDATED}.
Click cards to expand for comments and tags. Drag cards or use dropdowns to change status.
Data persists in localStorage.
</p>
{/* Stats overview */}
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-4" style={{ marginTop: 'var(--sp-5)' }}>
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--green)' }}>
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--green)' }}>{counts.done}</div>
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">Done</div>
</div>
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--accent)' }}>
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--accent)' }}>{counts['in-progress']}</div>
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">In Progress</div>
</div>
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--red)' }}>
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--red)' }}>{counts.todo}</div>
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">Todo</div>
</div>
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--muted)' }}>
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--muted)' }}>{counts.backlog}</div>
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">Backlog</div>
</div>
</div>
{/* Overall progress */}
<div className="mb-6 rounded-lg border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3">
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)', marginBottom: 'var(--sp-2)' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--fg)', fontWeight: 500 }}>Overall Implementation Progress</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', color: 'var(--fg-dim)' }}>
{completionRate}%
</span>
</div>
<div style={{
width: '100%',
height: '8px',
borderRadius: '4px',
background: 'var(--surface-raised)',
overflow: 'hidden',
}}>
<div style={{
width: `${completionRate}%`,
height: '100%',
borderRadius: '4px',
background: completionRate > 50 ? 'var(--green)' : completionRate > 25 ? 'var(--accent)' : 'var(--red)',
}} />
</div>
</div>
{/* Critical insight banner */}
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-amber/30 bg-amber/10 text-amber" style={{ maxWidth: '720px' }}>
<strong>Current State:</strong> The game has a complete <em>onboarding experience</em> (galaxy character base) but lacks a playable game loop.
The core systems (mining, trading, combat, economy) are frameworks without gameplay. Most in-System UI is commented out.
This reflects a focus on foundational architecture before gameplay implementation.
</div>
{/* Kanban board */}
<div style={{ marginTop: 'var(--sp-6)' }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(280px, 1fr))',
gap: 'var(--sp-4)',
overflowX: 'auto',
paddingBottom: 'var(--sp-4)',
}}>
{(Object.keys(columnData) as Column[]).map((columnKey) => {
const column = columnData[columnKey];
return (
<div
key={columnKey}
style={{
background: column.bgColor,
borderRadius: 'var(--radius-md)',
padding: 'var(--sp-3)',
border: '1px solid var(--border)',
minHeight: '400px',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Column header */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-2)',
marginBottom: 'var(--sp-3)',
paddingBottom: 'var(--sp-2)',
borderBottom: '2px solid var(--border)',
}}>
<div style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: column.color,
boxShadow: `0 0 8px ${column.color}66`,
}} />
<h3 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600, color: 'var(--fg)' }}>
{column.title}
</h3>
<span style={{
marginLeft: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
color: 'var(--muted)',
background: 'var(--surface)',
padding: '2px 8px',
borderRadius: 'var(--radius-pill)',
}}>
{column.items.length}
</span>
</div>
{/* Cards */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', flex: 1 }}>
{column.items.map((item) => {
const isExpanded = expandedCard === item.id;
const hasComments = item.comments && item.comments.length > 0;
const hasTags = item.tags && item.tags.length > 0;
return (
<div
key={item.id}
style={{
background: 'var(--surface)',
border: `1px solid ${isExpanded ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--sp-3)',
cursor: 'pointer',
transition: 'all 0.15s ease',
boxShadow: isExpanded ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
}}
onClick={() => setExpandedCard(isExpanded ? null : item.id)}
className="hover:shadow-sm"
>
{/* Card header */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', marginBottom: 'var(--sp-2)' }}>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
color: 'var(--muted)',
background: 'var(--surface-raised)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
{item.id}
</span>
<span style={{
fontSize: '0.7rem',
color: 'var(--fg-dim)',
background: 'rgba(100,100,100,0.1)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
{item.category}
</span>
<span style={{ marginLeft: 'auto', fontSize: '0.7rem' }}>
{hasComments && `💬 ${item.comments!.length} `}
{hasTags && `🏷️ ${item.tags!.length}`}
</span>
</div>
{/* Card title */}
<h4 style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.9rem', fontWeight: 500, color: 'var(--fg)' }}>
{item.title}
</h4>
{/* Card description */}
<p style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: '1.4' }}>
{item.description}
</p>
{/* Card details */}
<p style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.75rem', color: 'var(--fg-dim)', lineHeight: '1.4', fontStyle: 'italic' }}>
{item.details}
</p>
{/* Card footer */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-1)' }}>
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
📁 {item.files}
</div>
{item.notes && (
<div style={{
fontSize: '0.7rem',
color: 'var(--accent)',
padding: '4px 8px',
background: 'rgba(240,160,48,0.08)',
borderRadius: 'var(--radius-sm)',
borderLeft: '2px solid var(--accent)',
}}>
💡 {item.notes}
</div>
)}
{/* Tags display */}
{hasTags && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }}>
{item.tags!.map(tag => (
<span
key={tag}
onClick={(e) => {
e.stopPropagation();
removeTag(item.id, tag);
}}
style={{
fontSize: '0.65rem',
padding: '2px 6px',
background: 'rgba(100,150,255,0.1)',
border: '1px solid rgba(100,150,255,0.3)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
title="Click to remove tag"
>
🏷 {tag}
</span>
))}
</div>
)}
</div>
{/* Expanded section */}
{isExpanded && (
<div style={{ marginTop: 'var(--sp-3)', paddingTop: 'var(--sp-3)', borderTop: '1px solid var(--border)' }}>
{/* Status change dropdown */}
<div style={{ marginBottom: 'var(--sp-3)' }}>
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', marginRight: '8px' }}>Move to:</label>
<select
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const newColumn = e.target.value as Column;
if (newColumn !== columnKey) {
moveCard(item.id, columnKey, newColumn);
}
}}
value={columnKey}
style={{
padding: '4px 8px',
fontSize: '0.8rem',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--fg)',
}}
>
<option value="done">Done</option>
<option value="in-progress">In Progress</option>
<option value="todo">Todo</option>
<option value="backlog">Backlog</option>
</select>
</div>
{/* Add tag */}
<div style={{ marginBottom: 'var(--sp-3)', display: 'flex', gap: '8px' }}>
<input
type="text"
placeholder="Add tag..."
value={tagInputs[item.id] || ''}
onChange={(e) => setTagInputs(prev => ({ ...prev, [item.id]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
addTag(item.id, tagInputs[item.id] || '');
}
}}
onClick={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '4px 8px',
fontSize: '0.8rem',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--fg)',
}}
/>
<button
onClick={(e) => {
e.stopPropagation();
addTag(item.id, tagInputs[item.id] || '');
}}
style={{
padding: '4px 12px',
fontSize: '0.8rem',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Add Tag
</button>
</div>
{/* Comments section */}
<div>
<h5 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Comments</h5>
{/* Existing comments */}
{hasComments ? (
<div style={{ marginBottom: 'var(--sp-2)', maxHeight: '200px', overflowY: 'auto' }}>
{item.comments!.map(comment => (
<div
key={comment.id}
style={{
padding: '8px',
marginBottom: '8px',
background: 'var(--surface-raised)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontWeight: 500, color: 'var(--fg)' }}>{comment.author}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--muted)' }}>
{new Date(comment.timestamp).toLocaleString()}
</span>
</div>
<div style={{ color: 'var(--fg-dim)', lineHeight: '1.4' }}>{comment.text}</div>
</div>
))}
</div>
) : (
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', fontStyle: 'italic' }}>No comments yet</p>
)}
{/* Add comment */}
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
placeholder="Add a comment..."
value={commentInputs[item.id] || ''}
onChange={(e) => setCommentInputs(prev => ({ ...prev, [item.id]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
addComment(item.id, commentInputs[item.id] || '');
}
}}
onClick={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '8px',
fontSize: '0.8rem',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--fg)',
}}
/>
<button
onClick={(e) => {
e.stopPropagation();
addComment(item.id, commentInputs[item.id] || '');
}}
style={{
padding: '8px 16px',
fontSize: '0.8rem',
background: 'var(--accent)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--surface)',
}}
>
Post
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Legend */}
<div style={{ marginTop: 'var(--sp-6)', padding: 'var(--sp-3)', background: 'var(--surface-raised)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Categories</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sp-2)', fontSize: '0.75rem', color: 'var(--fg-dim)' }}>
{CATEGORIES.map(cat => (
<span key={cat} style={{
padding: '2px 8px',
background: 'var(--surface)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
}}>
{cat}
</span>
))}
</div>
</div>
{/* Notes */}
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginTop: 'var(--sp-6)', maxWidth: '720px' }}>
<strong>Implementation note:</strong> This board reflects code analysis of <code>apps/game/</code> as of {LAST_UPDATED}.
"Done" means fully functional. "In Progress" means framework exists but gameplay incomplete. "Todo" means next priority items.
See <a href="/docs/roadmap" style={{ color: 'var(--cyan)', textDecoration: 'underline' }}>Roadmap</a> for planned phases and
<a href="/docs/gameplay" style={{ color: 'var(--cyan)', textDecoration: 'underline' }}> Gameplay</a> for design details.
</div>
</div>
);
}

View File

@@ -4,36 +4,36 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export function OverviewPage() {
return (
<div className="mx-auto max-w-content">
<h1 style={{ marginBottom: '8px' }}>Single-Player Space Exploration RPG</h1>
<h1 style={{ marginBottom: '8px' }}>Narrative-Driven Space Exploration Saga</h1>
<p style={{ color: 'var(--fg-dim)', fontSize: '1.1rem', maxWidth: '680px', marginBottom: 'var(--sp-6)' }}>
Trade, quest, fight, or simply drift and enjoy the ambiance. A charming <strong style={{ color: 'var(--fg-bright)' }}>single-player open-world space RPG</strong> inspired
by the freedom and charm of Windward Horizon set in a <strong style={{ color: 'var(--fg-bright)' }}>procedurally generated galaxy</strong> where
every campaign is unique. Explore sectors, trade between stations, take on faction quests, mine asteroids,
or simply cruise the void. Combat is <strong style={{ color: 'var(--fg-bright)' }}>FTL-style tactical resource management</strong>
no dogfighting. You are the captain who decides <em>what</em> to power and <em>when</em>, not the pilot who dodges.
The architecture supports optional co-op servers in the future, but the core experience is your solo adventure.
Every player becomes the author of their own space opera mini-series. A <strong style={{ color: 'var(--fg-bright)' }}>single-player narrative experience</strong>
inspired by the open-world freedom of <strong style={{ color: 'var(--fg-bright)' }}>Windward</strong>, the strategic depth of <strong style={{ color: 'var(--fg-bright)' }}>Stellaris</strong>,
and AI-generated storytelling reminiscent of <strong style={{ color: 'var(--fg-bright)' }}>The Templin Institute</strong>. Explore procedurally generated galaxies,
engage in diplomacy and commerce, wage wars or broker peace and watch as the AI Story Director weaves your choices
into an evolving narrative. V1 focuses on emergent storytelling, with future updates generating episodic mini-series
from your campaign history.
</p>
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4">
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--purple)' }}>Story</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">AI Director</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--accent)' }}>Explore</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Core Pillar</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--cyan)' }}>SpacetimeDB</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Backend</div>
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--cyan)' }}>Diplomacy</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Trade & Talk</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--green)' }}>FTL Combat</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Combat Model</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--purple)' }}>Dynamic</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">World Model</div>
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--green)' }}>Dynamic</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Living Galaxy</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--red)' }}>Solo</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Adventure</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Your Saga</div>
</div>
</div>
@@ -43,10 +43,11 @@ export function OverviewPage() {
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan">
<strong>Primary inspiration:</strong> Windward Horizon's charm and freedom in a space setting. The game is an
<em> open-world exploration RPG</em> where the player charts their own path — trader, mercenary, explorer, or drifter.
<strong>Primary inspirations:</strong> Windward's open-world freedom, Stellaris's strategic diplomacy and commerce depth, and The Templin Institute's narrative-driven storytelling style.
The game is a <em>story-driven exploration saga</em> where the player authors their own mini-series — trader, diplomat, warmaker, or explorer.
The 3D scene is the primary viewport for spatial immersion. Panels and UI overlays handle station services, fitting, and market.
Combat uses FTL-style power management — no dogfighting. The galaxy is procedurally generated each campaign.
Combat, when it occurs, uses FTL-style power management — no dogfighting. The galaxy is procedurally generated each campaign.
The <strong>AI Story Director</strong> weaves your choices into an ongoing narrative.
</div>
<h3>Core Pillars</h3>
@@ -60,34 +61,39 @@ export function OverviewPage() {
</thead>
<tbody>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Open-world exploration</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Procedurally generated galaxy with unique sectors every campaign. Points of interest: stations, asteroid belts, anomalies, derelicts, signal sources. <strong style={{ color: 'var(--accent)' }}>Explore to discover resources, quests, and hidden stories.</strong> The map reveals as you travel — fog of war drives the urge to see what's out there.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Single system with POIs, procedural generation seed</span></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-purple/25 bg-purple/8 text-purple">AI Story Director</span></td>
<td style={{ color: 'var(--fg-dim)' }}>NEW PRIMARY — Emergent narrative generation tracks your choices, alliances, conflicts, and discoveries. The AI weaves your actions into an ongoing saga. <strong style={{ color: 'var(--accent)' }}>In V1: real-time emergent storytelling. Future: episodic mini-series generation from campaign history.</strong> Every decision feeds the narrative engine.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Basic event tracking + narrative synthesis</span></td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Trade & commerce</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Buy low at one station, sell high at another. Regional price differences driven by supply/demand and faction needs. <strong style={{ color: 'var(--accent)' }}>Trade routes are the economic backbone.</strong> Towns need resources to grow supplying them leads to prosperity.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>NPC economy, regional prices, trade routes</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional player-to-player market on co-op servers</span></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Exploration & Discovery</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Procedurally generated galaxy with fog of war. Discover anomalies, derelicts, new factions. <strong style={{ color: 'var(--accent)' }}>Every discovery becomes part of your story.</strong> The map reveals as you travel — fog of war drives the urge to see what's out there.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>POIs, procedural generation, discovery logging</span></td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">FTL-style combat</span></td>
<td style={{ color: 'var(--fg-dim)' }}>No dogfighting. Click hostile ship auto-engages manage <strong style={{ color: 'var(--fg)' }}>reactor power allocation</strong> (FTL-style) between Weapons/Shields/Engines/Aux. The skill is in <strong style={{ color: 'var(--fg)' }}>what</strong> you power, not <strong style={{ color: 'var(--fg)' }}>how fast</strong> you click. Combat creates consequences ship damage, loot, insurance but it's not the focus.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Core combat & power management</span></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Trade & Diplomacy</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Stellaris-inspired economic and political systems. Supply factions, broker alliances, negotiate borders. <strong style={{ color: 'var(--accent)' }}>Commerce and conversation are equally viable paths.</strong> Trade routes and diplomatic relations shape your narrative.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Regional prices, faction standings, basic diplomacy</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional player-to-player market on co-op servers</span></td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-purple/25 bg-purple/8 text-purple">Ship customization</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Build the ship that suits your style — tough juggernaut that can take a beating, nimble scout that rains fire from afar, or cargo hauler keeping trade routes supplied. Swap modules to change role. <strong style={{ color: 'var(--fg)' }}>Multiple ship classes from nimble interceptors to powerful cruisers.</strong></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-purple/25 bg-purple/8 text-purple">Ship Customization</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Your ship is your narrative vessel. Customize for trade, exploration, or conflict. Swap modules to change role. <strong style={{ color: 'var(--fg)' }}>Multiple ship classes from nimble interceptors to powerful cruisers.</strong></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Basic fitting, multiple ship classes</span></td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Dynamic world & factions</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Stations and provinces need resources to grow. Supplying them leads to prosperity. Faction leaders involve you in their agendas — helping one may hinder another. <strong style={{ color: 'var(--fg)' }}>Enough support can let a faction leader take control of a province without a single shot fired.</strong> A living galaxy that evolves through player actions and world events.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Strategic Conflict</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Combat exists but is not the focus. When conflict occurs, it's FTL-style tactical resource management — a dramatic beat in your story, not the gameplay loop. Click hostile → ship auto-engages → manage <strong style={{ color: 'var(--fg)' }}>reactor power allocation</strong> (FTL-style) between Weapons/Shields/Engines/Aux. <strong style={{ color: 'var(--fg-dim)' }}>Secondary feature, not a core pillar.</strong></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Secondary</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Basic combat system, minimized emphasis</span></td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Dynamic Galaxy</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Your actions shape both the world AND the story. Stations and provinces need resources to grow. Faction leaders involve you in their agendas — helping one may hinder another. <strong style={{ color: 'var(--fg)' }}>Enough support can let a faction leader take control without a single shot fired.</strong> A living galaxy that evolves through player choices and world events.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Factions, NPC agents, quests, territory influence</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Living galaxy with world agents on co-op servers</span></td>
</tr>
<tr>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Solo Adventure</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Your personal galaxy to explore at your own pace. The game is <strong style={{ color: 'var(--fg)' }}>designed as a solo adventure first</strong> — every system works for one captain. The SpacetimeDB architecture naturally supports optional co-op servers later, but that's an enhancement, not the core.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Solo adventure with full game loop</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional co-op servers</span></td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Solo Saga</span></td>
<td style={{ color: 'var(--fg-dim)' }}>Your personal galaxy to explore at your own pace. The game is <strong style={{ color: 'var(--fg)' }}>designed as a solo adventure first</strong> — every system works for one captain. <strong style={{ color: 'var(--fg)' }}>Your story, your pace.</strong> Future multiplayer servers will be an optional enhancement, not the core.</td>
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Solo adventure with full narrative loop</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional co-op servers</span></td>
</tr>
</tbody>
</table>
@@ -98,35 +104,49 @@ export function OverviewPage() {
</div>
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1">
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
<h4 style={{ color: 'var(--purple)' }}>Story-driven, not grind-driven</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
Progression creates narrative moments, not stat increases. Level ups unlock story branches,
new ship classes open new narrative possibilities, and faction relationships shape your saga's plot.
<strong style={{ color: 'var(--fg)' }}>Your choices generate your mini-series.</strong>
</p>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--accent)' }}>
<h4 style={{ color: 'var(--accent)' }}>Exploration-first, not spreadsheet-first</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
The 3D viewport IS the game. Players spend their time flying between points of interest, discovering new sectors,
and engaging with the world. Station panels handle trading, fitting, and services but the joy is in
the journey, not the ledger. <strong style={{ color: 'var(--fg)' }}>Trade, quest, fight, or simply set sail and enjoy the ambiance.</strong>
the journey, not the ledger. <strong style={{ color: 'var(--fg)' }}>Trade, negotiate, fight, or simply set sail and enjoy the ambiance.</strong>
</p>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4">
<h4 style={{ color: 'var(--cyan)' }}>Authoritative backend</h4>
<h4 style={{ color: 'var(--cyan)' }}>Local-first architecture</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
SpacetimeDB owns authoritative game state. The browser renders state and sends player
intentions. The renderer should never become the source of truth.
The game client runs locally with local persistence for V1. Your story lives on your machine.
Future multiplayer servers will be optional the single-player experience is complete and standalone.
</p>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4">
<h4 style={{ color: 'var(--green)' }}>Every campaign is unique</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
Procedurally generated galaxy maps ensure no two games are alike. Fog of war hides unexplored sectors.
Factions, resource distributions, and quest chains vary per seed. Replayability through discovery, not grind.
Factions, resource distributions, and narrative arcs vary per seed. Replayability through discovery and storytelling, not grind.
</p>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4">
<h4 style={{ color: 'var(--purple)' }}>FTL combat captain, not pilot</h4>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--orange)' }}>
<h4 style={{ color: 'var(--orange)' }}>Diplomacy and trade are viable paths</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
The player never directly flies or aims. Click a destination ship autopilots. Click a hostile ship auto-engages.
During combat, the player manages <strong style={{ color: 'var(--fg)' }}>reactor power allocation</strong> (FTL-style: weapons/shields/engines/aux)
and <strong style={{ color: 'var(--fg)' }}>subsystem targeting</strong>. You are the chief engineer deciding where
the reactor output goes not the pilot or gunner.
Inspired by Stellaris, commerce and conversation are equally valid ways to shape your galaxy. Supply factions to gain influence,
broker alliances between enemies, negotiate borders without firing a shot. <strong style={{ color: 'var(--fg)' }}>War is a choice, not the default.</strong>
</p>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--red)' }}>
<h4 style={{ color: 'var(--red)' }}>Conflict serves the story</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
Combat exists but is not the focus. When conflict occurs, it's FTL-style tactical resource management — a dramatic beat in your narrative.
The player never directly flies or aims. Click a hostile → ship auto-engages. Manage reactor power allocation (weapons/shields/engines/aux).
You are the captain making strategic decisions, not the pilot dodging fire.
</p>
</div>
</div>
@@ -138,19 +158,19 @@ export function OverviewPage() {
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4 border-l-[3px] border-l-accent" style={{ padding: 'var(--sp-6) var(--sp-8)' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', color: 'var(--fg-dim)', lineHeight: 2 }}>
<span style={{ color: 'var(--cyan)' }}>Spawn</span> <span style={{ color: 'var(--accent)' }}>Explore</span> {' '}
<span style={{ color: 'var(--green)' }}>Trade</span> {' '}
<span style={{ color: 'var(--purple)' }}>Quest</span> {' '}
<span style={{ color: 'var(--fg-bright)' }}>Fight</span> {' '}
<span style={{ color: 'var(--cyan)' }}>Upgrade Ship</span> {' '}
<span style={{ color: 'var(--accent)' }}>Sail Deeper</span>
<span style={{ color: 'var(--cyan)' }}>Spawn</span> → <span style={{ color: 'var(--accent)' }}>Discover</span> →{' '}
<span style={{ color: 'var(--green)' }}>Engage (Trade/Diplomacy/War)</span> →{' '}
<span style={{ color: 'var(--purple)' }}>AI Story Event</span> →{' '}
<span style={{ color: 'var(--fg-bright)' }}>Shape Galaxy</span> →{' '}
<span style={{ color: 'var(--cyan)' }}>Next Chapter</span>
</div>
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>The real loop is adventure.</strong> Spawn explore the unknown discover opportunities act on them grow your ship and reputation sail into deeper, riskier space.
The "mine → sell" cycle is the entry point. The endgame is about faction influence, rare discoveries, commanding powerful ships,
and shaping the galaxy through your choices <strong>whether by trade, combat, or diplomacy</strong>.
<strong>The real loop is your story.</strong> Spawn → explore the unknown → make choices that matter → watch the AI weave your actions into narrative threads →
shape factions and territories → sail into deeper chapters. The "mine → sell" cycle is the entry point. The endgame is about your unique saga,
faction influence shaped by your decisions, rare discoveries that become plot points, and commanding powerful ships as your narrative vessel.
<strong>Whether by trade, combat, or diplomacy — your choices generate your mini-series.</strong>
</div>
<h3 style={{ marginTop: 'var(--sp-6)' }}>Minimum Viable Screens</h3>

View File

@@ -4,16 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export function RoadmapPage() {
const eras = [
{
id: 'solo',
title: 'VOID::NAV — Single-Player Open-World RPG',
subtitle: 'Build the core adventure game locally. SpacetimeDB runs on the local machine from Phase 0 — there is no localStorage. One browser window, one captain, one procedurally generated galaxy to explore. Every feature is designed to be fun solo first.',
id: 'core',
title: 'VOID::NAV — Narrative-Driven Space Exploration Saga',
subtitle: 'Build a single-player experience where you author your own mini-series. Inspired by Windward\'s freedom, Stellaris\'s strategic depth, and AI-generated storytelling. Every campaign generates a unique tale. Local persistence from day one — your story lives on your machine.',
accent: 'var(--accent)',
phases: [
{
num: '0',
title: 'Local Skeleton & Star System',
goal: 'Vite app with local SpacetimeDB instance, rendered star system with station and asteroids. Player can connect and see the void.',
doneWhen: 'App boots, connects to local SpacetimeDB instance. Shows a star system with a station and 3 asteroids. Ship visible in 3D viewport. Game state persists in SpacetimeDB — no localStorage.',
goal: 'Game client with rendered star system, station, and asteroids. Player can spawn and see the void.',
doneWhen: 'Game boots. Shows a star system with a station and 3 asteroids. Ship visible in 3D viewport. Game state persists locally — closing and reopening restores state.',
status: 'current',
},
{
@@ -32,101 +32,37 @@ export function RoadmapPage() {
},
{
num: '3',
title: 'Combat — FTL Power Allocation',
goal: 'Auto-engage combat with reactor power management between weapons / shields / engines. Encounter NPC pirates at belts and anomalies.',
doneWhen: 'Target a hostile NPC at a belt or anomaly. Ship auto-engages. Player shifts reactor power between subsystems (FTL-style). Power allocation visibly changes combat outcome. Ship can be destroyed. NPC pirates drop loot and bounty.',
title: 'AI Story Director Foundation',
goal: 'Event tracking system. Player actions logged for narrative synthesis. Basic AI-generated story events trigger based on discoveries and choices.',
doneWhen: 'Player actions (discover POI, trade, fight, ally with faction) are logged. AI generates narrative text describing events. Story log accessible showing campaign history. Discoveries generate story beats.',
status: 'upcoming',
},
{
num: '4',
title: 'Ship Customization & Upgrades',
goal: 'Multiple ship classes (scout, hauler, warship). Module fitting with High/Med/Low slots. CPU/PG constraints. Buy new ships at stations.',
doneWhen: 'Dock at station. Open fitting screen. Equip weapons in high slots, shield booster in mid, cargo expander in low. Fitting affects combat and trade stats. Can buy a new ship class. Invalid fits rejected (insufficient CPU/PG).',
title: 'Diplomacy & Factions',
goal: 'NPC agents offer quests. Faction standing system. Stellaris-inspired diplomatic options. Trade routes and alliances shape both world and narrative.',
doneWhen: 'Talk to NPC agent at station. Accept a quest (deliver cargo, explore anomaly). Complete quest for ISK + standing. Standing unlocks better quests and prices. Supplying a station increases its prosperity. Faction relationships visible and affected by choices.',
status: 'upcoming',
},
{
num: '5',
title: 'Faction Quests & Dynamic World',
goal: 'NPC agents offer quests (kill, courier, mining, exploration). Faction standing system. Station prosperity responds to player supply. Dynamic world events spawn.',
doneWhen: 'Talk to NPC agent at station. Accept a quest (deliver cargo, kill pirates, explore anomaly). Complete quest for ISK + standing. Standing unlocks better quests and prices. Supplying a station increases its prosperity. World events spawn in explored systems.',
title: 'Ship Customization & Upgrades',
goal: 'Multiple ship classes (scout, hauler, warship). Module fitting with High/Med/Low slots. CPU/PG constraints. Buy new ships at stations.',
doneWhen: 'Dock at station. Open fitting screen. Equip weapons in high slots, shield booster in mid, cargo expander in low. Fitting affects stats. Can buy a new ship class. Invalid fits rejected (insufficient CPU/PG).',
status: 'upcoming',
},
{
num: '6',
title: 'Industry & Economy Depth',
goal: 'Refining ore into minerals. Manufacturing modules and items. Full NPC market with supply/demand. Price history and regional differences.',
doneWhen: 'Dock with ore. Refine at station. Minerals appear in hangar. Open manufacturing tab, select a blueprint, queue a job. Job completes. Product appears. Sell on NPC market. Prices react to trades. Different stations have different prices.',
title: 'Strategic Conflict (Secondary)',
goal: 'FTL-style combat as dramatic story beats, not gameplay loop. Auto-engage with power management. Consequences feed narrative.',
doneWhen: 'Target hostile NPC. Ship auto-engages. Manage reactor power (weapons/shields/engines). Combat outcome affects story (loot, bounty, reputation changes). Defeats and victories logged in story.',
status: 'upcoming',
},
{
num: '7',
title: 'Single-Player Polish & Procedural Galaxy',
goal: 'Procedurally generated multi-system galaxy. Fog of war. Full HUD. Notifications. Tutorial sequence. Ship AI companion (Zora Tier 0). Save/load.',
doneWhen: 'Full game loop is playable solo: explore → mine → trade → quest → fight → upgrade → explore deeper. Procedural galaxy generates unique systems each campaign. Fog of war reveals as you travel. HUD shows all relevant info. Tutorial guides new players through first 30 minutes. SpacetimeDB persists all state.',
status: 'future',
},
],
},
{
id: 'multi',
title: 'Optional — Co-op Servers',
subtitle: 'Promote local SpacetimeDB to a shared server for small co-op crews. Same adventure, shared with friends. Entirely optional — the core game is complete without this.',
accent: 'var(--cyan)',
phases: [
{
num: '8',
title: 'Shared Server Skeleton',
goal: 'Promote local SpacetimeDB to a shared server. Multiple clients connect and see the same 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: 'Co-op Presence & Movement',
goal: 'Players see each other in real time. Movement is server-authoritative with client-side interpolation. Small crew exploration.',
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 Market & Trading',
goal: 'Player-to-player trading. Buy/sell orders between co-op crew members. Shared 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: 'Chat & Communication',
goal: 'Local chat for co-op crew, delayed comms for immersion.',
doneWhen: 'Players in the same system see chat in real time. PMs arrive with light-speed delay. Crew can coordinate exploration.',
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: 'Co-op Combat',
goal: 'Crew members fight together. Multiple ships in an engagement. Coordinate against tough NPCs.',
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: 'Crews & Shared Progress',
goal: 'Form a persistent crew. Shared wallet, coordinated objectives, crew-level progression.',
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: 'Co-op Polish',
goal: 'Polish pass on co-op features. Error handling, reconnection, scaling tests for small crews.',
doneWhen: 'Fresh player can create account, complete the tutorial, explore, mine, trade, fight, and customize their ship — all without hitting a dead-end or a crash. Optionally, invite a friend to join the same server for co-op. Server handles a small crew of friends.',
goal: 'Procedurally generated multi-system galaxy. Fog of war. Full HUD. Notifications. Tutorial sequence. Ship AI companion (Zora Tier 0). Story-driven progression.',
doneWhen: 'Full game loop: discover → engage → AI story event → shape galaxynext chapter. Procedural galaxy generates unique systems each campaign. Fog of war reveals as you travel. HUD shows all relevant info. Tutorial guides first 30 minutes. State persists locally.',
status: 'future',
},
],
@@ -181,18 +117,16 @@ export function RoadmapPage() {
<div className="mx-auto max-w-content">
<h1 style={{ marginBottom: '8px' }}>Development Roadmap</h1>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
Two eras, sixteen phases. <strong style={{ color: 'var(--fg)' }}>The core game</strong> is a single-player open-world space RPG
with a local SpacetimeDB instance exploration, trading, FTL-style combat, ship customization, and faction quests.
<strong style={{ color: 'var(--fg)' }}>Optional co-op servers</strong> can be added later by promoting the local SpacetimeDB to a shared server.
Each phase has a verifiable done-when condition. Integration gates between phase groups ensure every system works together before advancing.
Eight phases to a complete single-player narrative experience. <strong style={{ color: 'var(--fg)' }}>Core game</strong> is a story-driven
space exploration saga inspired by Windward's freedom, Stellaris's strategic depth, and AI-generated storytelling.
Every phase has a verifiable done-when condition. Integration gates ensure all systems work together before advancing.
</p>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginTop: 'var(--sp-4)', maxWidth: '720px' }}>
<strong>Why single-player with local SpacetimeDB?</strong> The game is a solo adventure Windward Horizon in space. Networking is the biggest source of bugs and complexity.
By validating that exploration, trading, combat, fitting, and faction quests are fun locally using the <em>same</em> SpacetimeDB
persistence that would serve co-op we de-risk the entire project. There is no localStorage; SpacetimeDB is the
persistence layer from day 1. If co-op is added later, the question is only &quot;how do we share this server?&quot; not
&quot;is this game fun?&quot; or &quot;will the persistence migration work?&quot;
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-purple/20 bg-purple/8 text-purple" style={{ marginTop: 'var(--sp-4)', maxWidth: '720px' }}>
<strong>Why narrative-driven and single-player?</strong> The game is about <em>your story</em> Windward meets Stellaris with AI-generated
narrative in the style of The Templin Institute. Every choice feeds the AI Story Director, weaving your actions into an ongoing saga.
Local persistence means your story lives on your machine no cloud required, no subscriptions, no always-online. Just you and the void.
Future enhancements may add multiplayer, but the core experience is complete and standalone.
</div>
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
@@ -209,53 +143,35 @@ export function RoadmapPage() {
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
Navigate to asteroid mine fill cargo dock sell ore navigate to another station discover price difference trade.
The complete exploration and trade loop runs in a single session without errors. The galaxy feels alive and explorable.
SpacetimeDB persists the session closing the browser and reopening restores state.
Local persistence works closing and reopening restores state.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0, 1, 2</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--red)' }}>
<h4 style={{ color: 'var(--red)' }}>Gate 2 Combat + Customization (after Phase 4)</h4>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
<h4 style={{ color: 'var(--purple)' }}>Gate 2 Story Foundation (after Phase 3)</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
Customize a ship at station undock encounter NPC pirate manage FTL power allocation destroy or be destroyed
collect loot buy upgrades try a different ship class. Combat and ship customization form a satisfying adventure loop.
Player actions generate story events. AI Story Director weaves discoveries, trades, and choices into narrative text.
Story log accessible showing campaign history. The game feels like it's telling YOUR story.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 04</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 03</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
<h4 style={{ color: 'var(--green)' }}>Gate 3 Full Adventure Loop (after Phase 6)</h4>
<h4 style={{ color: 'var(--green)' }}>Gate 3 — Full Adventure Loop (after Phase 5)</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
Explore mine ore refine manufacture a module fit it fight pirates accept faction quest deliver goods
earn standing unlock better missions sell excess across stations at different prices.
The complete adventure: exploration, economy, industry, and faction systems work as an integrated whole.
Explore → discover → AI story event → engage faction → complete quest → earn standing → unlock new narrative branches.
Diplomacy, trade, and story systems work as an integrated whole. Choices matter.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 06</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 05</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
<h4 style={{ color: 'var(--cyan)' }}>Gate 4 — Core Game Complete (after Phase 7)</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
Full solo game with all systems integrated: explore mine trade quest fight customize sail deeper.
Procedurally generated galaxy. Fog of war. HUD, notifications, Zora Tier 0, world events, tutorial sequence.
A new player can learn the game in one session and want to keep going. SpacetimeDB state survives restart.
Full solo game: discover → engage → AI story event → shape galaxy → next chapter.
Procedurally generated galaxy, fog of war, HUD, notifications, Zora Tier 0, world events, tutorial, conflict as story beats.
A new player can learn the game in one session and want to keep going. State persists locally.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 07 (core game)</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
<h4 style={{ color: 'var(--purple)' }}>Gate 5 Co-op Core (after Phase 10)</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
Two players on the same server. Both see each other. Both can trade on the shared market. ISK and items
transfer atomically. Movement is synced. Connection loss and reconnection work. The same solo adventure, shared with a friend.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 810</div>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--fg-dim)' }}>
<h4 style={{ color: 'var(--fg-dim)' }}>Gate 6 Co-op Ready (after Phase 15)</h4>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
Fresh player can: create account, complete tutorial, explore their first system, mine and trade, survive a PvE encounter,
customize their ship, accept and complete a faction quest and optionally invite a friend to share the adventure all without crashes or dead ends.
Server handles a small crew of friends.
</p>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 015 (all)</div>
</div>
</div>
<div style={{ marginTop: 'var(--sp-8)' }}>
@@ -281,7 +197,7 @@ export function RoadmapPage() {
color: era.accent,
border: `1px solid ${era.accent === 'var(--accent)' ? 'var(--accent-border)' : 'rgba(34,211,238,0.3)'}`,
}}>
ERA {ei + 1}
CORE GAME
</span>
<div>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>{era.title}</h2>
@@ -300,6 +216,12 @@ export function RoadmapPage() {
</div>
))}
</div>
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginTop: 'var(--sp-8)', maxWidth: '720px' }}>
<strong>Future Enhancements</strong> Post-V1 features may include episodic mini-series generation (AI looks back at campaign history
and generates "episodes" like a Templin Institute video), multiplayer co-op servers, and expanded narrative systems.
These are optional enhancements the core single-player narrative experience is complete and standalone.
</div>
</div>
);
}

View File

@@ -17,7 +17,7 @@ export function TechStackPage() {
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.",
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.",
},
rendering: {
choice: 'React Three Fiber',
@@ -30,9 +30,9 @@ export function TechStackPage() {
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, and optional co-op all in one.',
whyNot: "A custom Node.js + PostgreSQL backend would require building real-time sync, subscriptions, and authoritative game logic from scratch.",
choice: 'Local Persistence (Rust/Bevy)',
reason: 'Game client manages state locally with persistent storage. No cloud dependency, no always-online requirement. Your story lives on your machine.',
whyNot: "Server-based backends would add complexity, hosting costs, and always-online requirements. Local-first is simpler and respects player ownership.",
},
styling: {
choice: 'Tailwind CSS',
@@ -40,9 +40,9 @@ export function TechStackPage() {
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.",
choice: 'Local Profile (MVP)',
reason: 'Simple local profile management. Player creates captain name and saves locally. Add external auth only if multiplayer is added later.',
whyNot: "Full OAuth/JWT would add complexity before we know the right identity model. Local is sufficient for single-player narrative experience.",
},
};
@@ -116,8 +116,8 @@ export function TechStackPage() {
</tr>
<tr>
<td style={{ color: 'var(--accent)' }}>Backend</td>
<td>SpacetimeDB</td>
<td style={{ color: 'var(--fg-dim)' }}>Real-time backend with reducers and subscriptions.</td>
<td>Local Persistence (Rust/Bevy)</td>
<td style={{ color: 'var(--fg-dim)' }}>Game client manages state locally with persistent storage.</td>
</tr>
<tr>
<td style={{ color: 'var(--fg-dim)' }}>Styling</td>
@@ -126,8 +126,8 @@ export function TechStackPage() {
</tr>
<tr>
<td style={{ color: 'var(--muted)' }}>Auth</td>
<td>SpacetimeDB identity</td>
<td style={{ color: 'var(--fg-dim)' }}>Simple identity first; add auth later.</td>
<td>Local Profile</td>
<td style={{ color: 'var(--fg-dim)' }}>Simple local profile management. Single-player experience.</td>
</tr>
</tbody>
</table>
@@ -146,14 +146,11 @@ export function TechStackPage() {
<div className="mb-5 overflow-x-auto rounded-lg border border-border bg-surface px-5 py-4 [&_code]:bg-transparent [&_code]:p-0 [&_code]:font-mono [&_code]:text-[0.82rem] [&_code]:leading-[1.6] [&_code]:text-fg-dim">
<code>
<span className="text-muted italic">{'//'} Starter project layout</span><br/>
<span className="text-purple">/client/src/app</span> <span className="text-muted italic">{' //'} App shell and providers</span><br/>
<span className="text-purple">/client/src/network</span> <span className="text-muted italic">{' //'} SpacetimeDB client, subscriptions</span><br/>
<span className="text-purple">/client/src/game</span> <span className="text-muted italic">{' //'} Renderer-independent types, view models</span><br/>
<span className="text-purple">/client/src/store</span> <span className="text-muted italic">{' //'} Zustand stores</span><br/>
<span className="text-purple">/client/src/renderers/r3f</span> <span className="text-muted italic">{'//'} R3F scene, meshes, camera</span><br/>
<span className="text-purple">/client/src/ui</span> <span className="text-muted italic">{' //'} HUD, inventory, market, chat</span><br/>
<span className="text-purple">/server-spacetime/src</span> <span className="text-muted italic">{' //'} SpacetimeDB module, reducers</span><br/>
<span className="text-muted italic">{'//'} Project layout</span><br/>
<span className="text-purple">/apps/game/src</span> <span className="text-muted italic">{' //'} Rust + Bevy game client</span><br/>
<span className="text-purple">/apps/docs/src</span> <span className="text-muted italic">{' //'} Design documentation</span><br/>
<span className="text-purple">/apps/site/src</span> <span className="text-muted italic">{' //'} Landing page website</span><br/>
<span className="text-purple">/packages/ui/src</span> <span className="text-muted italic">{' //'} Shared UI components</span><br/>
</code>
</div>
@@ -161,11 +158,11 @@ export function TechStackPage() {
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead><tr><th>Package</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td>vite, react, react-dom, typescript</td><td>Core frontend.</td></tr>
<tr><td>three, @react-three/fiber, @react-three/drei</td><td>3D scene and helper controls.</td></tr>
<tr><td>vite, react, react-dom, typescript</td><td>Core frontend (docs, site).</td></tr>
<tr><td>three, @react-three/fiber, @react-three/drei</td><td>3D scene and helper controls (docs demos).</td></tr>
<tr><td>zustand</td><td>Local UI/game view state.</td></tr>
<tr><td>tailwindcss</td><td>Panel and HUD styling.</td></tr>
<tr><td>spacetimedb TS client</td><td>Backend connection, reducers, subscriptions.</td></tr>
<tr><td>bevy, rust</td><td>Game client engine and language.</td></tr>
</tbody>
</table>
</div>

View File

@@ -1,922 +0,0 @@
// @ts-nocheck
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
const LAST_UPDATED = '2026-06-02';
const BACKEND_TABLES_IMPLEMENTED = 9;
const BACKEND_TABLES_DESIGNED = 56;
const BACKEND_REDUCERS_IMPLEMENTED = 12;
const BACKEND_REDUCERS_DESIGNED = 40;
type Phase = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7';
type Layer = 'backend' | 'frontend' | 'scene' | 'prototype';
type Status = 'done' | 'partial' | 'missing' | 'blocked';
interface TodoItem {
id: string;
title: string;
phase: Phase;
layer: Layer;
status: Status;
desc: string;
detail?: string;
files?: string;
blockedBy?: string[];
}
const STATUS_COLORS: Record<Status, string> = {
done: 'var(--green)',
partial: 'var(--accent)',
missing: 'var(--red)',
blocked: 'var(--muted)',
};
const STATUS_LABELS: Record<Status, string> = {
done: 'DONE',
partial: 'PARTIAL',
missing: 'MISSING',
blocked: 'BLOCKED',
};
const LAYER_LABELS: Record<Layer, string> = {
backend: 'SpacetimeDB',
frontend: 'Game UI',
scene: '3D Scene',
prototype: 'Prototype Ready',
};
const items: TodoItem[] = [
// ── PHASE 0: LOCAL SKELETON ──
{
id: 'P0-B1',
title: 'Player identity & connection',
phase: '0',
layer: 'backend',
status: 'done',
desc: 'Player table, connectPlayer, renamePlayer, ping reducers. Auth token persistence.',
files: 'services/spacetimedb/src/index.ts:143-218',
},
{
id: 'P0-B2',
title: 'World seeding (single system)',
phase: '0',
layer: 'backend',
status: 'done',
desc: 'seedWorld creates Solace system, station, 2 POIs. Hardcoded constants.',
files: 'services/spacetimedb/src/index.ts:138-141, 458-500',
},
{
id: 'P0-B3',
title: 'Ship state & wallet',
phase: '0',
layer: 'backend',
status: 'done',
desc: 'Ship table (position, flightMode, dockedStationId, cargoCapacity). Wallet table with ISK.',
files: 'services/spacetimedb/src/index.ts:28-45, 93-100',
},
{
id: 'P0-B4',
title: 'Server event logging',
phase: '0',
layer: 'backend',
status: 'done',
desc: 'ServerEvent table. Every reducer writes a typed event.',
files: 'services/spacetimedb/src/index.ts:114-123, 580-588',
},
{
id: 'P0-F1',
title: 'SpacetimeDB client connection',
phase: '0',
layer: 'frontend',
status: 'done',
desc: 'Connection lifecycle, table subscriptions, auth token, re-render triggers.',
files: 'apps/game/src/spacetime/client.ts, useSpacetimeConnection.ts, useGameSession.ts',
},
{
id: 'P0-F2',
title: 'Game shell layout',
phase: '0',
layer: 'frontend',
status: 'partial',
desc: 'Three-column responsive layout with left/right sidebars and center 3D viewport. Missing: center panel area is empty (hidden on large screens). No loading/splash screen.',
detail: 'Middle column <section> is hidden lg:block with no content. No station mode panel swap. Inline "Pilot" info should be its own component.',
files: 'apps/game/src/GameShell.tsx:70',
},
{
id: 'P0-F3',
title: 'Connection panel',
phase: '0',
layer: 'frontend',
status: 'done',
desc: 'Connection status, identity, transport/reducer messages, pilot name input, rename/ping.',
files: 'apps/game/src/ui/ConnectionPanel.tsx',
},
{
id: 'P0-S1',
title: '3D space scene foundation',
phase: '0',
layer: 'scene',
status: 'partial',
desc: 'R3F Canvas, environment (stars, fog, lights), station mesh, asteroid mesh, ship mesh. All clickable.',
detail: 'Missing: camera controls (no orbit/zoom/pan — fixed position). No sun/star light source. No nebulae. Grid plane is debug-only.',
files: 'apps/game/src/scene/',
},
{
id: 'P0-S2',
title: 'Ship mesh with engine glow',
phase: '0',
layer: 'scene',
status: 'partial',
desc: 'Cone+box+sphere ship. Engine glow changes by flightMode. Bob/roll animation.',
detail: 'Missing: no damage state visualization, no shield bubble, no warp animation, no mining laser beam (only flat Line), no module hardpoints visible.',
files: 'apps/game/src/scene/ShipMesh.tsx',
},
// ── PHASE 1: NAVIGATION & EXPLORATION ──
{
id: 'P1-B1',
title: 'Approach operation (start + complete)',
phase: '1',
layer: 'backend',
status: 'done',
desc: 'Timed approach with 5s duration. Validates undocked, target selected, target in system. Position update on complete.',
files: 'services/spacetimedb/src/index.ts:248-299',
},
{
id: 'P1-B2',
title: 'Target selection',
phase: '1',
layer: 'backend',
status: 'done',
desc: 'selectTarget validates POI exists and is in current system.',
files: 'services/spacetimedb/src/index.ts:236-246',
},
{
id: 'P1-B3',
title: 'Warp travel between systems',
phase: '1',
layer: 'backend',
status: 'missing',
desc: 'No stargate table, no warp sequence, no jump mechanics. Ship is locked to single system (Solace).',
blockedBy: ['P5-B3'],
},
{
id: 'P1-B4',
title: 'Stargate network',
phase: '1',
layer: 'backend',
status: 'missing',
desc: 'No stargate table. No gate activation range, jump cooldown, gate cloak. Design describes full stargate mechanics.',
},
{
id: 'P1-F1',
title: 'Mini star map (SVG)',
phase: '1',
layer: 'frontend',
status: 'done',
desc: '2D SVG top-down system map showing POIs, ship position, operation lines, click-to-select.',
files: 'apps/game/src/ui/MiniStarMap.tsx',
},
{
id: 'P1-F2',
title: 'Target panel with POI list',
phase: '1',
layer: 'frontend',
status: 'partial',
desc: 'Shows selected target and clickable POI list.',
detail: 'Missing: no distance to target, no spatial grouping, no range info, no sortable overview-style list.',
files: 'apps/game/src/ui/TargetPanel.tsx',
},
{
id: 'P1-F3',
title: 'Command rail (contextual actions)',
phase: '1',
layer: 'frontend',
status: 'partial',
desc: 'Bottom-fixed action bar with contextual buttons and progress bars for operations.',
detail: 'Missing: no abort/cancel operation, no warp/jump commands, no combat commands, no station service buttons, keyboard shortcut hints not visible.',
files: 'apps/game/src/ui/CommandRail.tsx',
},
{
id: 'P1-F4',
title: 'Keyboard shortcuts',
phase: '1',
layer: 'frontend',
status: 'partial',
desc: 'U=undock, D=dock, A=approach, M=mine, 1-9=select target.',
detail: 'Missing: no shortcut for sell, refine, fit, combat, warp. No modifier keys. No shortcut discovery overlay.',
files: 'apps/game/src/ui/useKeyboardShortcuts.ts',
},
{
id: 'P1-S1',
title: 'Ship position interpolation during approach',
phase: '1',
layer: 'scene',
status: 'done',
desc: 'Lerp-based smooth movement from current position to target during approach operation.',
files: 'apps/game/src/scene/GameSpaceScene.tsx',
},
{
id: 'P1-S2',
title: 'Camera controls (orbit, zoom, pan)',
phase: '1',
layer: 'scene',
status: 'missing',
desc: 'Camera is fixed at [8,9,44] with gentle sine drift. No OrbitControls, no zoom, no user camera interaction.',
},
{
id: 'P1-S3',
title: 'Warp/travel visual effects',
phase: '1',
layer: 'scene',
status: 'missing',
desc: 'No warp tunnel effect, no gate jump animation, no system transition. Prototype has a full warp sequence demo.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/WarpTravelDemo.tsx',
},
// ── PHASE 2: MINING, INVENTORY & TRADE ──
{
id: 'P2-B1',
title: 'Mining operation (start + complete)',
phase: '2',
layer: 'backend',
status: 'done',
desc: 'Timed mining with 6s duration. Validates undocked, at asteroid belt, cargo not full. Adds 1000 Veldspar to cargo.',
files: 'services/spacetimedb/src/index.ts:326-409',
},
{
id: 'P2-B2',
title: 'Cargo/inventory system',
phase: '2',
layer: 'backend',
status: 'partial',
desc: 'CargoItem table with stacking. Capacity tracking.',
detail: 'Missing: only "ore" category used. No mineral, module, loot, commodity categories. No jettison. No item splitting.',
files: 'services/spacetimedb/src/index.ts:79-91',
},
{
id: 'P2-B3',
title: 'NPC ore market (sell only)',
phase: '2',
layer: 'backend',
status: 'partial',
desc: 'sellOreToNpcMarket validates docked, ore category, quantity. Credits wallet.',
detail: 'Missing: only 1 ore type (Veldspar) at fixed 12 ISK. No NPC buy orders for minerals/modules. No sell-side NPC. No dynamic pricing. No regional variation. Prices hardcoded in both backend AND frontend.',
files: 'services/spacetimedb/src/index.ts:411-456',
},
{
id: 'P2-B4',
title: 'Dynamic NPC pricing (supply/demand)',
phase: '2',
layer: 'backend',
status: 'missing',
desc: 'Design describes full NPC pricing algorithm with EMA, demand pressure, regional seeds, anti-arbitrage. Nothing implemented.',
},
{
id: 'P2-B5',
title: 'Regional price differences',
phase: '2',
layer: 'backend',
status: 'missing',
desc: 'Single system only. No way for prices to differ between stations.',
blockedBy: ['P1-B3', 'P5-B3'],
},
{
id: 'P2-F1',
title: 'Cargo panel',
phase: '2',
layer: 'frontend',
status: 'partial',
desc: 'Displays cargo items with name, quantity, category, unit price.',
detail: 'Missing: no jettison button, no item stacking/splitting, no item details, no ore-type filtering.',
files: 'apps/game/src/ui/CargoPanel.tsx',
},
{
id: 'P2-F2',
title: 'NPC market panel',
phase: '2',
layer: 'frontend',
status: 'partial',
desc: 'Sell ore UI with quantity input, max button, payout preview. Only visible when docked.',
detail: 'Missing: only Veldspar. Prices hardcoded client-side in NPC_PRICES constant. No buy orders, no order book, no price history, no charts.',
files: 'apps/game/src/ui/NpcMarketPanel.tsx:5-7',
},
{
id: 'P2-F3',
title: 'Wallet panel',
phase: '2',
layer: 'frontend',
status: 'partial',
desc: 'Shows ISK balance.',
detail: 'Missing: no transaction history, no income/expenditure breakdown.',
files: 'apps/game/src/ui/WalletPanel.tsx',
},
{
id: 'P2-F4',
title: 'Ship status panel',
phase: '2',
layer: 'frontend',
status: 'partial',
desc: 'Flight mode badge, hull/shield/speed bars, position, system, operation countdown.',
detail: 'HARDCODED: hullPct=100 always (line 44), shieldPct=Math.random() (line 45), cargoPct always 0 (line 46), speed labels are hardcoded strings (lines 32-42). No armor layer. No capacitor. No power allocation. No module activation indicators.',
files: 'apps/game/src/ui/ShipStatusPanel.tsx:44-46',
},
{
id: 'P2-S1',
title: 'Asteroid rendering (per-belt clusters)',
phase: '2',
layer: 'scene',
status: 'partial',
desc: '3 asteroids per belt with hardcoded offsets, tumble animation, clickable.',
detail: 'Missing: all identical geometry/color. No ore type differentiation. No depletion state. No mining laser beam (flat Line only).',
files: 'apps/game/src/scene/AsteroidMesh.tsx, GameSpaceScene.tsx:120-124',
},
{
id: 'P2-S2',
title: 'Mining laser beam visual',
phase: '2',
layer: 'scene',
status: 'missing',
desc: 'Only a flat amber Line drawn during mining. No beam/particle effect connecting ship to asteroid.',
},
// ── PHASE 3: COMBAT — FTL POWER ALLOCATION ──
{
id: 'P3-B1',
title: 'NPC pirate entity system',
phase: '3',
layer: 'backend',
status: 'missing',
desc: 'No npc_entities, npc_class_templates, or loot_tables. Design describes full NPC spawning, AI behavior, state machine (IDLE→AGGRO→COMBAT→FLEE→DEAD).',
},
{
id: 'P3-B2',
title: 'Combat encounter state',
phase: '3',
layer: 'backend',
status: 'missing',
desc: 'No combat session, no damage resolution, no shield/armor/hull tracking, no weapon cycling.',
},
{
id: 'P3-B3',
title: 'FTL power allocation (weapons/shields/engines/aux)',
phase: '3',
layer: 'backend',
status: 'missing',
desc: 'Design describes 4 subsystems with powered/unpowered behavior, reroute timing (1.5-3s), and failure modes. Nothing implemented.',
},
{
id: 'P3-B4',
title: 'Ship health model (shield/armor/hull)',
phase: '3',
layer: 'backend',
status: 'missing',
desc: 'Ship table has no health columns. Design describes 3 HP pools with different damage resistance.',
},
{
id: 'P3-B5',
title: 'Bounty & loot drop system',
phase: '3',
layer: 'backend',
status: 'missing',
desc: 'No bounty ISK awards, no loot tables, no wreck/loot containers. Design describes full bounty tiers and module drops.',
},
{
id: 'P3-B6',
title: 'Ship destruction & respawn',
phase: '3',
layer: 'backend',
status: 'missing',
desc: 'No death mechanic, no respawn, no insurance payout. Design describes rookie frigate respawn, loot drops, insurance.',
},
{
id: 'P3-F1',
title: 'Combat HUD (target lock, power allocation, damage)',
phase: '3',
layer: 'frontend',
status: 'missing',
desc: 'No combat UI at all. Design describes target lock reticle, power allocation bars (W/S/E/Aux), shield/armor/hull wheel, capacitor gauge, weapon timer, combat log.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/CombatDemo.tsx, game-slice/ui/SliceCombatStage.tsx',
},
{
id: 'P3-S1',
title: 'Combat 3D scene (projectiles, shields, explosions)',
phase: '3',
layer: 'scene',
status: 'missing',
desc: 'No NPC ships rendered, no projectiles, no shield effects, no explosions, no damage numbers.',
files: 'Prototype ref: apps/docs/src/prototypes/r3f/combat/CombatScene.tsx',
},
// ── PHASE 4: SHIP CUSTOMIZATION & UPGRADES ──
{
id: 'P4-B1',
title: 'Ship types catalog (multiple classes)',
phase: '4',
layer: 'backend',
status: 'missing',
desc: 'Only 1 hardcoded "Starter Frigate". Design describes 5 classes (Frigate through Battleship) with full stat tables.',
},
{
id: 'P4-B2',
title: 'Module catalog & fitting system',
phase: '4',
layer: 'backend',
status: 'missing',
desc: 'No modules_catalog, no ship_fittings table. Design describes High/Med/Low slot types, CPU/PG constraints, module categories.',
},
{
id: 'P4-B3',
title: 'Fit/unfit module reducers',
phase: '4',
layer: 'backend',
status: 'missing',
desc: 'No reducers for module management. Design validates CPU/PG budget, slot type matching.',
},
{
id: 'P4-B4',
title: 'Ship purchase/acquisition',
phase: '4',
layer: 'backend',
status: 'missing',
desc: 'No ship buying, no hangar storage, no ship switching. Design describes NPC market, hangar, and rookie frigate policy.',
},
{
id: 'P4-F1',
title: 'Fitting screen UI',
phase: '4',
layer: 'frontend',
status: 'missing',
desc: 'No fitting UI. Design describes slot layout with drag-and-drop, CPU/PG bars, module catalog.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/FittingDemo.tsx, game-slice/ui/SliceFittingService.tsx',
},
{
id: 'P4-F2',
title: 'Ship purchase screen',
phase: '4',
layer: 'frontend',
status: 'missing',
desc: 'No ship shop, no hangar view, no ship comparison UI.',
},
// ── PHASE 5: FACTION QUESTS & DYNAMIC WORLD ──
{
id: 'P5-B1',
title: 'Faction & standing system',
phase: '5',
layer: 'backend',
status: 'missing',
desc: 'No factions table, no player standing, no loyalty points. Design describes -10 to +10 standing with faction consequences.',
},
{
id: 'P5-B2',
title: 'NPC agents & mission templates',
phase: '5',
layer: 'backend',
status: 'missing',
desc: 'No npc_agents, mission_templates, active_missions tables. Design describes 6 mission types (kill, courier, mining, survey, escort, trade) with 4 reward tiers.',
},
{
id: 'P5-B3',
title: 'Procedural galaxy generation',
phase: '5',
layer: 'backend',
status: 'missing',
desc: 'Single hardcoded system (Solace). Design describes seeded RNG galaxy with MST stargate topology, regions, constellations.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/GalaxyDemo.tsx',
},
{
id: 'P5-B4',
title: 'Station prosperity (dynamic world impact)',
phase: '5',
layer: 'backend',
status: 'missing',
desc: 'No station prosperity, no resource demand model. Design describes stations growing when supplied, faction leaders taking provinces.',
},
{
id: 'P5-B5',
title: 'World events (anomalies, faction conflicts)',
phase: '5',
layer: 'backend',
status: 'missing',
desc: 'No event spawning, no participation tracking, no galaxy story log. Design describes 6 event categories with 3 notification tiers.',
},
{
id: 'P5-F1',
title: 'Agent/mission panel',
phase: '5',
layer: 'frontend',
status: 'missing',
desc: 'No NPC agent list, no available missions, no mission tracker, no standing display.',
},
{
id: 'P5-F2',
title: 'Galaxy map (multi-system)',
phase: '5',
layer: 'frontend',
status: 'missing',
desc: 'Only single-system SVG map exists. No multi-system navigation, no stargate routes, no faction overlay.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/StarMapDemo.tsx',
},
// ── PHASE 6: INDUSTRY & ECONOMY DEPTH ──
{
id: 'P6-B1',
title: 'Refining (ore → minerals)',
phase: '6',
layer: 'backend',
status: 'missing',
desc: 'No refineOre reducer, no mineral item types. Design describes 8 ore types refining to 8 minerals with skill-based yield (50-95%).',
files: 'Prototype ref: apps/docs/src/prototypes/game-slice/sliceEconomy.ts',
},
{
id: 'P6-B2',
title: 'Manufacturing (minerals → modules/ships)',
phase: '6',
layer: 'backend',
status: 'missing',
desc: 'No blueprints, no manufacturing_jobs, no production chain. Design describes 5-tier chain: Ore → Mineral → Component → Module → Ship.',
},
{
id: 'P6-B3',
title: 'Multiple ore types',
phase: '6',
layer: 'backend',
status: 'missing',
desc: 'Only Veldspar. Design describes 8 ore types (Veldspar, Scordite, Pyroxeres, Kernite, Omber, Jaspet, Hemorphite, Arkonor).',
},
{
id: 'P6-F1',
title: 'Refining UI',
phase: '6',
layer: 'frontend',
status: 'missing',
desc: 'No refining interface. Design describes batch processing with yield preview.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/RefiningDemo.tsx, game-slice/ui/SliceRefiningService.tsx',
},
{
id: 'P6-F2',
title: 'Manufacturing UI',
phase: '6',
layer: 'frontend',
status: 'missing',
desc: 'No manufacturing tab. Design describes blueprint selection, job queue, material requirements.',
},
{
id: 'P6-F3',
title: 'Market with order book & price history',
phase: '6',
layer: 'frontend',
status: 'missing',
desc: 'No order book, no charts, no bid/ask spread. Only flat NPC buy for ore.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/MarketDemo.tsx',
},
// ── PHASE 7: POLISH & PROCEDURAL GALAXY ──
{
id: 'P7-B1',
title: 'XP & skill progression',
phase: '7',
layer: 'backend',
status: 'missing',
desc: 'No skills_catalog, no XP awards. Design describes 5 skills (Mining, Industry, Trade, Gunnery, Navigation) with level 0-5.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/ProgressionDemo.tsx',
},
{
id: 'P7-B2',
title: 'Tutorial objective chain',
phase: '7',
layer: 'backend',
status: 'missing',
desc: 'No objective tracking. Prototype has 7-objective tutorial chain.',
files: 'Prototype ref: apps/docs/src/prototypes/game-slice/sliceObjectives.ts',
},
{
id: 'P7-B3',
title: 'Zora ship AI (Tier 0)',
phase: '7',
layer: 'backend',
status: 'missing',
desc: 'No Zora state, no soul depth, no personality axes, no module gating.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/ZoraDemo.tsx',
},
{
id: 'P7-B4',
title: 'CONCORD / law enforcement',
phase: '7',
layer: 'backend',
status: 'missing',
desc: 'No security status, no CONCORD response, no criminal flagging.',
},
{
id: 'P7-B5',
title: 'Insurance system',
phase: '7',
layer: 'backend',
status: 'missing',
desc: 'No insurance policies, no coverage tiers, no payout on death.',
},
{
id: 'P7-F1',
title: 'Tutorial / onboarding flow',
phase: '7',
layer: 'frontend',
status: 'missing',
desc: 'No guided mission sequence, no skip option, no Zora hints.',
},
{
id: 'P7-F2',
title: 'Full Flight Mode HUD (diegetic)',
phase: '7',
layer: 'frontend',
status: 'missing',
desc: 'No diegetic HUD overlays on 3D viewport. Design describes curved shield/armor bars, module rack, overview panel, targeting reticle, capacitor gauge.',
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/GameHudDemo.tsx',
},
{
id: 'P7-F3',
title: 'Station Mode panel UI',
phase: '7',
layer: 'frontend',
status: 'missing',
desc: 'No station service menu. No docked-mode panel swap. GameShell shows same panels regardless of dock/flight state.',
},
{
id: 'P7-F4',
title: 'Event feed with categories',
phase: '7',
layer: 'frontend',
status: 'partial',
desc: 'Basic event log exists (12 items, no pagination).',
detail: 'Missing: no color coding by event type, no filtering, no combat vs market vs system categorization, no click-to-zoom.',
files: 'apps/game/src/ui/EventFeed.tsx',
},
{
id: 'P7-F5',
title: 'Fog of war / map reveal',
phase: '7',
layer: 'frontend',
status: 'missing',
desc: 'No fog of war mechanic. All POIs visible immediately in current system.',
},
// ── CROSS-CUTTING / INFRASTRUCTURE ──
{
id: 'X-B1',
title: 'Scheduled agents (timed server logic)',
phase: '0',
layer: 'backend',
status: 'missing',
desc: 'All game logic is reducer-call driven. No timed agents for: NPC spawning, combat ticks, economy ticks, world events, balancing.',
},
{
id: 'X-F1',
title: 'Hardcoded system/station references',
phase: '1',
layer: 'frontend',
status: 'partial',
desc: 'Client subscribes to hardcoded "solace" system and "solace-prime" station. useGameSession hardcodes these lookups.',
detail: 'client.ts:77-78, useGameSession.ts:129-130. Must be replaced with dynamic system lookup once multi-system exists.',
files: 'apps/game/src/spacetime/client.ts:77-78',
},
{
id: 'X-F2',
title: 'Operation auto-completion (client polling)',
phase: '1',
layer: 'frontend',
status: 'partial',
desc: 'GameSpaceScene polls Date.now() at 120ms intervals to detect operation completion and fire completeApproach/completeMiningCycle.',
detail: 'Works but is client-driven. Design recommends server-authoritative completion via scheduled reducers.',
files: 'apps/game/src/scene/GameSpaceScene.tsx',
},
{
id: 'X-F3',
title: 'Ambient traffic / other entities',
phase: '2',
layer: 'frontend',
status: 'missing',
desc: 'No other ships visible in space. Prototype has 4 ambient entities (3 friendly + 1 hostile) with orbital movement.',
files: 'Prototype ref: apps/docs/src/prototypes/game-slice/ui/SliceFlightStage.tsx:78-92',
},
];
const PHASE_LABELS: Record<Phase, string> = {
'0': 'Phase 0 — Local Skeleton',
'1': 'Phase 1 — Navigation & Exploration',
'2': 'Phase 2 — Mining, Inventory & Trade',
'3': 'Phase 3 — Combat (FTL Power Allocation)',
'4': 'Phase 4 — Ship Customization & Upgrades',
'5': 'Phase 5 — Faction Quests & Dynamic World',
'6': 'Phase 6 — Industry & Economy Depth',
'7': 'Phase 7 — Polish & Procedural Galaxy',
};
export function TodoPage() {
const [filterPhase, setFilterPhase] = useState<string>('all');
const [filterLayer, setFilterLayer] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const filtered = useMemo(() => {
return items.filter(item => {
if (filterPhase !== 'all' && item.phase !== filterPhase) return false;
if (filterLayer !== 'all' && item.layer !== filterLayer) return false;
if (filterStatus !== 'all' && item.status !== filterStatus) return false;
return true;
});
}, [filterPhase, filterLayer, filterStatus]);
const counts = useMemo(() => {
const byStatus = { done: 0, partial: 0, missing: 0, blocked: 0 };
const byPhase: Record<string, { done: number; partial: number; missing: number; blocked: number; total: number }> = {};
items.forEach(item => {
byStatus[item.status]++;
if (!byPhase[item.phase]) byPhase[item.phase] = { done: 0, partial: 0, missing: 0, blocked: 0, total: 0 };
byPhase[item.phase][item.status]++;
byPhase[item.phase].total++;
});
return { byStatus, byPhase };
}, []);
const grouped = useMemo(() => {
const groups: Record<string, TodoItem[]> = {};
filtered.forEach(item => {
const key = item.phase === '0' && item.id.startsWith('X-') ? 'cross' : item.phase;
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
return groups;
}, [filtered]);
const phaseOrder = ['0', '1', '2', '3', '4', '5', '6', '7', 'cross'];
const sortedGroups = phaseOrder.filter(p => grouped[p]).map(p => ({
key: p,
label: p === 'cross' ? 'Cross-Cutting / Infrastructure' : PHASE_LABELS[p as Phase],
items: grouped[p],
}));
return (
<div className="mx-auto max-w-content">
<h1 style={{ marginBottom: '8px' }}>Implementation Status</h1>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
Living TODO document tracking what's built vs. what's designed. Auto-generated from codebase review on {LAST_UPDATED}.
Each item maps to a specific phase, layer, and file reference.
</p>
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-4" style={{ marginTop: 'var(--sp-5)' }}>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--green)' }}>{counts.byStatus.done}</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Done</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--accent)' }}>{counts.byStatus.partial}</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Partial</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--red)' }}>{counts.byStatus.missing}</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Missing</div>
</div>
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--muted)' }}>{counts.byStatus.blocked}</div>
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Blocked</div>
</div>
</div>
<div className="mb-6 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan">
<strong>Backend coverage:</strong> {BACKEND_TABLES_IMPLEMENTED} / {BACKEND_TABLES_DESIGNED} tables ({Math.round(BACKEND_TABLES_IMPLEMENTED / BACKEND_TABLES_DESIGNED * 100)}%).
{BACKEND_REDUCERS_IMPLEMENTED} / {BACKEND_REDUCERS_DESIGNED} reducers ({Math.round(BACKEND_REDUCERS_IMPLEMENTED / BACKEND_REDUCERS_DESIGNED * 100)}%).
All 14 interactive demos and the full game-slice prototype live in <code>apps/docs/src/prototypes/</code> using localStorage these serve as reference implementations for migration into the SpacetimeDB backend.
</div>
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-6)', flexWrap: 'wrap' }}>
<select
value={filterPhase}
onChange={e => setFilterPhase(e.target.value)}
className="rounded-lg border border-border bg-surface-raised px-3 py-1.5 font-mono text-[0.75rem] text-fg"
>
<option value="all">All Phases</option>
{Object.entries(PHASE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
<option value="cross">Cross-Cutting</option>
</select>
<select
value={filterLayer}
onChange={e => setFilterLayer(e.target.value)}
className="rounded-lg border border-border bg-surface-raised px-3 py-1.5 font-mono text-[0.75rem] text-fg"
>
<option value="all">All Layers</option>
{Object.entries(LAYER_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="rounded-lg border border-border bg-surface-raised px-3 py-1.5 font-mono text-[0.75rem] text-fg"
>
<option value="all">All Status</option>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<span style={{ color: 'var(--muted)', fontSize: '0.75rem', lineHeight: '2' }}>
{filtered.length} / {items.length} items
</span>
</div>
{sortedGroups.map(group => {
const phaseCounts = group.key !== 'cross' ? counts.byPhase[group.key] : null;
const donePct = phaseCounts ? Math.round(((phaseCounts.done + phaseCounts.partial * 0.5) / phaseCounts.total) * 100) : null;
return (
<div key={group.key} style={{ marginBottom: 'var(--sp-8)' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-3)',
marginBottom: 'var(--sp-4)',
paddingBottom: 'var(--sp-2)',
borderBottom: '2px solid var(--border)',
}}>
<h2 style={{ margin: 0, fontSize: '1rem', flex: 1 }}>{group.label}</h2>
{phaseCounts && (
<div style={{ display: 'flex', gap: 'var(--sp-3)', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--green)' }}>{phaseCounts.done} done</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--accent)' }}>{phaseCounts.partial} partial</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--red)' }}>{phaseCounts.missing + phaseCounts.blocked} missing</span>
<div style={{
width: '80px',
height: '6px',
borderRadius: '3px',
background: 'var(--surface-raised)',
overflow: 'hidden',
}}>
<div style={{
width: `${donePct}%`,
height: '100%',
borderRadius: '3px',
background: donePct > 70 ? 'var(--green)' : donePct > 30 ? 'var(--accent)' : 'var(--red)',
}} />
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>{donePct}%</span>
</div>
)}
</div>
<div className="w-full border-collapse" style={{ overflowX: 'auto' }}>
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Layer</th>
<th>Title</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{group.items.map(item => (
<tr key={item.id}>
<td style={{ color: 'var(--muted)', fontSize: '0.7rem' }}>{item.id}</td>
<td>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: 'var(--radius-pill)',
fontSize: '0.65rem',
fontWeight: 600,
fontFamily: 'var(--font-mono)',
background: item.status === 'done' ? 'rgba(34,197,94,0.1)' : item.status === 'partial' ? 'rgba(240,160,48,0.1)' : item.status === 'blocked' ? 'var(--surface-raised)' : 'rgba(239,68,68,0.1)',
color: STATUS_COLORS[item.status],
border: `1px solid ${STATUS_COLORS[item.status]}33`,
}}>
{STATUS_LABELS[item.status]}
</span>
</td>
<td style={{ color: 'var(--fg-dim)', fontSize: '0.7rem' }}>{LAYER_LABELS[item.layer]}</td>
<td style={{ fontWeight: 500, fontFamily: 'var(--font-body)', color: 'var(--fg)' }}>{item.title}</td>
<td>
<div style={{ color: 'var(--fg-dim)', fontSize: '0.8rem', fontFamily: 'var(--font-body)' }}>{item.desc}</div>
{item.detail && (
<div style={{ color: 'var(--accent)', fontSize: '0.75rem', marginTop: '4px', padding: '4px 8px', background: 'rgba(240,160,48,0.06)', borderRadius: '4px', borderLeft: '2px solid var(--accent)' }}>
{item.detail}
</div>
)}
{item.files && (
<div style={{ color: 'var(--muted)', fontSize: '0.65rem', marginTop: '4px', fontFamily: 'var(--font-mono)' }}>
{item.files}
</div>
)}
{item.blockedBy && (
<div style={{ color: 'var(--muted)', fontSize: '0.65rem', marginTop: '4px' }}>
Blocked by: {item.blockedBy.join(', ')}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -7,4 +7,25 @@ export default defineConfig({
jsx: "automatic",
jsxImportSource: "react",
},
build: {
// Generate source maps for production debugging
sourcemap: true,
// Optimize chunk splitting for better caching
rollupOptions: {
output: {
manualChunks: {
// Bundle three.js and related libraries separately
"three-vendor": ["three", "@react-three/fiber", "@react-three/drei"],
// Bundle React separately (more stable across updates)
"react-vendor": ["react", "react-dom", "react-router-dom"],
},
},
},
// Chunk size warning limit (in kB)
chunkSizeWarningLimit: 1000,
},
// Optimize dependency pre-bundling
optimizeDeps: {
include: ["three", "@react-three/fiber", "@react-three/drei"],
},
});

View File

@@ -107,6 +107,11 @@ pub fn spawn_camera(mut commands: Commands) {
.looking_at(orbit.target, Vec3::Y),
MainCamera,
orbit,
Camera {
// Customize clear color for space background
clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)),
..default()
},
));
}
@@ -121,7 +126,7 @@ const ORBIT_MAX_DISTANCE: f32 = 1500.0;
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
/// buttons or panels would also rotate the camera.
///
/// Only runs when camera mode is Orbit.
/// Only runs when camera mode is Orbit or Follow (for tactical repositioning).
pub fn orbit_camera_control(
camera_state: Res<CameraState>,
mouse_input: Res<ButtonInput<MouseButton>>,
@@ -131,8 +136,8 @@ pub fn orbit_camera_control(
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
) {
// Only run orbit controls in Orbit mode
if camera_state.mode != CameraMode::Orbit {
// Only run orbit controls in Orbit or Follow mode (not Cinematic)
if !camera_state.mode.is_orbit() && !camera_state.mode.is_follow() {
mouse_motion.clear();
scroll_events.clear();
return;

View File

@@ -0,0 +1,355 @@
//! AI behavior execution systems.
//!
//! Systems that execute specific behaviors for NPCs: patrol, combat,
//! flee, mining, and trading.
use bevy::prelude::*;
use crate::gameplay::ai::{
AiConfig, AiNavigation, AiState, BehaviorState, CombatState, FactionAiBehavior,
FleeState, MiningState, PatrolState, StateTransition, TradingState,
};
use crate::gameplay::movement::components::MoveTarget;
use crate::gameplay::ai::perception::{Perception, PerceptionEvent, PerceptionEventType};
/// Update state transitions for all AI entities.
///
/// This system evaluates conditions and triggers state transitions
/// based on AI state, perception, and faction behavior.
pub fn update_state_transitions(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&Perception,
&FactionAiBehavior,
)>,
mut state_events: EventWriter<StateTransition>,
mut perception_events: EventReader<PerceptionEvent>,
time: Res<Time>,
) {
// First, handle perception events that might trigger state changes
for event in perception_events.read() {
if let Ok((_, mut ai_state, perception, faction_behavior)) =
ai_query.get_mut(event.observer)
{
match event.event_type {
PerceptionEventType::HostileDetected => {
// Transition to combat if we're not already fleeing
if ai_state.behavior != BehaviorState::Combat
&& ai_state.behavior != BehaviorState::Flee
{
let old_state = ai_state.behavior;
ai_state.behavior = BehaviorState::Combat;
ai_state.target_entity = Some(event.perceived);
state_events.send(StateTransition {
entity: event.observer,
from_state: old_state,
to_state: BehaviorState::Combat,
reason: "Hostile detected".to_string(),
});
}
}
PerceptionEventType::Lost => {
// Lost track of target - return to idle or patrol
if Some(event.perceived) == ai_state.target_entity {
let old_state = ai_state.behavior;
// Return to patrol if we have patrol waypoints, else idle
ai_state.behavior = BehaviorState::Idle;
ai_state.target_entity = None;
ai_state.target_position = None;
state_events.send(StateTransition {
entity: event.observer,
from_state: old_state,
to_state: BehaviorState::Idle,
reason: "Target lost".to_string(),
});
}
}
_ => {}
}
}
}
// Then, update time-based transitions
for (entity, mut ai_state, perception, faction_behavior) in ai_query.iter_mut() {
ai_state.time_in_state += time.delta_secs();
match ai_state.behavior {
BehaviorState::Idle => {
// Check for threats in idle state
if let Some(threat) = perception.primary_threat {
if faction_behavior.aggressiveness > 0.5 {
let old_state = ai_state.behavior;
ai_state.behavior = BehaviorState::Combat;
ai_state.target_entity = Some(threat);
state_events.send(StateTransition {
entity,
from_state: old_state,
to_state: BehaviorState::Combat,
reason: "Threat detected in idle".to_string(),
});
}
}
}
BehaviorState::Combat => {
// Check hull threshold for fleeing
if ai_state.hull_percentage < faction_behavior.flee_threshold() {
let old_state = ai_state.behavior;
ai_state.behavior = BehaviorState::Flee;
state_events.send(StateTransition {
entity,
from_state: old_state,
to_state: BehaviorState::Flee,
reason: "Hull critical".to_string(),
});
}
}
BehaviorState::Flee => {
// Check if we've reached safe distance
// TODO: Implement distance check from threat
// For now, NPCs stay in flee until they detect a safe distance
}
BehaviorState::Patrol | BehaviorState::Mining | BehaviorState::Trading => {
// Continue current behavior until interrupted
}
}
}
}
/// Execute patrol behavior for NPCs.
///
/// NPCs with patrol behavior follow waypoints through their assigned area.
pub fn execute_patrol(
mut commands: Commands,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut PatrolState,
&mut AiNavigation,
&Transform,
)>,
time: Res<Time>,
) {
for (entity, mut ai_state, mut patrol_state, mut navigation, transform) in ai_query.iter_mut()
{
// Update time at waypoint if we're waiting
if navigation.movement_target.is_none() && !patrol_state.waypoints.is_empty() {
patrol_state.time_at_waypoint += time.delta_secs();
if patrol_state.time_at_waypoint >= patrol_state.waypoint_wait_time {
// Move to next waypoint
patrol_state.time_at_waypoint = 0.0;
patrol_state.current_waypoint = (patrol_state.current_waypoint + 1)
% patrol_state.waypoints.len();
let target = patrol_state.waypoints[patrol_state.current_waypoint];
ai_state.target_position = Some(target);
// Assign movement target
commands.entity(entity).insert(MoveTarget(target));
navigation.movement_target = Some(target);
navigation.is_moving = true;
}
}
}
}
/// Execute combat behavior for NPCs.
///
/// NPCs in combat engage their targets with appropriate tactics.
pub fn execute_combat(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut CombatState,
&mut AiNavigation,
&FactionAiBehavior,
&Transform,
)>,
target_query: Query<&Transform>,
time: Res<Time>,
) {
for (entity, mut ai_state, mut combat_state, mut navigation, faction_behavior, transform) in
ai_query.iter_mut()
{
// Get target transform if we have one
let target_transform = if let Some(target_entity) = combat_state.target {
target_query.get(target_entity).ok()
} else if let Some(target_entity) = ai_state.target_entity {
combat_state.target = Some(target_entity);
target_query.get(target_entity).ok()
} else {
None
};
if let Some(target_tf) = target_transform {
let distance = transform.translation.distance(target_tf.translation);
// Move to preferred engagement distance
let preferred_distance = faction_behavior.preferred_engagement_distance();
let distance_diff = distance - preferred_distance;
if distance_diff.abs() > 20.0 {
// Need to adjust distance
let direction = (target_tf.translation - transform.translation).normalize();
let target_pos = if distance > preferred_distance {
// Move closer
transform.translation + direction * (distance - preferred_distance) * 0.5
} else {
// Move away
transform.translation - direction * (preferred_distance - distance) * 0.5
};
commands.entity(entity).insert(MoveTarget(target_pos));
navigation.movement_target = Some(target_pos);
navigation.is_moving = true;
} else {
// At optimal distance, stop moving
navigation.is_moving = false;
commands.entity(entity).remove::<MoveTarget>();
}
// Update attack timing
combat_state.time_since_last_attack += time.delta_secs();
if combat_state.time_since_last_attack >= combat_state.attack_cooldown {
// Attack!
combat_state.time_since_last_attack = 0.0;
combat_state.is_attacking = true;
// In full implementation, this would trigger weapon fire
bevy::log::debug!("Entity {:?} attacks!", entity);
} else {
combat_state.is_attacking = false;
}
}
}
}
/// Execute flee behavior for NPCs.
///
/// NPCs in flee behavior move away from threats at high speed.
pub fn execute_flee(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut FleeState,
&mut AiNavigation,
&Transform,
)>,
threat_query: Query<&Transform>,
) {
for (entity, mut ai_state, mut flee_state, mut navigation, transform) in ai_query.iter_mut() {
// Get threat position
let threat_transform = if let Some(threat_entity) = flee_state.threat {
threat_query.get(threat_entity).ok()
} else if let Some(threat_entity) = ai_state.target_entity {
flee_state.threat = Some(threat_entity);
threat_query.get(threat_entity).ok()
} else {
// No threat - return to idle
ai_state.behavior = BehaviorState::Idle;
continue;
};
if let Some(threat_tf) = threat_transform {
// Calculate flee direction (away from threat)
let away_from_threat = (transform.translation - threat_tf.translation).normalize();
flee_state.flee_direction = away_from_threat;
// Move away at high speed
let flee_distance = ai_config.safe_flee_distance;
let target_pos = transform.translation + away_from_threat * flee_distance;
commands.entity(entity).insert(MoveTarget(target_pos));
navigation.movement_target = Some(target_pos);
navigation.is_moving = true;
navigation.speed_factor = 1.2; // 120% speed when fleeing
}
}
}
/// Execute mining behavior for NPCs.
///
/// NPCs with mining behavior stay at asteroid belts and mine.
pub fn execute_mining(
mut ai_query: Query<(
Entity,
&mut AiState,
&mut MiningState,
&mut AiNavigation,
)>,
time: Res<Time>,
) {
for (entity, mut ai_state, mut mining_state, mut navigation) in ai_query.iter_mut() {
// Update mining time
mining_state.mining_time += time.delta_secs();
// Mining is complete when cargo is full or cycle duration is reached
if mining_state.mining_time >= mining_state.mining_cycle_duration
|| mining_state.cargo_remaining <= 0.0
{
// Mining cycle complete
mining_state.mining_time = 0.0;
if mining_state.cargo_remaining > 0.0 {
// Continue mining
bevy::log::debug!("Entity {:?} continues mining", entity);
} else {
// Cargo full - return to station
ai_state.behavior = BehaviorState::Trading;
bevy::log::debug!("Entity {:?} cargo full, returning to station", entity);
}
}
}
}
/// Execute trading behavior for NPCs.
///
/// NPCs with trading behavior travel between stations.
pub fn execute_trading(
mut commands: Commands,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut TradingState,
&mut AiNavigation,
&Transform,
)>,
) {
for (entity, mut ai_state, mut trading_state, mut navigation, transform) in ai_query.iter_mut() {
// If we have waypoints, follow them
if !trading_state.trade_route.is_empty() {
let current_waypoint = trading_state.trade_route[trading_state.current_waypoint];
let distance = transform.translation.distance(current_waypoint);
if distance < 20.0 {
// Reached waypoint - move to next
trading_state.current_waypoint =
(trading_state.current_waypoint + 1) % trading_state.trade_route.len();
let next_waypoint = trading_state.trade_route[trading_state.current_waypoint];
commands.entity(entity).insert(MoveTarget(next_waypoint));
navigation.movement_target = Some(next_waypoint);
navigation.is_moving = true;
} else if navigation.movement_target.is_none() {
// Start moving
commands.entity(entity).insert(MoveTarget(current_waypoint));
navigation.movement_target = Some(current_waypoint);
navigation.is_moving = true;
}
}
}
}

View File

@@ -0,0 +1,115 @@
//! Faction-based AI behavior profiles.
//!
//! Different factions have different AI behaviors and response patterns.
//! This module defines faction-specific AI characteristics.
use bevy::prelude::*;
/// AI behavior profile for a faction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FactionAiProfile {
/// Concord: Strict law enforcement, immediate response to hostiles
Concord,
/// Amarr: Hierarchical, organized patrols
Amarr,
/// Minmatar: Aggressive, fast response
Minmatar,
/// Gallente: Balanced, diplomatic preference
Gallente,
/// Caldari: Calculated, strategic positioning
Caldari,
/// Pirates: Opportunistic, ambush tactics
Pirates,
/// Independent: Variable behavior
Independent,
}
/// Faction-specific AI behavior settings.
#[derive(Component, Debug, Clone)]
pub struct FactionAiBehavior {
/// Faction this behavior belongs to
pub faction: String,
/// Behavior profile for this faction
pub profile: FactionAiProfile,
/// Combat aggressiveness (0.0 to 1.0)
pub aggressiveness: f32,
/// Response time to threats (seconds)
pub response_time: f32,
/// Preference for diplomatic solutions (0.0 to 1.0)
pub diplomatic_preference: f32,
/// Trade openness (0.0 to 1.0)
pub trade_openness: f32,
}
impl Default for FactionAiBehavior {
fn default() -> Self {
Self {
faction: "Independent".to_string(),
profile: FactionAiProfile::Independent,
aggressiveness: 0.5,
response_time: 5.0,
diplomatic_preference: 0.5,
trade_openness: 0.5,
}
}
}
impl FactionAiBehavior {
/// Create faction-specific AI behavior from faction name.
pub fn from_faction(faction: &str) -> Self {
let profile = match faction {
"Concord" => FactionAiProfile::Concord,
"Amarr" => FactionAiProfile::Amarr,
"Minmatar" => FactionAiProfile::Minmatar,
"Gallente" => FactionAiProfile::Gallente,
"Caldari" => FactionAiProfile::Caldari,
"Pirates" => FactionAiProfile::Pirates,
_ => FactionAiProfile::Independent,
};
let (aggressiveness, response_time, diplomatic_preference, trade_openness) = match profile {
FactionAiProfile::Concord => (0.9, 2.0, 0.3, 0.5), // Quick to respond, lawful
FactionAiProfile::Amarr => (0.7, 4.0, 0.2, 0.6), // Organized but slower
FactionAiProfile::Minmatar => (0.8, 3.0, 0.4, 0.7), // Aggressive but reasonable
FactionAiProfile::Gallente => (0.5, 5.0, 0.8, 0.9), // Diplomatic, trade-friendly
FactionAiProfile::Caldari => (0.6, 4.0, 0.5, 0.7), // Calculated, balanced
FactionAiProfile::Pirates => (0.9, 1.0, 0.0, 0.1), // Very aggressive, hostile
FactionAiProfile::Independent => (0.5, 5.0, 0.6, 0.8), // Variable
};
Self {
faction: faction.to_string(),
profile,
aggressiveness,
response_time,
diplomatic_preference,
trade_openness,
}
}
/// Get the preferred engagement distance for this faction.
pub fn preferred_engagement_distance(&self) -> f32 {
match self.profile {
FactionAiProfile::Concord => 80.0, // Close range for law enforcement
FactionAiProfile::Amarr => 120.0, // Mid-range, organized
FactionAiProfile::Minmatar => 60.0, // Close range, aggressive
FactionAiProfile::Gallente => 100.0, // Mid-range, balanced
FactionAiProfile::Caldari => 150.0, // Long range, strategic
FactionAiProfile::Pirates => 50.0, // Very close, ambush
FactionAiProfile::Independent => 100.0,
}
}
/// Get the flee threshold for this faction.
pub fn flee_threshold(&self) -> f32 {
match self.profile {
FactionAiProfile::Concord => 0.1, // Rarely flee (law enforcement)
FactionAiProfile::Amarr => 0.25, // Flee at 25% hull
FactionAiProfile::Minmatar => 0.3, // Flee at 30% hull
FactionAiProfile::Gallente => 0.35,
FactionAiProfile::Caldari => 0.2, // Calculated retreat
FactionAiProfile::Pirates => 0.5, // Flee easily (opportunistic)
FactionAiProfile::Independent => 0.35,
}
}
}

View File

@@ -0,0 +1,87 @@
//! AI system for NPC behavior.
//!
//! This module provides a state machine-based AI system for NPC ships
//! and entities. It includes:
//!
//! - **State Machine**: Hierarchical behavior states (Idle, Patrol, Combat, Flee, Mining, Trading)
//! - **Navigation**: Pathfinding and movement using the existing movement system
//! - **Perception**: Event-based detection of player and other entities
//! - **Spawning**: NPC ship spawning based on system security and faction
//! - **Faction AI**: Faction-specific behavior profiles
mod behavior;
mod faction;
mod navigation;
mod perception;
mod spawning;
mod states;
pub use faction::{FactionAiBehavior, FactionAiProfile};
pub use navigation::{AiNavigation, assign_movement_target};
pub use perception::{Perceptible, Perception, PerceptionEvent, update_perception};
pub use spawning::{NpcMetadata, NpcSpawner, spawn_station_traffic, spawn_system_npcs};
pub use states::{
AiState, AiStateMachine, BehaviorState, CombatState, FleeState,
MiningState, PatrolState, StateTransition, TradingState,
};
pub use behavior::{execute_patrol, execute_combat, execute_flee, execute_mining, execute_trading};
use bevy::prelude::*;
use crate::state::AppState;
/// Main AI plugin that orchestrates all AI systems.
pub struct AiPlugin;
impl Plugin for AiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AiStateMachine>()
.add_event::<PerceptionEvent>()
.add_event::<StateTransition>()
// AI spawning systems - run when entering in-game state
.add_systems(OnEnter(AppState::InGame), spawning::spawn_system_npcs)
// AI update systems - run every frame during gameplay
.add_systems(
Update,
(
perception::update_perception,
navigation::update_ai_navigation,
behavior::update_state_transitions,
behavior::execute_patrol,
behavior::execute_combat,
behavior::execute_flee,
behavior::execute_mining,
behavior::execute_trading,
)
.chain()
.run_if(in_state(AppState::InGame)),
);
}
}
/// Configuration resource for AI behavior tuning.
#[derive(Resource, Debug, Clone)]
pub struct AiConfig {
/// Detection range for perception (in world units)
pub perception_range: f32,
/// How often AI updates their decisions (in seconds)
pub decision_interval: f32,
/// Range at which NPCs engage in combat
pub combat_range: f32,
/// Hull percentage at which NPCs flee
pub flee_hull_threshold: f32,
/// Distance considered "safe" when fleeing
pub safe_flee_distance: f32,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
perception_range: 500.0,
decision_interval: 0.5,
combat_range: 200.0,
flee_hull_threshold: 0.3, // 30% hull
safe_flee_distance: 800.0,
}
}
}

View File

@@ -0,0 +1,120 @@
//! AI navigation system.
//!
//! Handles AI movement and pathfinding using the existing movement system.
//! NPCs use the same movement components as the player but are driven by
//! AI decisions rather than player input.
use bevy::prelude::*;
use crate::gameplay::movement::components::MoveTarget;
use crate::gameplay::ai::{AiConfig, AiState, BehaviorState};
/// Component for AI-controlled navigation.
#[derive(Component, Debug, Clone)]
pub struct AiNavigation {
/// Current movement target (world position)
pub movement_target: Option<Vec3>,
/// Distance threshold to consider target reached
pub arrival_distance: f32,
/// Whether we're currently moving toward target
pub is_moving: bool,
/// Preferred speed (as fraction of max speed)
pub speed_factor: f32,
}
impl Default for AiNavigation {
fn default() -> Self {
Self {
movement_target: None,
arrival_distance: 10.0,
is_moving: false,
speed_factor: 0.8,
}
}
}
/// Assign a movement target to an AI entity.
///
/// This function adds or updates the MoveTarget component for an NPC
/// and updates the AiNavigation component accordingly.
pub fn assign_movement_target(
commands: &mut Commands,
entity: Entity,
target: Vec3,
arrival_distance: f32,
speed_factor: f32,
) {
commands.entity(entity).insert(MoveTarget(target));
commands.entity(entity).insert(AiNavigation {
movement_target: Some(target),
arrival_distance,
is_moving: true,
speed_factor,
});
}
/// Clear movement target for an AI entity.
pub fn clear_movement_target(commands: &mut Commands, entity: Entity) {
commands.entity(entity).remove::<MoveTarget>();
commands.entity(entity).insert(AiNavigation {
movement_target: None,
arrival_distance: 10.0,
is_moving: false,
speed_factor: 0.8,
});
}
/// Update AI navigation based on current behavior state.
///
/// This system runs every frame and updates movement targets based on
/// the NPC's current behavior state and state-specific data.
pub fn update_ai_navigation(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut AiNavigation,
&Transform,
)>,
) {
for (entity, mut ai_state, mut navigation, transform) in ai_query.iter_mut() {
// Check if we've arrived at our current target
if let Some(target) = navigation.movement_target {
let distance_to_target = transform.translation.distance(target);
if distance_to_target < navigation.arrival_distance {
// We've arrived - clear the movement target
navigation.is_moving = false;
navigation.movement_target = None;
commands.entity(entity).remove::<MoveTarget>();
}
}
// Update navigation based on behavior state
match ai_state.behavior {
BehaviorState::Idle => {
// No movement in idle state
if navigation.is_moving {
clear_movement_target(&mut commands, entity);
}
}
BehaviorState::Flee => {
// Flee movement is handled by flee behavior system
// Just update speed factor for emergency
navigation.speed_factor = 1.2; // 120% speed when fleeing
}
BehaviorState::Combat => {
// Combat maintains distance to target
navigation.speed_factor = 0.9; // 90% speed in combat
}
BehaviorState::Patrol | BehaviorState::Trading => {
// Normal movement speed
navigation.speed_factor = 0.7; // 70% speed for routine operations
}
BehaviorState::Mining => {
// No movement while mining - we stay at the belt
navigation.speed_factor = 0.0;
}
}
}
}

View File

@@ -0,0 +1,162 @@
//! AI perception system.
//!
//! Event-based detection system for AI entities to perceive the player
//! and other entities in the game world.
use bevy::prelude::*;
/// Marks an entity as perceptible to AI (can be detected).
#[derive(Component, Debug, Clone)]
pub struct Perceptible {
/// Detection priority (higher = more likely to be noticed)
pub priority: f32,
/// Whether this entity is hostile
pub is_hostile: bool,
/// Faction of this entity (for faction-based perception)
pub faction: Option<String>,
}
impl Default for Perceptible {
fn default() -> Self {
Self {
priority: 1.0,
is_hostile: false,
faction: None,
}
}
}
/// Perception component for AI entities.
#[derive(Component, Debug, Clone)]
pub struct Perception {
/// Entities currently perceived by this AI
pub perceived_entities: Vec<Entity>,
/// The most threatening perceived entity
pub primary_threat: Option<Entity>,
/// The most interesting perceived entity (for non-hostile interactions)
pub primary_interest: Option<Entity>,
/// Time since last perception update
pub time_since_last_update: f32,
}
impl Default for Perception {
fn default() -> Self {
Self {
perceived_entities: Vec::new(),
primary_threat: None,
primary_interest: None,
time_since_last_update: 0.0,
}
}
}
/// Event sent when an AI perceives something notable.
#[derive(Event, Debug, Clone)]
pub struct PerceptionEvent {
/// AI entity that perceived something
pub observer: Entity,
/// Entity that was perceived
pub perceived: Entity,
/// Type of perception event
pub event_type: PerceptionEventType,
}
/// Types of perception events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerceptionEventType {
/// Detected a new entity
Detected,
/// Lost track of an entity
Lost,
/// Entity became hostile
HostileDetected,
/// Entity is in trouble (low health, etc.)
DistressSignal,
/// Entity has valuable cargo
ValuableTarget,
}
/// Update perception for all AI entities.
///
/// This system runs every frame and updates each AI's perception
/// based on nearby entities and their perceptible components.
pub fn update_perception(
mut perception_query: Query<(
Entity,
&mut Perception,
&Transform,
)>,
perceptible_query: Query<(Entity, &Perceptible, &Transform)>,
mut perception_events: EventWriter<PerceptionEvent>,
time: Res<Time>,
) {
for (observer_entity, mut perception, observer_transform) in perception_query.iter_mut() {
perception.time_since_last_update += time.delta_secs();
// Update perception every 0.5 seconds
if perception.time_since_last_update < 0.5 {
continue;
}
perception.time_since_last_update = 0.0;
let previous_entities = std::mem::take(&mut perception.perceived_entities);
// Find all perceptible entities within range
for (perceived_entity, perceptible, perceived_transform) in perceptible_query.iter() {
// Don't perceive ourselves
if perceived_entity == observer_entity {
continue;
}
let distance = observer_transform
.translation
.distance(perceived_transform.translation);
// Detection range based on priority
let detection_range = 500.0 * perceptible.priority;
if distance < detection_range {
// Entity is perceived
perception.perceived_entities.push(perceived_entity);
// Check if this is a newly detected entity
if !previous_entities.contains(&perceived_entity) {
perception_events.send(PerceptionEvent {
observer: observer_entity,
perceived: perceived_entity,
event_type: if perceptible.is_hostile {
PerceptionEventType::HostileDetected
} else {
PerceptionEventType::Detected
},
});
}
// Update primary threat
if perceptible.is_hostile {
if perception.primary_threat.is_none()
|| distance < observer_transform.translation.distance(
// Can't access this easily without another query, simplify for now
Vec3::ZERO,
)
{
perception.primary_threat = Some(perceived_entity);
}
} else {
perception.primary_interest = Some(perceived_entity);
}
}
}
// Check for lost entities
for lost_entity in previous_entities {
if !perception.perceived_entities.contains(&lost_entity) {
perception_events.send(PerceptionEvent {
observer: observer_entity,
perceived: lost_entity,
event_type: PerceptionEventType::Lost,
});
}
}
}
}

View File

@@ -0,0 +1,311 @@
//! NPC spawning system.
//!
//! Handles spawning of NPC ships based on system security level,
//! faction presence, and gameplay requirements.
use bevy::prelude::*;
use crate::gameplay::ai::{AiState, BehaviorState};
use crate::gameplay::galaxy::{Identifiable, StarSystem};
use crate::gameplay::in_system::ActiveSystem;
use crate::gameplay::movement::components::{MaxSpeed, TurnRate, Velocity};
/// Metadata for spawned NPCs.
#[derive(Component, Debug, Clone)]
pub struct NpcMetadata {
/// NPC identifier
pub id: String,
/// Display name
pub name: String,
/// Faction this NPC belongs to
pub faction: String,
/// NPC type (patrol, pirate, trader, miner)
pub npc_type: NpcType,
/// Spawn level (affects stats)
pub spawn_level: u32,
}
/// Types of NPCs that can be spawned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NpcType {
/// Faction patrol ship
Patrol,
/// Pirate / hostile
Pirate,
/// Trading vessel
Trader,
/// Mining vessel
Miner,
/// Station traffic (shuttles, etc.)
StationTraffic,
}
/// NPC spawner resource.
#[derive(Resource, Debug, Clone)]
pub struct NpcSpawner {
/// Whether to spawn NPCs in the current system
pub spawn_enabled: bool,
/// Maximum NPCs per system
pub max_npcs_per_system: u32,
/// Spawn multiplier (for difficulty scaling)
pub spawn_multiplier: f32,
}
impl Default for NpcSpawner {
fn default() -> Self {
Self {
spawn_enabled: true,
max_npcs_per_system: 15,
spawn_multiplier: 1.0,
}
}
}
/// Spawn NPCs for the current system when entering gameplay.
///
/// This runs on AppState::InGame enter and spawns appropriate NPCs
/// based on the active system's security level and faction.
pub fn spawn_system_npcs(
mut commands: Commands,
active_system: Res<ActiveSystem>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// For now, use a default security level since ActiveSystem doesn't store it
let security = 0.5; // Default mid-sec
let faction = "Concord"; // Default faction
// Determine spawn count based on security level
let base_spawn_count = match security {
sec if sec >= 0.8 => 8, // High-sec: more patrols
sec if sec >= 0.5 => 5, // Mid-sec: moderate
_ => 3, // Low-sec: fewer but more dangerous
};
let spawn_count = (base_spawn_count as f32) * 1.0; // Use spawn multiplier from resource if available
bevy::log::info!(
"Spawning {} NPCs for system {} (security: {})",
spawn_count as u32,
active_system.system_name,
security
);
// Spawn faction patrols for high-sec systems
if security >= 0.5 {
let patrol_count = (spawn_count * 0.6) as u32;
for i in 0..patrol_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Patrol,
i,
);
}
}
// Spawn pirates for low-sec systems
if security < 0.5 {
let pirate_count = (spawn_count * 0.4) as u32 + 1;
for i in 0..pirate_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Pirate,
i,
);
}
}
// Spawn traders for all systems
let trader_count = (spawn_count * 0.2) as u32 + 1;
for i in 0..trader_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Trader,
i,
);
}
// Spawn miners for systems with asteroid belts
let miner_count = (spawn_count * 0.2) as u32 + 1;
for i in 0..miner_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Miner,
i,
);
}
}
/// Spawn a single NPC ship.
fn spawn_npc(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
system_name: &str,
faction: &str,
npc_type: NpcType,
index: u32,
) {
// Determine faction and appearance based on NPC type
let (npc_faction, color, name_prefix) = match npc_type {
NpcType::Patrol => (
faction.to_string(),
get_faction_color(faction),
"PATROL",
),
NpcType::Pirate => (
"Pirates".to_string(),
[0.8, 0.2, 0.2], // Red
"PIRATE",
),
NpcType::Trader => (
"Commerce".to_string(),
[0.2, 0.6, 0.8], // Blue
"TRADER",
),
NpcType::Miner => (
"MiningGuild".to_string(),
[0.6, 0.6, 0.2], // Yellow
"MINER",
),
NpcType::StationTraffic => (
"Station".to_string(),
[0.5, 0.5, 0.5], // Gray
"TRAFFIC",
),
};
// Generate spawn position (random position within system bounds)
let angle = (index as f32) * 0.5; // Spread out spawns
let radius = 150.0 + (index as f32) * 50.0;
let spawn_pos = Vec3::new(
angle.cos() * radius,
0.0,
angle.sin() * radius,
);
// Create NPC entity
let npc_id = format!("npc-{}-{}", name_prefix, index);
let npc_name = format!("{} {}", name_prefix, index + 1);
let mut entity = commands.spawn((
// Transform
Transform::from_translation(spawn_pos),
Visibility::default(),
InheritedVisibility::default(),
// NPC metadata
NpcMetadata {
id: npc_id.clone(),
name: npc_name.clone(),
faction: npc_faction.clone(),
npc_type,
spawn_level: 1,
},
// AI state
AiState {
behavior: match npc_type {
NpcType::Patrol => BehaviorState::Patrol,
NpcType::Pirate => BehaviorState::Idle,
NpcType::Trader => BehaviorState::Trading,
NpcType::Miner => BehaviorState::Mining,
NpcType::StationTraffic => BehaviorState::Idle,
},
..default()
},
// Identifiable for targeting system
Identifiable {
id: npc_id,
display_name: npc_name.clone(),
classification: crate::gameplay::galaxy::Classification::Ship,
},
// Movement components
MaxSpeed(50.0),
TurnRate(2.0),
Velocity::default(),
// Visual representation (simple cone for now)
Mesh3d(meshes.add(Cone::new(2.0, 8.0).mesh())),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(color[0], color[1], color[2]),
emissive: LinearRgba::new(color[0] * 0.3, color[1] * 0.3, color[2] * 0.3, 1.0),
..default()
})),
));
// Add state-specific components
match npc_type {
NpcType::Patrol => {
entity.insert(crate::gameplay::ai::PatrolState::default());
}
NpcType::Pirate => {
// Pirates don't have special state - they use base AiState
}
NpcType::Trader => {
entity.insert(crate::gameplay::ai::TradingState::default());
}
NpcType::Miner => {
entity.insert(crate::gameplay::ai::MiningState::default());
}
NpcType::StationTraffic => {
// Traffic is idle most of the time
}
}
bevy::log::debug!("Spawned NPC: {} at {:?}", npc_name.clone(), spawn_pos);
}
/// Spawn station traffic (NPCs that dock/undock at stations).
pub fn spawn_station_traffic(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
active_system: &ActiveSystem,
) {
// Spawn 2-3 traffic NPCs per station
let traffic_count = 3;
let faction = "Concord"; // Default faction
for i in 0..traffic_count {
spawn_npc(
commands,
meshes,
materials,
&active_system.system_name,
faction,
NpcType::StationTraffic,
i,
);
}
}
/// Get the color for a faction.
fn get_faction_color(faction: &str) -> [f32; 3] {
match faction {
"Concord" => [0.13, 0.83, 0.93], // cyan
"Amarr" => [0.96, 0.62, 0.04], // amber
"Minmatar" => [0.94, 0.27, 0.27], // red
"Gallente" => [0.66, 0.33, 0.97], // purple
"Caldari" => [0.22, 0.74, 0.97], // blue
_ => [0.5, 0.5, 0.5], // gray (default)
}
}

View File

@@ -0,0 +1,207 @@
//! AI state machine definitions.
//!
//! Defines the hierarchical behavior state machine for NPC entities.
//! States include Idle, Patrol, Combat, Flee, Mining, and Trading.
use bevy::prelude::*;
/// The main behavior states for NPC AI.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BehaviorState {
/// No active behavior - waiting for stimulus
#[default]
Idle,
/// Following a patrol route or pattern
Patrol,
/// Engaging in combat with a hostile target
Combat,
/// Fleeing from a threat
Flee,
/// Mining at an asteroid belt
Mining,
/// Trading between stations
Trading,
}
/// Current AI state component - attached to all NPC entities.
#[derive(Component, Debug, Clone)]
pub struct AiState {
/// Current behavior state
pub behavior: BehaviorState,
/// Time since last state change
pub time_in_state: f32,
/// Current target entity (if any)
pub target_entity: Option<Entity>,
/// Current target position (if any)
pub target_position: Option<Vec3>,
/// Hull percentage (0.0 to 1.0) - affects state transitions
pub hull_percentage: f32,
}
impl Default for AiState {
fn default() -> Self {
Self {
behavior: BehaviorState::Idle,
time_in_state: 0.0,
target_entity: None,
target_position: None,
hull_percentage: 1.0,
}
}
}
/// State-specific data for patrol behavior.
#[derive(Component, Debug, Clone)]
pub struct PatrolState {
/// List of waypoints in the patrol route
pub waypoints: Vec<Vec3>,
/// Current waypoint index
pub current_waypoint: usize,
/// Whether to loop the patrol route
pub loop_route: bool,
/// Wait time at each waypoint (seconds)
pub waypoint_wait_time: f32,
/// Time spent at current waypoint
pub time_at_waypoint: f32,
}
impl Default for PatrolState {
fn default() -> Self {
Self {
waypoints: Vec::new(),
current_waypoint: 0,
loop_route: true,
waypoint_wait_time: 2.0,
time_at_waypoint: 0.0,
}
}
}
/// State-specific data for combat behavior.
#[derive(Component, Debug, Clone)]
pub struct CombatState {
/// Target entity we're fighting
pub target: Option<Entity>,
/// Preferred combat distance
pub preferred_distance: f32,
/// Time since last attack
pub time_since_last_attack: f32,
/// Attack cooldown (seconds)
pub attack_cooldown: f32,
/// Whether we're currently attacking
pub is_attacking: bool,
}
impl Default for CombatState {
fn default() -> Self {
Self {
target: None,
preferred_distance: 100.0,
time_since_last_attack: 0.0,
attack_cooldown: 3.0,
is_attacking: false,
}
}
}
/// State-specific data for flee behavior.
#[derive(Component, Debug, Clone)]
pub struct FleeState {
/// Entity we're fleeing from
pub threat: Option<Entity>,
/// Direction we're fleeing (normalized)
pub flee_direction: Vec3,
/// Distance we've fled
pub distance_fled: f32,
/// Whether we've reached safe distance
pub reached_safe_distance: bool,
}
impl Default for FleeState {
fn default() -> Self {
Self {
threat: None,
flee_direction: Vec3::ZERO,
distance_fled: 0.0,
reached_safe_distance: false,
}
}
}
/// State-specific data for mining behavior.
#[derive(Component, Debug, Clone)]
pub struct MiningState {
/// Asteroid belt we're mining at
pub belt_entity: Option<Entity>,
/// Time spent mining
pub mining_time: f32,
/// Mining cycle duration (seconds)
pub mining_cycle_duration: f32,
/// Cargo capacity remaining
pub cargo_remaining: f32,
}
impl Default for MiningState {
fn default() -> Self {
Self {
belt_entity: None,
mining_time: 0.0,
mining_cycle_duration: 8.0,
cargo_remaining: 100.0,
}
}
}
/// State-specific data for trading behavior.
#[derive(Component, Debug, Clone)]
pub struct TradingState {
/// Current destination station
pub destination_station: Option<Entity>,
/// Current trade route waypoints
pub trade_route: Vec<Vec3>,
/// Current waypoint in trade route
pub current_waypoint: usize,
/// Time until next trade decision
pub time_until_next_decision: f32,
}
impl Default for TradingState {
fn default() -> Self {
Self {
destination_station: None,
trade_route: Vec::new(),
current_waypoint: 0,
time_until_next_decision: 5.0,
}
}
}
/// Event sent when an AI entity transitions to a new state.
#[derive(Event, Debug, Clone)]
pub struct StateTransition {
/// Entity that changed state
pub entity: Entity,
/// Previous state
pub from_state: BehaviorState,
/// New state
pub to_state: BehaviorState,
/// Reason for transition (for debugging/logging)
pub reason: String,
}
/// Resource that tracks all AI state machines for debugging and monitoring.
#[derive(Resource, Debug, Default)]
pub struct AiStateMachine {
/// Count of NPCs in each behavior state
pub state_counts: [usize; 6],
}
impl AiStateMachine {
/// Record a state transition for statistics.
pub fn record_transition(&mut self, from: BehaviorState, to: BehaviorState) {
// Decrement old state count
self.state_counts[from as usize] = self.state_counts[from as usize].saturating_sub(1);
// Increment new state count
self.state_counts[to as usize] += 1;
}
}

View File

@@ -753,6 +753,7 @@ pub fn spawn_system_contents(
contents: &SystemContents,
star_entity: Entity,
assets: &ContentAssets,
materials: &mut Assets<StandardMaterial>,
) {
// ── Planets ─────────────────────────────────────────────────────────────
for planet in &contents.planets {
@@ -786,6 +787,18 @@ pub fn spawn_system_contents(
if planet.habitable {
entity.insert(HabitablePlanet);
}
// Add city lights for high-population planets
if let Some(city_lights_mat) = city_lights_material(materials, planet.planet_type, planet.population) {
entity.with_children(|planet| {
planet.spawn((
Mesh3d(assets.planet_mesh.clone()),
MeshMaterial3d(city_lights_mat),
// Slightly larger sphere for city lights glow
Transform::from_scale(Vec3::splat(1.02)),
));
});
}
}
// ── Asteroid belts ──────────────────────────────────────────────────────
@@ -853,6 +866,8 @@ pub fn spawn_system_contents(
for station in &contents.stations {
let local = orbital_position(station.orbit, station.phase);
let cube_size = 0.16;
// Use population-based material for visual indicator
let station_mat = population_station_material(materials, station.population);
parent.spawn((
Station,
Orbital {
@@ -871,7 +886,7 @@ pub fn spawn_system_contents(
classification: Classification::Structure,
},
Mesh3d(assets.station_mesh.clone()),
MeshMaterial3d(assets.station_material.clone()),
MeshMaterial3d(station_mat),
Transform::from_translation(local).with_scale(Vec3::splat(cube_size)),
));
}
@@ -1070,6 +1085,56 @@ fn translucent_material(
})
}
/// Create a population-based emissive material for stations.
/// Higher population = brighter, more cyan glow (civilization lights).
fn population_station_material(
materials: &mut Assets<StandardMaterial>,
population: u32,
) -> Handle<StandardMaterial> {
// Base cyan color for stations
let (base_r, base_g, base_b) = (0.13, 0.83, 0.93);
// Scale emissive intensity by population (logarithmic scale)
// Pop 10k -> 1.0x, Pop 10M -> 2.5x
let pop_factor = 1.0 + (population as f32).log10() * 0.5;
let intensity = (pop_factor * 1.4).min(3.5);
materials.add(StandardMaterial {
base_color: Color::srgb(base_r, base_g, base_b),
emissive: LinearRgba::new(base_r * intensity, base_g * intensity, base_b * intensity, 1.0),
unlit: true,
..default()
})
}
/// Create city lights material for high-population planets.
/// Returns Some material if population > 100,000, None otherwise.
fn city_lights_material(
materials: &mut Assets<StandardMaterial>,
planet_type: PlanetType,
population: u32,
) -> Option<Handle<StandardMaterial>> {
// Only show city lights on high-population habitable worlds
const CITY_LIGHTS_THRESHOLD: u32 = 100_000;
if population < CITY_LIGHTS_THRESHOLD {
return None;
}
// Warm yellow-white lights for cities
let (r, g, b) = (1.0, 0.95, 0.8);
// Scale by population
let pop_factor = 1.0 + (population as f32).log10() * 0.3;
let intensity = (pop_factor * 0.8).min(2.0).min(population as f32 / 500_000.0);
Some(materials.add(StandardMaterial {
base_color: Color::srgba(r, g, b, 0.0), // Transparent base
emissive: LinearRgba::new(r * intensity, g * intensity, b * intensity, 1.0),
unlit: true,
alpha_mode: AlphaMode::Blend,
..default()
}))
}
/// Slugify a display name for use as part of an [`Identifiable::id`]:
/// lowercase, spaces → hyphens. Good enough for stable, human-readable IDs.
fn slug(s: &str) -> String {

View File

@@ -112,8 +112,6 @@ const FACTIONS: &[(&str, [f32; 3])] = &[
("Caldari", [0.22, 0.74, 0.97]), // blue
];
const OUTER_STARTING_SYSTEM_RADIUS: f32 = 0.65;
#[derive(Debug, Clone)]
pub struct StartingBaseCandidate {
pub id: String,
@@ -223,7 +221,6 @@ pub fn generate_galaxy(params: &GalaxyParams) -> GeneratedGalaxy {
impl GeneratedGalaxy {
pub fn starting_base_map(&self) -> StartingBaseMap {
let outer_radius = self.params.size * OUTER_STARTING_SYSTEM_RADIUS;
let mut candidates = Vec::new();
let mut map_systems = Vec::with_capacity(self.systems.len());
@@ -242,7 +239,13 @@ impl GeneratedGalaxy {
for (system, contents) in self.systems.iter().zip(self.contents.iter()) {
let distance_from_core = system.position.length();
if !system.is_core && distance_from_core >= outer_radius {
// A system is valid for starting if it has:
// - At least one station (stations are always dockable)
// - OR at least one inhabited planet (population > 0)
let has_dockable = !contents.stations.is_empty()
|| contents.planets.iter().any(|p| p.population > 0);
if !system.is_core && has_dockable {
candidates.push(StartingBaseCandidate {
id: system.id.clone(),
name: system.name.clone(),
@@ -801,6 +804,7 @@ fn spawn_galaxy_scene(
sys_contents,
star_entity,
&content_assets,
materials,
);
});
}
@@ -938,7 +942,6 @@ mod tests {
fn starting_base_map_uses_saved_galaxy_without_regeneration() {
let galaxy = generate_galaxy(&GalaxyParams::default());
let map = galaxy.starting_base_map();
let outer_radius = galaxy.params.size * OUTER_STARTING_SYSTEM_RADIUS;
assert!(map.candidates.iter().all(|candidate| {
galaxy
@@ -946,10 +949,20 @@ mod tests {
.iter()
.any(|system| system.id == candidate.id)
}));
assert!(map
.candidates
.iter()
.all(|candidate| candidate.distance_from_core >= outer_radius));
// Candidates must have dockable locations (stations or inhabited planets) and not be core systems
assert!(map.candidates.iter().all(|candidate| {
let index = galaxy
.systems
.iter()
.position(|system| system.id == candidate.id);
index.is_some_and(|index| {
let system = &galaxy.systems[index];
let contents = &galaxy.contents[index];
!system.is_core
&& (!contents.stations.is_empty()
|| contents.planets.iter().any(|p| p.population > 0))
})
}));
assert!(map.candidates.iter().all(|candidate| {
let index = galaxy
.systems

View File

@@ -13,7 +13,8 @@ use crate::gameplay::movement::components::{Velocity, MoveTarget};
use crate::gameplay::galaxy::Identifiable;
use super::{DockedState, UndockEvent};
use super::scene::{Docked, PlayerShip};
use super::flight_ui::setup_flight_ui;
// UI removed - no longer needed
// use super::flight_ui::setup_flight_ui;
/// Flight state component attached to the player ship when actively flying.
#[derive(Component, Debug, Clone, Default)]
@@ -53,7 +54,7 @@ fn handle_undock(
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
player_query: Query<(Entity, &Transform), (With<PlayerShip>, With<Docked>)>,
docked_ui_query: Query<Entity, With<super::ui::DockedUi>>,
// docked_ui_query removed - UI no longer needed
) {
for event in events.read() {
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
@@ -86,19 +87,19 @@ fn handle_undock(
// Update docked state resource
docked_state.undock();
// Transition camera to follow mode
// Transition camera to tactical follow mode (isometric view)
camera_state.mode = CameraMode::Follow;
camera_state.target_entity = Some(player_entity);
camera_state.follow_distance = 15.0;
camera_state.follow_height = 5.0;
camera_state.follow_distance = 45.0; // Higher for tactical view
camera_state.follow_height = 35.0; // Isometric angle
// Spawn flight HUD
setup_flight_ui(commands.reborrow());
// UI removed - gameplay only
// setup_flight_ui(commands.reborrow());
// Despawn docked UI
for entity in docked_ui_query.iter() {
commands.entity(entity).despawn();
}
// Despawn docked UI (commented out - UI being removed)
// for entity in docked_ui_query.iter() {
// commands.entity(entity).despawn();
// }
bevy::log::info!("Transitioned to flight mode");
}
@@ -111,7 +112,7 @@ fn handle_docking(
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
identifiable_query: Query<&Identifiable>,
flight_ui_query: Query<Entity, With<super::flight_ui::FlightUi>>,
// flight_ui_query removed - UI no longer needed
) {
for event in events.read() {
bevy::log::info!("Handling docking at target {:?}", event.station);
@@ -137,13 +138,13 @@ fn handle_docking(
camera_state.mode = CameraMode::Cinematic;
camera_state.target_entity = Some(event.station);
// UI removed - no longer needed
// Despawn flight HUD
for entity in flight_ui_query.iter() {
commands.entity(entity).despawn();
}
// for entity in flight_ui_query.iter() {
// commands.entity(entity).despawn();
// }
// Respawn docked UI
super::ui::setup_docked_ui(commands.reborrow());
// super::ui::setup_docked_ui(commands.reborrow());
bevy::log::info!("Docked at {}", identifiable.display_name);
}

View File

@@ -39,24 +39,27 @@ impl Plugin for InSystemPlugin {
OnEnter(AppState::InGame),
(
scene::setup_in_system_view,
ui::setup_docked_ui,
// UI removed - no longer needed
// ui::setup_docked_ui,
add_targetable_to_pois,
).chain(),
)
.add_systems(
OnExit(AppState::InGame),
(
ui::despawn_docked_ui,
flight_ui::despawn_flight_ui,
// UI removed - no longer needed
// ui::despawn_docked_ui,
// flight_ui::despawn_flight_ui,
scene::despawn_in_system_scene,
).chain(),
)
.add_systems(
Update,
(
ui::refresh_docked_ui,
ui::undock_button_handler,
flight_ui::update_flight_ui,
// UI removed - no longer needed
// ui::refresh_docked_ui,
// ui::undock_button_handler,
// flight_ui::update_flight_ui,
handle_action_triggered,
)
.chain()

View File

@@ -248,9 +248,48 @@ fn spawn_system_scene(
contents,
star_entity,
&content_assets,
materials,
);
});
// Spawn tactical grid helper for spatial reference
// Matches the movement demo styling: subtle dark grid
const GRID_SIZE: f32 = 200.0;
const GRID_DIVISIONS: usize = 20;
const GRID_COLOR: Color = Color::srgba(0.05, 0.08, 0.13, 0.5); // #0d1520 with transparency
// Create grid lines
let step = GRID_SIZE / GRID_DIVISIONS as f32;
let half_size = GRID_SIZE * 0.5;
for i in 0..=GRID_DIVISIONS {
let offset = (i as f32 * step) - half_size;
// X-axis line
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(GRID_SIZE, 0.02, 0.02))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: GRID_COLOR,
unlit: true,
..default()
})),
Transform::from_translation(Vec3::new(0.0, -2.0, offset)),
InSystemSpawned,
));
// Z-axis line
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(0.02, 0.02, GRID_SIZE))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: GRID_COLOR,
unlit: true,
..default()
})),
Transform::from_translation(Vec3::new(offset, -2.0, 0.0)),
InSystemSpawned,
));
}
// If we have a docking target, spawn player ship docked at it
let station_entity = if let Some(target) = docking_target {
// Calculate target position

View File

@@ -1,8 +1,10 @@
pub mod ai;
pub mod campaign;
pub mod character_creation;
pub mod galaxy;
pub mod in_system;
pub mod movement;
pub mod narrative;
pub mod physics;
pub mod star_map;
pub mod starting_base;

View File

@@ -0,0 +1,212 @@
//! Event logging system.
//!
//! Captures all player actions as structured events that become
//! the foundation for narrative generation.
use bevy::prelude::*;
use std::collections::HashMap;
use crate::gameplay::narrative::CampaignHistory;
/// All possible game events that can occur in a campaign.
#[derive(Event, Debug, Clone)]
pub enum GameEvent {
/// Player mined ore from an asteroid belt
Mining(MiningEvent),
/// Player bought or sold items at a station
Trade(TradeEvent),
/// Player engaged in combat
Combat(CombatEvent),
/// Player discovered a new system or POI
Exploration(ExplorationEvent),
/// Player docked at a station
Docking(DockingEvent),
/// Player accepted/abandoned/failed a mission
Mission(MissionEvent),
/// Custom event for extensible storytelling
Custom(CustomEvent),
}
/// Event from mining activities.
#[derive(Debug, Clone)]
pub struct MiningEvent {
pub timestamp: f64,
pub location: String,
pub ore_type: String,
pub quantity: u32,
pub duration_seconds: f32,
}
/// Event from trading activities.
#[derive(Debug, Clone)]
pub struct TradeEvent {
pub timestamp: f64,
pub station: String,
pub faction: String,
pub is_purchase: bool,
pub item_name: String,
pub quantity: u32,
pub unit_price: f32,
pub total_value: f32,
}
/// Event from combat activities.
#[derive(Debug, Clone)]
pub struct CombatEvent {
pub timestamp: f64,
pub location: String,
pub opponent: String,
pub opponent_faction: Option<String>,
pub outcome: CombatOutcome,
pub hull_remaining: f32,
pub rewards_earned: Option<f32>,
}
/// Outcome of a combat engagement.
#[derive(Debug, Clone)]
pub enum CombatOutcome {
Victory,
Defeat,
Retreat,
Draw,
}
/// Event from exploration activities.
#[derive(Debug, Clone)]
pub struct ExplorationEvent {
pub timestamp: f64,
pub location: String,
pub discovery_type: DiscoveryType,
pub description: String,
}
/// Types of discoveries.
#[derive(Debug, Clone)]
pub enum DiscoveryType {
NewSystem,
NewStation,
Anomaly,
Derelict,
ResourceDeposit,
}
/// Event from docking activities.
#[derive(Debug, Clone)]
pub struct DockingEvent {
pub timestamp: f64,
pub station: String,
pub system: String,
pub faction: String,
pub visit_duration_seconds: f32,
}
/// Event from mission activities.
#[derive(Debug, Clone)]
pub struct MissionEvent {
pub timestamp: f64,
pub mission_id: String,
pub mission_type: String,
pub event_type: MissionEventType,
pub outcome: Option<MissionOutcome>,
}
/// Type of mission event.
#[derive(Debug, Clone)]
pub enum MissionEventType {
Accepted,
Completed,
Abandoned,
Failed,
}
/// Outcome of a completed mission.
#[derive(Debug, Clone)]
pub enum MissionOutcome {
Success,
PartialSuccess,
Failure,
}
/// Custom event for extensible storytelling.
#[derive(Debug, Clone)]
pub struct CustomEvent {
pub timestamp: f64,
pub event_type: String,
pub title: String,
pub description: String,
pub metadata: HashMap<String, String>,
}
impl GameEvent {
/// Get the timestamp of this event.
pub fn timestamp(&self) -> f64 {
match self {
GameEvent::Mining(e) => e.timestamp,
GameEvent::Trade(e) => e.timestamp,
GameEvent::Combat(e) => e.timestamp,
GameEvent::Exploration(e) => e.timestamp,
GameEvent::Docking(e) => e.timestamp,
GameEvent::Mission(e) => e.timestamp,
GameEvent::Custom(e) => e.timestamp,
}
}
/// Get a short title for this event.
pub fn title(&self) -> String {
match self {
GameEvent::Mining(e) => format!("Mined {} {}", e.quantity, e.ore_type),
GameEvent::Trade(e) => {
if e.is_purchase {
format!("Purchased {} {}", e.quantity, e.item_name)
} else {
format!("Sold {} {}", e.quantity, e.item_name)
}
}
GameEvent::Combat(e) => match e.outcome {
CombatOutcome::Victory => format!("Victory over {}", e.opponent),
CombatOutcome::Defeat => format!("Defeated by {}", e.opponent),
CombatOutcome::Retreat => format!("Retreated from {}", e.opponent),
CombatOutcome::Draw => format!("Draw with {}", e.opponent),
},
GameEvent::Exploration(e) => match e.discovery_type {
DiscoveryType::NewSystem => format!("Discovered system: {}", e.location),
DiscoveryType::NewStation => format!("Discovered station: {}", e.location),
DiscoveryType::Anomaly => format!("Found anomaly: {}", e.description),
DiscoveryType::Derelict => format!("Found derelict: {}", e.description),
DiscoveryType::ResourceDeposit => format!("Found resource: {}", e.description),
},
GameEvent::Docking(e) => format!("Docked at {}", e.station),
GameEvent::Mission(e) => format!(
"{:?} mission: {}",
e.event_type, e.mission_id
),
GameEvent::Custom(e) => e.title.clone(),
}
}
/// Get the location where this event occurred.
pub fn location(&self) -> String {
match self {
GameEvent::Mining(e) => e.location.clone(),
GameEvent::Trade(e) => e.station.clone(),
GameEvent::Combat(e) => e.location.clone(),
GameEvent::Exploration(e) => e.location.clone(),
GameEvent::Docking(e) => e.system.clone(),
GameEvent::Mission(e) => "Unknown".to_string(),
GameEvent::Custom(e) => e.metadata.get("location").cloned().unwrap_or_default(),
}
}
}
/// Log game events to campaign history.
///
/// This system listens for GameEvents and adds them to the CampaignHistory.
pub fn log_game_events(
mut events: EventReader<GameEvent>,
mut history: ResMut<CampaignHistory>,
) {
for event in events.read() {
bevy::log::debug!("Logging game event: {}", event.title());
history.add_event(event.clone());
}
}

View File

@@ -0,0 +1,313 @@
//! Campaign history tracking.
//!
//! Maintains the timeline of all events in a campaign with chapter
//! divisions and key moment detection.
use bevy::prelude::*;
use std::collections::HashMap;
use crate::gameplay::narrative::{GameEvent, NarrativeConfig};
/// Complete campaign history with timeline and chapters.
#[derive(Resource, Debug, Clone)]
pub struct CampaignHistory {
/// All events in chronological order
pub events: Vec<GameEvent>,
/// Detected chapters in the campaign
pub chapters: Vec<Chapter>,
/// Key moments (significant events)
pub key_moments: Vec<KeyMoment>,
/// Campaign statistics
pub statistics: CampaignStatistics,
/// Time since last chapter detection
pub time_since_chapter_check: f32,
}
impl Default for CampaignHistory {
fn default() -> Self {
Self {
events: Vec::new(),
chapters: Vec::new(),
key_moments: Vec::new(),
statistics: CampaignStatistics::default(),
time_since_chapter_check: 0.0,
}
}
}
impl CampaignHistory {
/// Add a new event to the campaign history.
pub fn add_event(&mut self, event: GameEvent) {
// Update statistics before consuming the event
self.update_statistics(&event);
// Check if this is a key moment
if self.is_key_moment(&event) {
self.key_moments.push(KeyMoment {
event_index: self.events.len(),
timestamp: event.timestamp(),
significance: self.calculate_significance(&event),
description: event.title(),
});
}
// Finally, consume the event by pushing it to the history
self.events.push(event);
}
/// Get the most recent N events.
pub fn recent_events(&self, count: usize) -> &[GameEvent] {
let start = self.events.len().saturating_sub(count);
&self.events[start..]
}
/// Get all events since a given timestamp.
pub fn events_since(&self, timestamp: f64) -> &[GameEvent] {
let start = self
.events
.partition_point(|e| e.timestamp() < timestamp);
&self.events[start..]
}
/// Check if an event is a key moment.
fn is_key_moment(&self, event: &GameEvent) -> bool {
match event {
GameEvent::Combat(e) => {
matches!(e.outcome, crate::gameplay::narrative::CombatOutcome::Victory)
&& e.hull_remaining < 0.3
}
GameEvent::Exploration(e) => {
matches!(
e.discovery_type,
crate::gameplay::narrative::DiscoveryType::NewSystem
)
}
GameEvent::Mission(e) => {
matches!(
e.event_type,
crate::gameplay::narrative::MissionEventType::Completed
)
}
_ => false,
}
}
/// Calculate the significance score of an event (0.0 to 1.0).
fn calculate_significance(&self, event: &GameEvent) -> f32 {
match event {
GameEvent::Combat(e) => {
let base = 0.7;
let bonus = if e.hull_remaining < 0.3 { 0.2 } else { 0.0 };
base + bonus
}
GameEvent::Exploration(_) => 0.6,
GameEvent::Mission(_) => 0.5,
GameEvent::Trade(_) => 0.2,
GameEvent::Mining(_) => 0.1,
GameEvent::Docking(_) => 0.05,
GameEvent::Custom(_) => 0.4,
}
}
/// Update campaign statistics based on a new event.
fn update_statistics(&mut self, event: &GameEvent) {
match event {
GameEvent::Mining(_) => self.statistics.mining_operations += 1,
GameEvent::Trade(_) => self.statistics.trade_operations += 1,
GameEvent::Combat(e) => {
self.statistics.combat_operations += 1;
match e.outcome {
crate::gameplay::narrative::CombatOutcome::Victory => {
self.statistics.combat_victories += 1
}
crate::gameplay::narrative::CombatOutcome::Defeat => {
self.statistics.combat_defeats += 1
}
_ => {}
}
}
GameEvent::Exploration(_) => self.statistics.discoveries += 1,
GameEvent::Mission(_) => self.statistics.missions_completed += 1,
GameEvent::Docking(_) => self.statistics.docking_operations += 1,
GameEvent::Custom(_) => {}
}
}
}
/// A chapter in the campaign narrative.
#[derive(Debug, Clone)]
pub struct Chapter {
/// Chapter index (0-based)
pub index: usize,
/// Chapter title
pub title: String,
/// Start event index
pub start_event: usize,
/// End event index (exclusive)
pub end_event: usize,
/// Primary theme of this chapter
pub theme: ChapterTheme,
/// Summary of events in this chapter
pub summary: String,
/// Timestamp when chapter started
pub start_timestamp: f64,
}
/// Themes that chapters can have.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterTheme {
Exploration,
Commerce,
Conflict,
Diplomacy,
Mystery,
Survival,
Growth,
}
/// A key moment in the campaign.
#[derive(Debug, Clone)]
pub struct KeyMoment {
/// Index of the event in the campaign history
pub event_index: usize,
/// When this occurred
pub timestamp: f64,
/// How significant (0.0 to 1.0)
pub significance: f32,
/// Description
pub description: String,
}
/// Campaign statistics.
#[derive(Debug, Clone, Default)]
pub struct CampaignStatistics {
/// Total events logged
pub total_events: usize,
/// Mining operations performed
pub mining_operations: u32,
/// Trade operations performed
pub trade_operations: u32,
/// Combat operations performed
pub combat_operations: u32,
/// Combat victories
pub combat_victories: u32,
/// Combat defeats
pub combat_defeats: u32,
/// Discoveries made
pub discoveries: u32,
/// Missions completed
pub missions_completed: u32,
/// Docking operations
pub docking_operations: u32,
}
/// Detect chapters in the campaign history.
///
/// This system runs periodically and analyzes the event stream to
/// identify natural chapter boundaries based on event patterns.
pub fn detect_chapters(
mut history: ResMut<CampaignHistory>,
config: Res<NarrativeConfig>,
time: Res<Time>,
) {
history.time_since_chapter_check += time.delta_secs();
// Only check every 10 seconds
if history.time_since_chapter_check < 10.0 {
return;
}
history.time_since_chapter_check = 0.0;
// Only detect chapters if we have enough events
if history.events.len() < config.events_per_chapter {
return;
}
// Calculate expected chapter count
let expected_chapters = history.events.len() / config.events_per_chapter;
// Add new chapter if needed
if history.chapters.len() < expected_chapters {
let start_index = if let Some(last_chapter) = history.chapters.last() {
last_chapter.end_event
} else {
0
};
let end_index = (start_index + config.events_per_chapter).min(history.events.len());
// Determine theme from events in this range
let theme = detect_chapter_theme(&history.events[start_index..end_index]);
let chapter = Chapter {
index: history.chapters.len(),
title: format!("Chapter {}: {:?}", history.chapters.len() + 1, theme),
start_event: start_index,
end_event: end_index,
theme,
summary: generate_chapter_summary(&history.events[start_index..end_index]),
start_timestamp: history
.events
.get(start_index)
.map(|e| e.timestamp())
.unwrap_or(0.0),
};
bevy::log::info!("Detected new chapter: {}", chapter.title);
history.chapters.push(chapter);
}
}
/// Detect the theme of a chapter from its events.
fn detect_chapter_theme(events: &[GameEvent]) -> ChapterTheme {
let mut counts = HashMap::new();
for event in events {
let category = match event {
GameEvent::Mining(_) => "commerce",
GameEvent::Trade(_) => "commerce",
GameEvent::Combat(_) => "conflict",
GameEvent::Exploration(_) => "exploration",
GameEvent::Docking(_) => "commerce",
GameEvent::Mission(_) => "growth",
GameEvent::Custom(_) => "mystery",
};
*counts.entry(category).or_insert(0) += 1;
}
let max_category = counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(cat, _)| cat)
.unwrap_or("exploration");
match max_category {
"exploration" => ChapterTheme::Exploration,
"commerce" => ChapterTheme::Commerce,
"conflict" => ChapterTheme::Conflict,
"growth" => ChapterTheme::Growth,
"mystery" => ChapterTheme::Mystery,
_ => ChapterTheme::Exploration,
}
}
/// Generate a summary for a chapter from its events.
fn generate_chapter_summary(events: &[GameEvent]) -> String {
if events.is_empty() {
return "Empty chapter".to_string();
}
let start = events.first().unwrap();
let end = events.last().unwrap();
let duration_hours = (end.timestamp() - start.timestamp()) / 3600.0;
format!(
"Events from {:.1} hours of gameplay. Started with {}, ended with {}.",
duration_hours,
start.title(),
end.title()
)
}

View File

@@ -0,0 +1,70 @@
//! Narrative system for AI-generated storytelling.
//!
//! This module provides the foundation for the AI Story Director,
//! which tracks player actions and weaves them into an ongoing narrative.
//!
//! ## Components
//!
//! - **Event Logging**: Capture all player actions as structured events
//! - **Campaign History**: Maintain timeline of events with chapter divisions
//! - **Narrative Synthesis**: Generate story text from events (LLM-powered)
//! - **Story UI**: Display campaign narrative to players
mod events;
mod history;
mod synthesis;
mod ui;
pub use events::*;
pub use history::*;
pub use synthesis::*;
pub use ui::*;
use bevy::prelude::*;
use crate::state::AppState;
/// Main narrative plugin.
pub struct NarrativePlugin;
impl Plugin for NarrativePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<NarrativeConfig>()
.init_resource::<CampaignHistory>()
.add_event::<GameEvent>()
// Narrative systems run during gameplay
.add_systems(
Update,
events::log_game_events.run_if(in_state(AppState::InGame)),
)
// Chapter detection runs periodically
.add_systems(
Update,
history::detect_chapters.run_if(in_state(AppState::InGame)),
);
}
}
/// Configuration for narrative generation.
#[derive(Resource, Debug, Clone)]
pub struct NarrativeConfig {
/// Whether narrative generation is enabled
pub enabled: bool,
/// Events per chapter (approximate)
pub events_per_chapter: usize,
/// Whether to use LLM for synthesis (when available)
pub use_llm_synthesis: bool,
/// Update interval for story generation (seconds)
pub synthesis_interval: f32,
}
impl Default for NarrativeConfig {
fn default() -> Self {
Self {
enabled: true,
events_per_chapter: 25,
use_llm_synthesis: false, // Disabled until LLM integration
synthesis_interval: 60.0,
}
}
}

View File

@@ -0,0 +1,51 @@
//! Narrative synthesis engine.
//!
//! TODO: LLM-based narrative generation that weaves events into
//! coherent story text.
//!
//! This module will:
//! - Aggregate events into story beats
//! - Generate narrative text using LLM API (Claude)
//! - Maintain story coherence and continuity
//! - Handle character dialogue and scene descriptions
use bevy::prelude::*;
/// Placeholder for narrative synthesis functionality.
///
/// Full implementation will require:
/// - Event aggregation and pattern detection
/// - LLM API integration (Claude API)
/// - Story template system
/// - Character personality modeling
pub struct NarrativeSynthesis {
/// LLM API endpoint (when available)
pub api_endpoint: Option<String>,
/// Whether synthesis is available
pub available: bool,
}
impl Default for NarrativeSynthesis {
fn default() -> Self {
Self {
api_endpoint: None,
available: false,
}
}
}
/// Generate narrative text from events.
///
/// TODO: Implement LLM-based synthesis.
pub fn generate_narrative(_events: &[crate::gameplay::narrative::GameEvent]) -> String {
// Placeholder: returns a simple summary
// Full implementation will use LLM to generate engaging narrative
"Narrative synthesis not yet implemented. This will use the Claude API to generate compelling story text from campaign events.".to_string()
}
/// Generate chapter summary.
///
/// TODO: Implement with LLM synthesis.
pub fn generate_chapter_summary(_chapter: &super::Chapter) -> String {
"Chapter summary generation not yet implemented.".to_string()
}

View File

@@ -0,0 +1,64 @@
//! Story log UI system.
//!
//! TODO: In-game interface for reading campaign narrative with
//! chapter navigation and search functionality.
//!
//! This module will:
//! - Display story log with chapter divisions
//! - Support chapter navigation
//! - Provide search and filtering
//! - Allow export/save of campaign story
use bevy::prelude::*;
/// Component for the story log UI root.
#[derive(Component)]
pub struct StoryLogUi;
/// Component for chapter list items.
#[derive(Component)]
pub struct ChapterListItem {
/// Chapter index
pub index: usize,
}
/// Component for event detail items.
#[derive(Component)]
pub struct EventDetailItem {
/// Event index
pub event_index: usize,
}
/// Setup the story log UI.
///
/// TODO: Implement full UI with:
/// - Chapter list with titles and themes
/// - Event timeline display
/// - Chapter navigation buttons
/// - Search/filter controls
/// - Export button
pub fn setup_story_log_ui(_commands: &mut Commands) {
// Placeholder: UI setup to be implemented
bevy::log::info!("Story log UI setup not yet implemented");
}
/// Update the story log UI with current narrative data.
///
/// TODO: Implement UI update logic.
pub fn update_story_log_ui() {
// Placeholder: UI update to be implemented
}
/// Handle chapter navigation clicks.
///
/// TODO: Implement chapter navigation.
pub fn handle_chapter_navigation() {
// Placeholder: Navigation handling to be implemented
}
/// Export campaign story to file.
///
/// TODO: Implement export functionality.
pub fn export_campaign_story(_history: &super::CampaignHistory) -> Result<(), String> {
Err("Export not yet implemented".to_string())
}

View File

@@ -176,6 +176,7 @@ pub fn spawn_selected_pois(
system_contents,
star_entity,
&content_assets,
&mut materials,
);
});

View File

@@ -8,9 +8,9 @@ use bevy::prelude::*;
use camera::{orbit_camera_control, follow_camera_system, CameraState};
use gameplay::campaign::CampaignDraft;
use gameplay::{
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin,
movement::MovementPlugin, physics::PhysicsPlugin, star_map::StarMapPlugin,
starting_base::StartingBasePlugin,
ai::AiPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin,
in_system::InSystemPlugin, movement::MovementPlugin, narrative::NarrativePlugin,
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin,
};
use state::AppState;
use ui::main_menu;
@@ -50,6 +50,8 @@ fn main() {
CharacterCreationPlugin,
StartingBasePlugin,
InSystemPlugin,
AiPlugin,
NarrativePlugin,
))
.run();
}

View File

@@ -7,4 +7,23 @@ export default defineConfig({
jsx: "automatic",
jsxImportSource: "react",
},
build: {
// Generate source maps for production debugging
sourcemap: true,
// Optimize chunk splitting for better caching
rollupOptions: {
output: {
manualChunks: {
// Bundle React separately (more stable across updates)
"react-vendor": ["react", "react-dom"],
},
},
},
// Chunk size warning limit (in kB)
chunkSizeWarningLimit: 500,
},
// Optimize dependency pre-bundling
optimizeDeps: {
include: ["react", "react-dom"],
},
});