@@ -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