m3
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
2026-03-23 00:16:50 -05:00
parent 1a9209431b
commit aeadb80fdb
22 changed files with 402 additions and 75 deletions

View File

@@ -13,6 +13,7 @@ RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV DATABASE_PATH=/data/stellar.sqlite
COPY package.json package-lock.json ./
COPY server/package.json server/package.json
@@ -22,5 +23,5 @@ COPY --from=builder /app/server/dist ./server/dist
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000
VOLUME ["/data"]
CMD ["node", "server/dist/server.js"]

View File

@@ -37,6 +37,8 @@ docker run -d \
-p 8080:3000 \
-e PORT=3000 \
-e LOG_LEVEL=info \
-e DATABASE_PATH=/data/stellar.sqlite \
-v /mnt/user/appdata/stellar:/data \
stellar:local
```
@@ -69,6 +71,8 @@ docker run -d \
-p 8080:3000 \
-e PORT=3000 \
-e LOG_LEVEL=info \
-e DATABASE_PATH=/data/stellar.sqlite \
-v /mnt/user/appdata/stellar:/data \
<your-registry>/stellar:<tag>
```
@@ -107,6 +111,8 @@ docker run -d \
-p 8080:3000 \
-e PORT=3000 \
-e LOG_LEVEL=info \
-e DATABASE_PATH=/data/stellar.sqlite \
-v /mnt/user/appdata/stellar:/data \
stellar:local
```
@@ -120,7 +126,7 @@ docker compose up -d --build
## Notes
- There is no persistent SQLite mount yet because M3 persistence has not been implemented.
- When SQLite is added, bind-mount a host path such as `/mnt/user/appdata/stellar/data` into the container.
- SQLite persistence now defaults to `/data/stellar.sqlite` in containers.
- The examples above bind `/mnt/user/appdata/stellar` into `/data` so profile data survives container recreation.
- If Unraid already uses port `8080`, change the host-side port mapping and browse to that port instead.
- Logs are written to container stdout/stderr and can be viewed from the Unraid Docker UI or with `docker logs stellar`.

View File

@@ -1,10 +1,74 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { fetchPlayerProfile, savePlayerProfile, type PlayerProfile } from "./api/player";
import { Hud } from "./components/Hud";
import { GameShell } from "./game/GameShell";
import { defaultGameState, type GameState } from "./game/types";
export default function App() {
const [gameState, setGameState] = useState<GameState>(defaultGameState);
const [profile, setProfile] = useState<PlayerProfile | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const hasLoaded = useRef(false);
useEffect(() => {
let cancelled = false;
fetchPlayerProfile()
.then((nextProfile) => {
if (cancelled) {
return;
}
setProfile(nextProfile);
})
.catch((error: Error) => {
if (cancelled) {
return;
}
setLoadError(error.message);
setProfile({
id: "local-player",
mass: defaultGameState.mass,
xp: defaultGameState.xp,
evolutions: []
});
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!profile) {
return;
}
if (!hasLoaded.current) {
hasLoaded.current = true;
return;
}
const timeoutId = window.setTimeout(() => {
void savePlayerProfile({
mass: gameState.mass,
xp: gameState.xp,
evolutions: profile.evolutions
}).catch(() => undefined);
}, 750);
return () => {
window.clearTimeout(timeoutId);
};
}, [gameState.mass, gameState.xp, profile]);
const initialProfile: PlayerProfile = profile ?? {
id: "local-player",
mass: defaultGameState.mass,
xp: defaultGameState.xp,
evolutions: []
};
return (
<main className="min-h-screen bg-ink text-slate-100">
@@ -17,12 +81,23 @@ export default function App() {
</h1>
</div>
<div className="max-w-sm text-right text-sm text-slate-300">
M1 loop: drag the singularity, consume dust, and grow your event horizon.
M3 persistence: profile state now reloads through the API-backed SQLite store.
</div>
</header>
{loadError ? (
<div className="mb-4 rounded-2xl border border-amber-400/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
Profile load fell back to local defaults: {loadError}
</div>
) : null}
<div className="grid flex-1 gap-6 lg:grid-cols-[1fr_320px]">
<GameShell gameState={gameState} onStateChange={setGameState} />
<GameShell
key={`${initialProfile.id}:${initialProfile.mass}:${initialProfile.xp}`}
gameState={gameState}
initialProfile={initialProfile}
onStateChange={setGameState}
/>
<Hud gameState={gameState} />
</div>
</div>

33
client/src/api/player.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface PlayerProfile {
id: string;
mass: number;
xp: number;
evolutions: string[];
}
export async function fetchPlayerProfile() {
const response = await fetch("/api/player");
if (!response.ok) {
throw new Error(`Failed to load player profile: ${response.status}`);
}
return (await response.json()) as PlayerProfile;
}
export async function savePlayerProfile(profile: Pick<PlayerProfile, "mass" | "xp" | "evolutions">) {
const response = await fetch("/api/player", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(profile)
});
if (!response.ok) {
throw new Error(`Failed to save player profile: ${response.status}`);
}
return (await response.json()) as PlayerProfile;
}

View File

@@ -27,6 +27,10 @@ export function Hud({ gameState }: HudProps) {
<dt className="text-slate-400">Tier</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.tier}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">XP</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.xp}</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">{gameState.pullRadius.toFixed(0)}m</dd>

View File

@@ -1,13 +1,15 @@
import { useEffect, useRef } from "react";
import { initMatterScene } from "./matterScene";
import { type GameState } from "./types";
import { type PlayerProfile } from "../api/player";
interface GameShellProps {
gameState: GameState;
onStateChange: (state: GameState) => void;
initialProfile: PlayerProfile;
}
export function GameShell({ gameState, onStateChange }: GameShellProps) {
export function GameShell({ gameState, onStateChange, initialProfile }: GameShellProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
@@ -17,8 +19,8 @@ export function GameShell({ gameState, onStateChange }: GameShellProps) {
return;
}
return initMatterScene(canvas, onStateChange);
}, [onStateChange]);
return initMatterScene(canvas, onStateChange, initialProfile);
}, [initialProfile, onStateChange]);
return (
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-nebula shadow-2xl shadow-slate-950/40">
@@ -28,7 +30,7 @@ export function GameShell({ gameState, onStateChange }: GameShellProps) {
<span>{gameState.tier}</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-4 text-sm text-slate-300/80">
Drag inside the black hole to steer. Dust caught inside the event horizon increases mass and radius.
Drag inside the black hole to steer. Profile progress now loads from and saves to the API-backed SQLite store.
</div>
</section>
);

View File

@@ -8,12 +8,11 @@ import {
type Body as MatterBody,
type IEventCollision
} from "matter-js";
import { type PlayerProfile } from "../api/player";
import { getCurrentPreyTier, getNextUnlock, getTierForMass, getUnlockedKinds } from "./progression";
import { CELESTIAL_OBJECTS, type CelestialKind, type CelestialObjectDefinition } from "./spaceObjects";
import { defaultGameState, type GameState } from "./types";
const STARTING_MASS = defaultGameState.mass;
const STARTING_RADIUS = defaultGameState.radius;
const EMIT_INTERVAL_MS = 900;
interface CelestialBody extends MatterBody {
@@ -22,7 +21,11 @@ interface CelestialBody extends MatterBody {
};
}
export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state: GameState) => void) {
export function initMatterScene(
canvas: HTMLCanvasElement,
onStateChange: (state: GameState) => void,
initialProfile: Pick<PlayerProfile, "mass" | "xp">
) {
const context = canvas.getContext("2d");
if (!context) {
@@ -32,7 +35,9 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const engine = Engine.create({
gravity: { x: 0, y: 0 }
});
const blackHole = Bodies.circle(0, 0, STARTING_RADIUS, {
const startingMass = Math.max(defaultGameState.mass, initialProfile.mass);
const startingRadius = defaultGameState.radius + Math.sqrt(startingMass - defaultGameState.mass) * 2.6;
const blackHole = Bodies.circle(0, 0, defaultGameState.radius, {
isStatic: true,
isSensor: true,
frictionAir: 0,
@@ -46,8 +51,9 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const pointer = { x: 0, y: 0 };
const worldSize = { width: 0, height: 0 };
let mass = STARTING_MASS;
let radius = STARTING_RADIUS;
let mass = startingMass;
let xp = initialProfile.xp;
let radius = startingRadius;
let dragging = false;
let animationFrame = 0;
let lastTimestamp = 0;
@@ -55,13 +61,15 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
let consumedObjects = 0;
let lastEmit = 0;
Body.scale(blackHole, startingRadius / defaultGameState.radius, startingRadius / defaultGameState.radius);
World.add(engine.world, blackHole);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const getPullRadius = () => radius * 3.1;
const getRadiusForMass = (nextMass: number) => STARTING_RADIUS + Math.sqrt(nextMass - STARTING_MASS) * 2.6;
const getRadiusForMass = (nextMass: number) =>
defaultGameState.radius + Math.sqrt(nextMass - defaultGameState.mass) * 2.6;
const getObjectDef = (kind: CelestialKind) =>
CELESTIAL_OBJECTS.find((objectDef) => objectDef.kind === kind) as CelestialObjectDefinition;
@@ -72,6 +80,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const emitState = () => {
onStateChange({
mass,
xp,
radius,
pullRadius: getPullRadius(),
tier: getTierForMass(mass),
@@ -192,7 +201,9 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
World.remove(engine.world, body);
consumedObjects += 1;
mass += getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
const rewardMass = getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
mass += rewardMass;
xp += rewardMass;
syncHoleScale(getRadiusForMass(mass));
emitState();
@@ -384,6 +395,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
animationFrame = window.requestAnimationFrame(tick);
};
mass = startingMass;
resizeCanvas();
topOffField();
emitState();

View File

@@ -2,6 +2,7 @@ import { getTierForMass } from "./progression";
export interface GameState {
mass: number;
xp: number;
radius: number;
pullRadius: number;
tier: string;
@@ -14,6 +15,7 @@ export interface GameState {
export const defaultGameState: GameState = {
mass: 12,
xp: 0,
radius: 24,
pullRadius: 74.4,
tier: getTierForMass(12),

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,11 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/healthz": "http://localhost:3000"
}
},
test: {
environment: "jsdom",

View File

@@ -4,7 +4,11 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/healthz": "http://localhost:3000"
}
},
test: {
environment: "jsdom",

View File

@@ -6,11 +6,16 @@ services:
environment:
PORT: "3000"
LOG_LEVEL: info
DATABASE_PATH: /data/stellar.sqlite
ports:
- "8080:3000"
volumes:
- stellar-data:/data
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/healthz"]
interval: 10s
timeout: 5s
retries: 5
volumes:
stellar-data:

11
package-lock.json generated
View File

@@ -1526,6 +1526,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -6363,6 +6373,7 @@
"pino-http": "^10.4.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/node": "^22.13.11",

View File

@@ -18,6 +18,7 @@
"pino-http": "^10.4.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/node": "^22.13.11",
@@ -26,4 +27,3 @@
"vitest": "^3.0.8"
}
}

View File

@@ -1,14 +1,14 @@
import { Router } from "express";
import { completeMission, listMissions } from "../services/missions.js";
import { missionStore } from "../services/persistence.js";
export const missionsRouter = Router();
missionsRouter.get("/", (_request, response) => {
response.json(listMissions());
response.json(missionStore.listMissions());
});
missionsRouter.post("/:id/complete", (request, response) => {
const mission = completeMission(request.params.id);
const mission = missionStore.completeMission(request.params.id);
if (!mission) {
response.status(404).json({ error: "Mission not found" });
@@ -17,4 +17,3 @@ missionsRouter.post("/:id/complete", (request, response) => {
response.json({ rewardXp: mission.rewardXp, mission });
});

View File

@@ -1,14 +1,13 @@
import { Router } from "express";
import { getPlayerProfile, savePlayerProfile } from "../services/playerStore.js";
import { playerStore } from "../services/persistence.js";
export const playerRouter = Router();
playerRouter.get("/", (_request, response) => {
response.json(getPlayerProfile());
response.json(playerStore.getProfile());
});
playerRouter.post("/", (request, response) => {
const profile = savePlayerProfile(request.body ?? {});
const profile = playerStore.saveProfile(request.body ?? {});
response.status(200).json(profile);
});

View File

@@ -1,13 +1,38 @@
import { describe, expect, it } from "vitest";
import { completeMission, listMissions } from "./services/missions.js";
import { createDatabase } from "./services/database.js";
import { createMissionStore } from "./services/missions.js";
import { createPlayerStore } from "./services/playerStore.js";
import { initializeSchema } from "./services/schema.js";
describe("missions service", () => {
describe("persistence services", () => {
it("lists seed missions", () => {
expect(listMissions().length).toBeGreaterThan(0);
const database = createDatabase(":memory:");
initializeSchema(database);
const missionStore = createMissionStore(database);
expect(missionStore.listMissions().length).toBeGreaterThan(0);
});
it("completes a mission by id", () => {
expect(completeMission("first-growth")?.completed).toBe(true);
});
const database = createDatabase(":memory:");
initializeSchema(database);
const missionStore = createMissionStore(database);
expect(missionStore.completeMission("first-growth")?.completed).toBe(true);
});
it("persists player profile updates", () => {
const database = createDatabase(":memory:");
initializeSchema(database);
const playerStore = createPlayerStore(database);
playerStore.saveProfile({ mass: 42, xp: 21, evolutions: ["swift-vortex"] });
expect(playerStore.getProfile()).toEqual({
id: "local-player",
mass: 42,
xp: 21,
evolutions: ["swift-vortex"]
});
});
});

View File

@@ -0,0 +1,23 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import path from "node:path";
export function resolveDatabasePath() {
if (process.env.DATABASE_PATH) {
return process.env.DATABASE_PATH;
}
if (process.env.NODE_ENV === "production") {
return "/data/stellar.sqlite";
}
return path.resolve(process.cwd(), "server/data/stellar.sqlite");
}
export function createDatabase(databasePath = resolveDatabasePath()) {
if (databasePath !== ":memory:") {
mkdirSync(path.dirname(databasePath), { recursive: true });
}
return new Database(databasePath);
}

View File

@@ -1,3 +1,5 @@
import type Database from "better-sqlite3";
export interface Mission {
id: string;
title: string;
@@ -6,35 +8,52 @@ export interface Mission {
completed: boolean;
}
const missions: Mission[] = [
{
id: "first-growth",
title: "First Growth",
description: "Reach a stable absorber prototype.",
rewardXp: 50,
completed: false
interface MissionRow {
id: string;
title: string;
description: string;
reward_xp: number;
completed: number;
}
function toMission(row: MissionRow): Mission {
return {
id: row.id,
title: row.title,
description: row.description,
rewardXp: row.reward_xp,
completed: row.completed === 1
};
}
export function createMissionStore(database: Database.Database) {
const listStatement = database.prepare(`
SELECT id, title, description, reward_xp, completed
FROM missions
ORDER BY reward_xp ASC, id ASC
`);
const getStatement = database.prepare(`
SELECT id, title, description, reward_xp, completed
FROM missions
WHERE id = ?
`);
const completeStatement = database.prepare("UPDATE missions SET completed = 1 WHERE id = ?");
return {
listMissions() {
return (listStatement.all() as MissionRow[]).map(toMission);
},
{
id: "field-test",
title: "Field Test",
description: "Prepare the first sandbox sector.",
rewardXp: 100,
completed: false
}
];
export function listMissions() {
return missions;
}
completeMission(id: string) {
const row = getStatement.get(id) as MissionRow | undefined;
export function completeMission(id: string) {
const mission = missions.find((entry) => entry.id === id);
if (!mission) {
if (!row) {
return null;
}
mission.completed = true;
return mission;
completeStatement.run(id);
return toMission({ ...row, completed: 1 });
}
};
}

View File

@@ -0,0 +1,11 @@
import { createDatabase } from "./database.js";
import { createMissionStore } from "./missions.js";
import { createPlayerStore } from "./playerStore.js";
import { initializeSchema } from "./schema.js";
const database = createDatabase();
initializeSchema(database);
export const playerStore = createPlayerStore(database);
export const missionStore = createMissionStore(database);

View File

@@ -1,3 +1,5 @@
import type Database from "better-sqlite3";
export interface PlayerProfile {
id: string;
mass: number;
@@ -5,26 +7,71 @@ export interface PlayerProfile {
evolutions: string[];
}
const defaultProfile: PlayerProfile = {
export const defaultProfile: PlayerProfile = {
id: "local-player",
mass: 12,
xp: 0,
evolutions: []
};
let profile = { ...defaultProfile };
export function getPlayerProfile() {
return profile;
interface PlayerRow {
id: string;
mass: number;
xp: number;
evolutions: string;
}
export function savePlayerProfile(nextProfile: Partial<PlayerProfile>) {
profile = {
...profile,
function toProfile(row: PlayerRow): PlayerProfile {
return {
id: row.id,
mass: row.mass,
xp: row.xp,
evolutions: JSON.parse(row.evolutions) as string[]
};
}
export function createPlayerStore(database: Database.Database) {
const selectProfile = database.prepare("SELECT id, mass, xp, evolutions FROM player_profiles WHERE id = ?");
const insertProfile = database.prepare(`
INSERT INTO player_profiles (id, mass, xp, evolutions)
VALUES (@id, @mass, @xp, @evolutions)
ON CONFLICT(id) DO UPDATE SET
mass = excluded.mass,
xp = excluded.xp,
evolutions = excluded.evolutions
`);
return {
getProfile(id = defaultProfile.id) {
const row = selectProfile.get(id) as PlayerRow | undefined;
if (!row) {
insertProfile.run({
...defaultProfile,
evolutions: JSON.stringify(defaultProfile.evolutions)
});
return { ...defaultProfile };
}
return toProfile(row);
},
saveProfile(nextProfile: Partial<PlayerProfile> & { id?: string }) {
const current = this.getProfile(nextProfile.id ?? defaultProfile.id);
const profile: PlayerProfile = {
...current,
...nextProfile,
evolutions: nextProfile.evolutions ?? profile.evolutions
id: nextProfile.id ?? current.id,
evolutions: nextProfile.evolutions ?? current.evolutions
};
insertProfile.run({
...profile,
evolutions: JSON.stringify(profile.evolutions)
});
return profile;
}
};
}

View File

@@ -0,0 +1,45 @@
import type Database from "better-sqlite3";
const seedMissions = [
{
id: "first-growth",
title: "First Growth",
description: "Reach a stable absorber prototype.",
rewardXp: 50
},
{
id: "field-test",
title: "Field Test",
description: "Prepare the first sandbox sector.",
rewardXp: 100
}
];
export function initializeSchema(database: Database.Database) {
database.exec(`
CREATE TABLE IF NOT EXISTS player_profiles (
id TEXT PRIMARY KEY,
mass REAL NOT NULL,
xp INTEGER NOT NULL,
evolutions TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS missions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
reward_xp INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
);
`);
const insertMission = database.prepare(`
INSERT OR IGNORE INTO missions (id, title, description, reward_xp, completed)
VALUES (@id, @title, @description, @rewardXp, 0)
`);
for (const mission of seedMissions) {
insertMission.run(mission);
}
}