@@ -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"]
|
||||
|
||||
|
||||
10
UNRAID.md
10
UNRAID.md
@@ -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`.
|
||||
|
||||
@@ -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
33
client/src/api/player.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
23
server/src/services/database.ts
Normal file
23
server/src/services/database.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
id: "field-test",
|
||||
title: "Field Test",
|
||||
description: "Prepare the first sandbox sector.",
|
||||
rewardXp: 100,
|
||||
completed: false
|
||||
}
|
||||
];
|
||||
|
||||
export function listMissions() {
|
||||
return missions;
|
||||
interface MissionRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reward_xp: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export function completeMission(id: string) {
|
||||
const mission = missions.find((entry) => entry.id === id);
|
||||
|
||||
if (!mission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
mission.completed = true;
|
||||
return mission;
|
||||
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);
|
||||
},
|
||||
|
||||
completeMission(id: string) {
|
||||
const row = getStatement.get(id) as MissionRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
completeStatement.run(id);
|
||||
return toMission({ ...row, completed: 1 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
11
server/src/services/persistence.ts
Normal file
11
server/src/services/persistence.ts
Normal 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);
|
||||
|
||||
@@ -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,
|
||||
...nextProfile,
|
||||
evolutions: nextProfile.evolutions ?? profile.evolutions
|
||||
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,
|
||||
id: nextProfile.id ?? current.id,
|
||||
evolutions: nextProfile.evolutions ?? current.evolutions
|
||||
};
|
||||
|
||||
insertProfile.run({
|
||||
...profile,
|
||||
evolutions: JSON.stringify(profile.evolutions)
|
||||
});
|
||||
|
||||
return profile;
|
||||
}
|
||||
};
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
45
server/src/services/schema.ts
Normal file
45
server/src/services/schema.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user