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 FROM node:20-alpine
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV DATABASE_PATH=/data/stellar.sqlite
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY server/package.json server/package.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 COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000 EXPOSE 3000
VOLUME ["/data"]
CMD ["node", "server/dist/server.js"] CMD ["node", "server/dist/server.js"]

View File

@@ -37,6 +37,8 @@ docker run -d \
-p 8080:3000 \ -p 8080:3000 \
-e PORT=3000 \ -e PORT=3000 \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e DATABASE_PATH=/data/stellar.sqlite \
-v /mnt/user/appdata/stellar:/data \
stellar:local stellar:local
``` ```
@@ -69,6 +71,8 @@ docker run -d \
-p 8080:3000 \ -p 8080:3000 \
-e PORT=3000 \ -e PORT=3000 \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e DATABASE_PATH=/data/stellar.sqlite \
-v /mnt/user/appdata/stellar:/data \
<your-registry>/stellar:<tag> <your-registry>/stellar:<tag>
``` ```
@@ -107,6 +111,8 @@ docker run -d \
-p 8080:3000 \ -p 8080:3000 \
-e PORT=3000 \ -e PORT=3000 \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e DATABASE_PATH=/data/stellar.sqlite \
-v /mnt/user/appdata/stellar:/data \
stellar:local stellar:local
``` ```
@@ -120,7 +126,7 @@ docker compose up -d --build
## Notes ## Notes
- There is no persistent SQLite mount yet because M3 persistence has not been implemented. - SQLite persistence now defaults to `/data/stellar.sqlite` in containers.
- When SQLite is added, bind-mount a host path such as `/mnt/user/appdata/stellar/data` into the container. - 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. - 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`. - 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 { Hud } from "./components/Hud";
import { GameShell } from "./game/GameShell"; import { GameShell } from "./game/GameShell";
import { defaultGameState, type GameState } from "./game/types"; import { defaultGameState, type GameState } from "./game/types";
export default function App() { export default function App() {
const [gameState, setGameState] = useState<GameState>(defaultGameState); 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 ( return (
<main className="min-h-screen bg-ink text-slate-100"> <main className="min-h-screen bg-ink text-slate-100">
@@ -17,12 +81,23 @@ export default function App() {
</h1> </h1>
</div> </div>
<div className="max-w-sm text-right text-sm text-slate-300"> <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> </div>
</header> </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]"> <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} /> <Hud gameState={gameState} />
</div> </div>
</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> <dt className="text-slate-400">Tier</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.tier}</dd> <dd className="mt-1 text-xl font-semibold">{gameState.tier}</dd>
</div> </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"> <div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Pull Radius</dt> <dt className="text-slate-400">Pull Radius</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.pullRadius.toFixed(0)}m</dd> <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 { useEffect, useRef } from "react";
import { initMatterScene } from "./matterScene"; import { initMatterScene } from "./matterScene";
import { type GameState } from "./types"; import { type GameState } from "./types";
import { type PlayerProfile } from "../api/player";
interface GameShellProps { interface GameShellProps {
gameState: GameState; gameState: GameState;
onStateChange: (state: GameState) => void; 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); const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -17,8 +19,8 @@ export function GameShell({ gameState, onStateChange }: GameShellProps) {
return; return;
} }
return initMatterScene(canvas, onStateChange); return initMatterScene(canvas, onStateChange, initialProfile);
}, [onStateChange]); }, [initialProfile, onStateChange]);
return ( return (
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-nebula shadow-2xl shadow-slate-950/40"> <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> <span>{gameState.tier}</span>
</div> </div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-4 text-sm text-slate-300/80"> <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> </div>
</section> </section>
); );

View File

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

View File

@@ -2,6 +2,7 @@ import { getTierForMass } from "./progression";
export interface GameState { export interface GameState {
mass: number; mass: number;
xp: number;
radius: number; radius: number;
pullRadius: number; pullRadius: number;
tier: string; tier: string;
@@ -14,6 +15,7 @@ export interface GameState {
export const defaultGameState: GameState = { export const defaultGameState: GameState = {
mass: 12, mass: 12,
xp: 0,
radius: 24, radius: 24,
pullRadius: 74.4, pullRadius: 74.4,
tier: getTierForMass(12), 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({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173 port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/healthz": "http://localhost:3000"
}
}, },
test: { test: {
environment: "jsdom", environment: "jsdom",

View File

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

View File

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

11
package-lock.json generated
View File

@@ -1526,6 +1526,16 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -6363,6 +6373,7 @@
"pino-http": "^10.4.0" "pino-http": "^10.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/node": "^22.13.11", "@types/node": "^22.13.11",

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,38 @@
import { describe, expect, it } from "vitest"; 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", () => { 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", () => { 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 { export interface Mission {
id: string; id: string;
title: string; title: string;
@@ -6,35 +8,52 @@ export interface Mission {
completed: boolean; completed: boolean;
} }
const missions: Mission[] = [ interface MissionRow {
{ id: string;
id: "first-growth", title: string;
title: "First Growth", description: string;
description: "Reach a stable absorber prototype.", reward_xp: number;
rewardXp: 50, completed: number;
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) { function toMission(row: MissionRow): Mission {
const mission = missions.find((entry) => entry.id === id); return {
id: row.id,
title: row.title,
description: row.description,
rewardXp: row.reward_xp,
completed: row.completed === 1
};
}
if (!mission) { 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);
},
completeMission(id: string) {
const row = getStatement.get(id) as MissionRow | undefined;
if (!row) {
return null; return null;
} }
mission.completed = true; completeStatement.run(id);
return mission; 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 { export interface PlayerProfile {
id: string; id: string;
mass: number; mass: number;
@@ -5,26 +7,71 @@ export interface PlayerProfile {
evolutions: string[]; evolutions: string[];
} }
const defaultProfile: PlayerProfile = { export const defaultProfile: PlayerProfile = {
id: "local-player", id: "local-player",
mass: 12, mass: 12,
xp: 0, xp: 0,
evolutions: [] evolutions: []
}; };
let profile = { ...defaultProfile }; interface PlayerRow {
id: string;
export function getPlayerProfile() { mass: number;
return profile; xp: number;
evolutions: string;
} }
export function savePlayerProfile(nextProfile: Partial<PlayerProfile>) { function toProfile(row: PlayerRow): PlayerProfile {
profile = { return {
...profile, 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, ...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; 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);
}
}