9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
server/node_modules/
|
||||||
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
.git/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
|
||||||
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report a defect in gameplay, API behavior, or deployment
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: bug
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Propose a gameplay, UX, or infrastructure enhancement
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
37
.github/workflows/ci.yml
vendored
Normal file
37
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "codex/**"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker compose -f docker/docker-compose.yml build
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
server/data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
15
Makefile
Normal file
15
Makefile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
dev:
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
npm run docker-build
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
npm run docker-up
|
||||||
|
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Stellar
|
||||||
|
|
||||||
|
Space-themed absorber game built as a React + Matter.js client with a small Express + SQLite API.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
The repository is currently scaffolded through M0:
|
||||||
|
|
||||||
|
- React 18 + TypeScript + Vite client
|
||||||
|
- Express + TypeScript API with `pino` logging
|
||||||
|
- Single-container Docker build that serves the client and API from one process
|
||||||
|
- Workspace-based root scripts for local development and builds
|
||||||
|
- CI workflow for install, lint, test, build, and Docker verification
|
||||||
|
- Baseline docs and test folders
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Client defaults to `http://localhost:5173`.
|
||||||
|
API defaults to `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docker-up
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the single production-style container on `http://localhost:8080`.
|
||||||
|
Static assets and API routes are served by the same Express process.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run lint
|
||||||
|
npm run docker-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `client/` React frontend and game runtime
|
||||||
|
- `server/` Express API, static asset host, and future persistence layer
|
||||||
|
- `docker/` container definitions
|
||||||
|
- `docs/` architecture and contributor docs
|
||||||
|
- `tests/` reserved for shared test assets and future end-to-end coverage
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
[`ci.yml`](F:\CODING\stellar\.github\workflows\ci.yml) validates:
|
||||||
|
|
||||||
|
- `npm ci`
|
||||||
|
- `npm run lint`
|
||||||
|
- `npm run test`
|
||||||
|
- `npm run build`
|
||||||
|
- `docker compose -f docker/docker-compose.yml build`
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Stellar</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
34
client/package.json
Normal file
34
client/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"matter-js": "^0.20.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.2",
|
||||||
|
"vitest": "^3.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
client/postcss.config.cjs
Normal file
7
client/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
28
client/src/App.tsx
Normal file
28
client/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Hud } from "./components/Hud";
|
||||||
|
import { GameShell } from "./game/GameShell";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-ink text-slate-100">
|
||||||
|
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<header className="mb-6 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-glow/80">Stellar</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||||
|
Become the gravity well.
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-sm text-right text-sm text-slate-300">
|
||||||
|
M0 scaffold: UI shell, Matter.js mount point, and API-ready project structure.
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid flex-1 gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
|
<GameShell />
|
||||||
|
<Hud />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
11
client/src/components/Hud.test.tsx
Normal file
11
client/src/components/Hud.test.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Hud } from "./Hud";
|
||||||
|
|
||||||
|
describe("Hud", () => {
|
||||||
|
it("renders the boot sequence panel", () => {
|
||||||
|
render(<Hud />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Boot Sequence")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Bootstrap Matter.js world")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
client/src/components/Hud.tsx
Normal file
47
client/src/components/Hud.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const objectives = [
|
||||||
|
"Bootstrap Matter.js world",
|
||||||
|
"Wire player state to the API",
|
||||||
|
"Start M1 absorber loop"
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Hud() {
|
||||||
|
return (
|
||||||
|
<aside className="rounded-3xl border border-white/10 bg-white/5 p-5 shadow-2xl shadow-cyan-950/30 backdrop-blur">
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-glow/70">Mission Board</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Boot Sequence</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
|
<dt className="text-slate-400">Mass</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold">12</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
|
<dt className="text-slate-400">Tier</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold">Dustling</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
|
<dt className="text-slate-400">Pull Radius</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold">48m</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-black/20 p-3">
|
||||||
|
<dt className="text-slate-400">Mode</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold">Sandbox</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-amber-300/70">Next Steps</p>
|
||||||
|
<ul className="mt-3 space-y-3 text-sm text-slate-200">
|
||||||
|
{objectives.map((objective) => (
|
||||||
|
<li key={objective} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||||
|
{objective}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
27
client/src/game/GameShell.tsx
Normal file
27
client/src/game/GameShell.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { initPlaceholderScene } from "./placeholderScene";
|
||||||
|
|
||||||
|
export function GameShell() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return initPlaceholderScene(canvas);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-nebula shadow-2xl shadow-slate-950/40">
|
||||||
|
<canvas ref={canvasRef} className="h-[560px] w-full" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between p-4 text-xs uppercase tracking-[0.25em] text-slate-300/70">
|
||||||
|
<span>Simulation viewport</span>
|
||||||
|
<span>Placeholder render</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
69
client/src/game/placeholderScene.ts
Normal file
69
client/src/game/placeholderScene.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export function initPlaceholderScene(canvas: HTMLCanvasElement) {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationFrame = 0;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.floor(rect.width * window.devicePixelRatio);
|
||||||
|
canvas.height = Math.floor(rect.height * window.devicePixelRatio);
|
||||||
|
context.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (time: number) => {
|
||||||
|
const width = canvas.width / window.devicePixelRatio;
|
||||||
|
const height = canvas.height / window.devicePixelRatio;
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const gradient = context.createRadialGradient(cx, cy, 24, cx, cy, Math.max(width, height) * 0.45);
|
||||||
|
gradient.addColorStop(0, "rgba(14, 165, 233, 0.35)");
|
||||||
|
gradient.addColorStop(0.35, "rgba(15, 23, 42, 0.4)");
|
||||||
|
gradient.addColorStop(1, "rgba(2, 6, 23, 1)");
|
||||||
|
|
||||||
|
context.fillStyle = gradient;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
for (let i = 0; i < 60; i += 1) {
|
||||||
|
const orbit = 40 + i * 6;
|
||||||
|
const angle = time * 0.0002 * (1 + i * 0.01) + i;
|
||||||
|
const x = cx + Math.cos(angle) * orbit;
|
||||||
|
const y = cy + Math.sin(angle) * orbit * 0.55;
|
||||||
|
const size = 1 + (i % 3);
|
||||||
|
|
||||||
|
context.fillStyle = i % 8 === 0 ? "#f97316" : "#e2e8f0";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, size, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = "#020617";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(cx, cy, 38, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
context.strokeStyle = "rgba(125, 211, 252, 0.45)";
|
||||||
|
context.lineWidth = 8;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(cx, cy, 54 + Math.sin(time * 0.002) * 4, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
animationFrame = window.requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animationFrame = window.requestAnimationFrame(render);
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
11
client/src/main.tsx
Normal file
11
client/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
26
client/src/styles/global.css
Normal file
26
client/src/styles/global.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(14, 165, 233, 0.2), transparent 35%),
|
||||||
|
linear-gradient(180deg, #020617 0%, #0f172a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
2
client/src/utils/constants.ts
Normal file
2
client/src/utils/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const GAME_TITLE = "Stellar";
|
||||||
|
|
||||||
1
client/src/vitest.setup.ts
Normal file
1
client/src/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
15
client/tailwind.config.d.ts
vendored
Normal file
15
client/tailwind.config.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
declare const _default: {
|
||||||
|
content: string[];
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: string;
|
||||||
|
glow: string;
|
||||||
|
ember: string;
|
||||||
|
nebula: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
plugins: any[];
|
||||||
|
};
|
||||||
|
export default _default;
|
||||||
14
client/tailwind.config.js
Normal file
14
client/tailwind.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: "#020617",
|
||||||
|
glow: "#7dd3fc",
|
||||||
|
ember: "#f97316",
|
||||||
|
nebula: "#0f172a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
17
client/tailwind.config.ts
Normal file
17
client/tailwind.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: "#020617",
|
||||||
|
glow: "#7dd3fc",
|
||||||
|
ember: "#f97316",
|
||||||
|
nebula: "#0f172a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
} satisfies Config;
|
||||||
|
|
||||||
26
client/tsconfig.json
Normal file
26
client/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
client/tsconfig.node.json
Normal file
10
client/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "tailwind.config.ts", "postcss.config.cjs"]
|
||||||
|
}
|
||||||
1
client/tsconfig.node.tsbuildinfo
Normal file
1
client/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
client/tsconfig.tsbuildinfo
Normal file
1
client/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vitest.setup.ts","./src/components/hud.test.tsx","./src/components/hud.tsx","./src/game/gameshell.tsx","./src/game/placeholderscene.ts","./src/utils/constants.ts"],"version":"5.9.3"}
|
||||||
2
client/vite.config.d.ts
vendored
Normal file
2
client/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
13
client/vite.config.js
Normal file
13
client/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: "./src/vitest.setup.ts"
|
||||||
|
}
|
||||||
|
});
|
||||||
14
client/vite.config.ts
Normal file
14
client/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: "./src/vitest.setup.ts"
|
||||||
|
}
|
||||||
|
});
|
||||||
25
docker/Dockerfile
Normal file
25
docker/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY client/package.json client/package.json
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY client ./client
|
||||||
|
COPY server ./server
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
RUN npm ci --omit=dev --workspace server
|
||||||
|
|
||||||
|
COPY --from=builder /app/server/dist ./server/dist
|
||||||
|
COPY --from=builder /app/client/dist ./client/dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server/dist/server.js"]
|
||||||
16
docker/docker-compose.yml
Normal file
16
docker/docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
LOG_LEVEL: info
|
||||||
|
ports:
|
||||||
|
- "8080:3000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/healthz"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
26
docs/api-reference.md
Normal file
26
docs/api-reference.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
## `GET /healthz`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## `GET /api/player`
|
||||||
|
|
||||||
|
Returns the current seed profile.
|
||||||
|
|
||||||
|
## `POST /api/player`
|
||||||
|
|
||||||
|
Accepts a partial profile payload and returns the updated profile.
|
||||||
|
|
||||||
|
## `GET /api/missions`
|
||||||
|
|
||||||
|
Returns seeded missions.
|
||||||
|
|
||||||
|
## `POST /api/missions/:id/complete`
|
||||||
|
|
||||||
|
Marks a mission complete and returns the XP reward.
|
||||||
|
|
||||||
14
docs/architecture.md
Normal file
14
docs/architecture.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Current Baseline
|
||||||
|
|
||||||
|
- `client/` hosts the React application and game shell.
|
||||||
|
- `server/` hosts the Express API, production static asset serving, and future persistence services.
|
||||||
|
- `docker/` contains a single-container production build and local compose entrypoint.
|
||||||
|
|
||||||
|
## Immediate Direction
|
||||||
|
|
||||||
|
- M0 keeps the game scene intentionally simple.
|
||||||
|
- M1 replaces the placeholder canvas renderer with a Matter.js-driven absorber loop.
|
||||||
|
- Persistence is currently in-memory and will move to SQLite during M3.
|
||||||
|
- Production packaging now uses one container that serves both the API and built client from Express.
|
||||||
20
docs/contribution-guide.md
Normal file
20
docs/contribution-guide.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Contribution Guide
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install Node.js 20+.
|
||||||
|
2. Run `npm install`.
|
||||||
|
3. Run `npm run dev`.
|
||||||
|
4. Run `npm run docker-up` to verify the single-container path.
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
|
||||||
|
- Keep client gameplay code under `client/src/game`.
|
||||||
|
- Keep API and persistence code under `server/src`.
|
||||||
|
- Keep production assumptions aligned with the single-container deployment model.
|
||||||
|
- Prefer small milestone-focused changesets.
|
||||||
|
|
||||||
|
## CI Baseline
|
||||||
|
|
||||||
|
The repo includes a first-pass workflow that runs install, lint, test, build, and Docker build checks on pushes and pull requests.
|
||||||
|
|
||||||
6367
package-lock.json
generated
Normal file
6367
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "stellar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm --workspace client run dev -- --host 0.0.0.0\" \"npm --workspace server run dev\"",
|
||||||
|
"build": "npm run build:client && npm run build:server",
|
||||||
|
"build:client": "npm --workspace client run build",
|
||||||
|
"build:server": "npm --workspace server run build",
|
||||||
|
"start": "npm --workspace server run start",
|
||||||
|
"test": "npm run test:client && npm run test:server",
|
||||||
|
"test:client": "npm --workspace client run test",
|
||||||
|
"test:server": "npm --workspace server run test",
|
||||||
|
"lint": "npm run lint:client && npm run lint:server",
|
||||||
|
"lint:client": "npm --workspace client run lint",
|
||||||
|
"lint:server": "npm --workspace server run lint",
|
||||||
|
"docker-build": "docker compose -f docker/docker-compose.yml build",
|
||||||
|
"docker-up": "docker compose -f docker/docker-compose.yml up --build",
|
||||||
|
"docker-down": "docker compose -f docker/docker-compose.yml down"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/package.json
Normal file
29
server/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.9.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"pino": "^9.7.0",
|
||||||
|
"pino-http": "^10.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/node": "^22.13.11",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vitest": "^3.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
6
server/src/middleware/errorHandler.ts
Normal file
6
server/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
export function errorHandler(error: Error, _request: Request, response: Response, _next: NextFunction) {
|
||||||
|
response.status(500).json({ error: error.message || "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
20
server/src/routes/missions.ts
Normal file
20
server/src/routes/missions.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { completeMission, listMissions } from "../services/missions.js";
|
||||||
|
|
||||||
|
export const missionsRouter = Router();
|
||||||
|
|
||||||
|
missionsRouter.get("/", (_request, response) => {
|
||||||
|
response.json(listMissions());
|
||||||
|
});
|
||||||
|
|
||||||
|
missionsRouter.post("/:id/complete", (request, response) => {
|
||||||
|
const mission = completeMission(request.params.id);
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
response.status(404).json({ error: "Mission not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json({ rewardXp: mission.rewardXp, mission });
|
||||||
|
});
|
||||||
|
|
||||||
14
server/src/routes/player.ts
Normal file
14
server/src/routes/player.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { getPlayerProfile, savePlayerProfile } from "../services/playerStore.js";
|
||||||
|
|
||||||
|
export const playerRouter = Router();
|
||||||
|
|
||||||
|
playerRouter.get("/", (_request, response) => {
|
||||||
|
response.json(getPlayerProfile());
|
||||||
|
});
|
||||||
|
|
||||||
|
playerRouter.post("/", (request, response) => {
|
||||||
|
const profile = savePlayerProfile(request.body ?? {});
|
||||||
|
response.status(200).json(profile);
|
||||||
|
});
|
||||||
|
|
||||||
13
server/src/server.test.ts
Normal file
13
server/src/server.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { completeMission, listMissions } from "./services/missions.js";
|
||||||
|
|
||||||
|
describe("missions service", () => {
|
||||||
|
it("lists seed missions", () => {
|
||||||
|
expect(listMissions().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes a mission by id", () => {
|
||||||
|
expect(completeMission("first-growth")?.completed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
62
server/src/server.ts
Normal file
62
server/src/server.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import pino from "pino";
|
||||||
|
import { pinoHttp } from "pino-http";
|
||||||
|
import { errorHandler } from "./middleware/errorHandler.js";
|
||||||
|
import { missionsRouter } from "./routes/missions.js";
|
||||||
|
import { playerRouter } from "./routes/player.js";
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL ?? "info"
|
||||||
|
});
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const clientDistPath = path.resolve(__dirname, "../../client/dist");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(
|
||||||
|
pinoHttp({
|
||||||
|
logger
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/healthz", (_request, response) => {
|
||||||
|
response.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/logs", (_request, response) => {
|
||||||
|
response.json({
|
||||||
|
logs: [],
|
||||||
|
message: "Log streaming will be added with persistent transport support."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/api/player", playerRouter);
|
||||||
|
app.use("/api/missions", missionsRouter);
|
||||||
|
|
||||||
|
if (existsSync(clientDistPath)) {
|
||||||
|
app.use(express.static(clientDistPath));
|
||||||
|
|
||||||
|
app.get("*", (request, response, next) => {
|
||||||
|
if (request.path.startsWith("/api/")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.sendFile(path.join(clientDistPath, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
app.listen(port, "0.0.0.0", () => {
|
||||||
|
logger.info({ port }, "stellar server listening");
|
||||||
|
});
|
||||||
40
server/src/services/missions.ts
Normal file
40
server/src/services/missions.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export interface Mission {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rewardXp: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missions: Mission[] = [
|
||||||
|
{
|
||||||
|
id: "first-growth",
|
||||||
|
title: "First Growth",
|
||||||
|
description: "Reach a stable absorber prototype.",
|
||||||
|
rewardXp: 50,
|
||||||
|
completed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field-test",
|
||||||
|
title: "Field Test",
|
||||||
|
description: "Prepare the first sandbox sector.",
|
||||||
|
rewardXp: 100,
|
||||||
|
completed: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function listMissions() {
|
||||||
|
return missions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeMission(id: string) {
|
||||||
|
const mission = missions.find((entry) => entry.id === id);
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mission.completed = true;
|
||||||
|
return mission;
|
||||||
|
}
|
||||||
|
|
||||||
30
server/src/services/playerStore.ts
Normal file
30
server/src/services/playerStore.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface PlayerProfile {
|
||||||
|
id: string;
|
||||||
|
mass: number;
|
||||||
|
xp: number;
|
||||||
|
evolutions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProfile: PlayerProfile = {
|
||||||
|
id: "local-player",
|
||||||
|
mass: 12,
|
||||||
|
xp: 0,
|
||||||
|
evolutions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile = { ...defaultProfile };
|
||||||
|
|
||||||
|
export function getPlayerProfile() {
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePlayerProfile(nextProfile: Partial<PlayerProfile>) {
|
||||||
|
profile = {
|
||||||
|
...profile,
|
||||||
|
...nextProfile,
|
||||||
|
evolutions: nextProfile.evolutions ?? profile.evolutions
|
||||||
|
};
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
tests/e2e/.gitkeep
Normal file
1
tests/e2e/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
tests/unit/.gitkeep
Normal file
1
tests/unit/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user