@@ -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"]
|
||||||
|
|
||||||
|
|||||||
10
UNRAID.md
10
UNRAID.md
@@ -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`.
|
||||||
|
|||||||
@@ -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
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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 {
|
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,
|
||||||
if (!mission) {
|
title: row.title,
|
||||||
return null;
|
description: row.description,
|
||||||
}
|
rewardXp: row.reward_xp,
|
||||||
|
completed: row.completed === 1
|
||||||
mission.completed = true;
|
};
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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,
|
||||||
...nextProfile,
|
mass: row.mass,
|
||||||
evolutions: nextProfile.evolutions ?? profile.evolutions
|
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