M0
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
2026-03-22 23:33:24 -05:00
parent 27dac51b5c
commit 177be6332b
47 changed files with 7287 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
import type { NextFunction, Request, Response } from "express";
export function errorHandler(error: Error, _request: Request, response: Response, _next: NextFunction) {
response.status(500).json({ error: error.message || "Internal server error" });
}

View File

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

View File

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

13
server/src/server.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { completeMission, listMissions } from "./services/missions.js";
describe("missions service", () => {
it("lists seed missions", () => {
expect(listMissions().length).toBeGreaterThan(0);
});
it("completes a mission by id", () => {
expect(completeMission("first-growth")?.completed).toBe(true);
});
});

62
server/src/server.ts Normal file
View File

@@ -0,0 +1,62 @@
import cors from "cors";
import express from "express";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { pinoHttp } from "pino-http";
import { errorHandler } from "./middleware/errorHandler.js";
import { missionsRouter } from "./routes/missions.js";
import { playerRouter } from "./routes/player.js";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info"
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const clientDistPath = path.resolve(__dirname, "../../client/dist");
const app = express();
const port = Number(process.env.PORT ?? 3000);
app.use(cors());
app.use(express.json());
app.use(
pinoHttp({
logger
})
);
app.get("/healthz", (_request, response) => {
response.json({ status: "ok" });
});
app.get("/api/logs", (_request, response) => {
response.json({
logs: [],
message: "Log streaming will be added with persistent transport support."
});
});
app.use("/api/player", playerRouter);
app.use("/api/missions", missionsRouter);
if (existsSync(clientDistPath)) {
app.use(express.static(clientDistPath));
app.get("*", (request, response, next) => {
if (request.path.startsWith("/api/")) {
next();
return;
}
response.sendFile(path.join(clientDistPath, "index.html"));
});
}
app.use(errorHandler);
app.listen(port, "0.0.0.0", () => {
logger.info({ port }, "stellar server listening");
});

View File

@@ -0,0 +1,40 @@
export interface Mission {
id: string;
title: string;
description: string;
rewardXp: number;
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;
}
export function completeMission(id: string) {
const mission = missions.find((entry) => entry.id === id);
if (!mission) {
return null;
}
mission.completed = true;
return mission;
}

View File

@@ -0,0 +1,30 @@
export interface PlayerProfile {
id: string;
mass: number;
xp: number;
evolutions: string[];
}
const defaultProfile: PlayerProfile = {
id: "local-player",
mass: 12,
xp: 0,
evolutions: []
};
let profile = { ...defaultProfile };
export function getPlayerProfile() {
return profile;
}
export function savePlayerProfile(nextProfile: Partial<PlayerProfile>) {
profile = {
...profile,
...nextProfile,
evolutions: nextProfile.evolutions ?? profile.evolutions
};
return profile;
}