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

@@ -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
},
{
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 });
}
};
}

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,
...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;
}

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);
}
}