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

This commit is contained in:
2026-03-23 00:06:01 -05:00
parent 23ac496963
commit 1a9209431b
14 changed files with 258 additions and 83 deletions

View File

@@ -34,4 +34,4 @@ jobs:
run: npm run build run: npm run build
- name: Build Docker image - name: Build Docker image
run: docker compose -f docker/docker-compose.yml build run: docker compose build

View File

@@ -23,3 +23,4 @@ COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server/dist/server.js"] CMD ["node", "server/dist/server.js"]

View File

@@ -31,6 +31,7 @@ npm run docker-up
This starts the single production-style container on `http://localhost:8080`. This starts the single production-style container on `http://localhost:8080`.
Static assets and API routes are served by the same Express process. Static assets and API routes are served by the same Express process.
You can also run `docker build .` directly from the repo root.
## Scripts ## Scripts
@@ -46,7 +47,7 @@ npm run docker-build
- `client/` React frontend and game runtime - `client/` React frontend and game runtime
- `server/` Express API, static asset host, and future persistence layer - `server/` Express API, static asset host, and future persistence layer
- `docker/` container definitions - `Dockerfile` and `compose.yml` define the container runtime
- `docs/` architecture and contributor docs - `docs/` architecture and contributor docs
- `tests/` reserved for shared test assets and future end-to-end coverage - `tests/` reserved for shared test assets and future end-to-end coverage
@@ -58,4 +59,4 @@ npm run docker-build
- `npm run lint` - `npm run lint`
- `npm run test` - `npm run test`
- `npm run build` - `npm run build`
- `docker compose -f docker/docker-compose.yml build` - `docker compose build`

View File

@@ -25,7 +25,7 @@ cd stellar
Build the image from the repo root: Build the image from the repo root:
```bash ```bash
docker build -f docker/Dockerfile -t stellar:local . docker build -t stellar:local .
``` ```
Start the container: Start the container:
@@ -51,11 +51,11 @@ http://<your-unraid-ip>:8080
From the repo root: From the repo root:
```bash ```bash
docker compose -f docker/docker-compose.yml build docker compose build
docker compose -f docker/docker-compose.yml up -d docker compose up -d
``` ```
This uses the repos single-service compose file and exposes the app on port `8080`. This uses the repo's single-service compose file and exposes the app on port `8080`.
## Option 3: Pull A Prebuilt Image ## Option 3: Pull A Prebuilt Image
@@ -99,7 +99,7 @@ If building from source:
```bash ```bash
cd /mnt/user/appdata/stellar cd /mnt/user/appdata/stellar
git pull git pull
docker build -f docker/Dockerfile -t stellar:local . docker build -t stellar:local .
docker rm -f stellar docker rm -f stellar
docker run -d \ docker run -d \
--name stellar \ --name stellar \
@@ -115,7 +115,7 @@ If using compose:
```bash ```bash
cd /mnt/user/appdata/stellar cd /mnt/user/appdata/stellar
git pull git pull
docker compose -f docker/docker-compose.yml up -d --build docker compose up -d --build
``` ```
## Notes ## Notes

View File

@@ -7,6 +7,6 @@ describe("Hud", () => {
render(<Hud gameState={defaultGameState} />); render(<Hud gameState={defaultGameState} />);
expect(screen.getByText("Sector Absorption")).toBeTruthy(); expect(screen.getByText("Sector Absorption")).toBeTruthy();
expect(screen.getByText("Reach mass 40 to stabilize your pull")).toBeTruthy(); expect(screen.getByText("Clear lower-tier debris to unlock heavier targets")).toBeTruthy();
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { type GameState } from "../game/types"; import { type GameState } from "../game/types";
const objectives = [ const objectives = [
"Reach mass 40 to stabilize your pull", "Clear lower-tier debris to unlock heavier targets",
"Keep sweeping the field to chain dust pickups", "Meteorites and larger bodies only absorb after their mass gate",
"Persist profile data in M3" "Persist profile data in M3"
]; ];
@@ -37,11 +37,19 @@ export function Hud({ gameState }: HudProps) {
</div> </div>
<div className="rounded-2xl bg-black/20 p-3"> <div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Consumed</dt> <dt className="text-slate-400">Consumed</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.consumedDust}</dd> <dd className="mt-1 text-xl font-semibold">{gameState.consumedObjects}</dd>
</div> </div>
<div className="rounded-2xl bg-black/20 p-3"> <div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Dust Left</dt> <dt className="text-slate-400">Objects Left</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.dustRemaining}</dd> <dd className="mt-1 text-xl font-semibold">{gameState.objectsRemaining}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Max Prey</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.preyTier}</dd>
</div>
<div className="rounded-2xl bg-black/20 p-3">
<dt className="text-slate-400">Next Unlock</dt>
<dd className="mt-1 text-xl font-semibold">{gameState.nextUnlock ?? "Complete"}</dd>
</div> </div>
</dl> </dl>

View File

@@ -1,7 +1,6 @@
import { import {
Bodies, Bodies,
Body, Body,
Composite,
Engine, Engine,
Events, Events,
Vector, Vector,
@@ -9,18 +8,17 @@ import {
type Body as MatterBody, type Body as MatterBody,
type IEventCollision type IEventCollision
} from "matter-js"; } from "matter-js";
import { getTierForMass } from "./progression"; import { getCurrentPreyTier, getNextUnlock, getTierForMass, getUnlockedKinds } from "./progression";
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_MASS = defaultGameState.mass;
const STARTING_RADIUS = defaultGameState.radius; const STARTING_RADIUS = defaultGameState.radius;
const DUST_COUNT = 36;
const DUST_REWARD = 2;
const EMIT_INTERVAL_MS = 900; const EMIT_INTERVAL_MS = 900;
interface DustBody extends MatterBody { interface CelestialBody extends MatterBody {
plugin: { plugin: {
isDust?: boolean; celestialKind?: CelestialKind;
}; };
} }
@@ -43,7 +41,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
} }
}); });
const boundaries: MatterBody[] = []; const boundaries: MatterBody[] = [];
const dustBodies = new Set<DustBody>(); const celestialBodies = new Set<CelestialBody>();
const consumedBodies = new WeakSet<MatterBody>(); const consumedBodies = new WeakSet<MatterBody>();
const pointer = { x: 0, y: 0 }; const pointer = { x: 0, y: 0 };
const worldSize = { width: 0, height: 0 }; const worldSize = { width: 0, height: 0 };
@@ -54,7 +52,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
let animationFrame = 0; let animationFrame = 0;
let lastTimestamp = 0; let lastTimestamp = 0;
let fps = 60; let fps = 60;
let consumedDust = 0; let consumedObjects = 0;
let lastEmit = 0; let lastEmit = 0;
World.add(engine.world, blackHole); World.add(engine.world, blackHole);
@@ -63,14 +61,24 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
const getPullRadius = () => radius * 3.1; const getPullRadius = () => radius * 3.1;
const getRadiusForMass = (nextMass: number) => STARTING_RADIUS + Math.sqrt(nextMass - STARTING_MASS) * 2.6;
const getObjectDef = (kind: CelestialKind) =>
CELESTIAL_OBJECTS.find((objectDef) => objectDef.kind === kind) as CelestialObjectDefinition;
const getFieldCount = (kind: CelestialKind) =>
Array.from(celestialBodies).filter((body) => body.plugin.celestialKind === kind).length;
const emitState = () => { const emitState = () => {
onStateChange({ onStateChange({
mass, mass,
radius, radius,
pullRadius: getPullRadius(), pullRadius: getPullRadius(),
tier: getTierForMass(mass), tier: getTierForMass(mass),
consumedDust, consumedObjects,
dustRemaining: dustBodies.size, objectsRemaining: celestialBodies.size,
preyTier: getCurrentPreyTier(mass),
nextUnlock: getNextUnlock(mass),
fps fps
}); });
}; };
@@ -111,7 +119,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
World.add(engine.world, boundaries); World.add(engine.world, boundaries);
}; };
const createDust = () => { const createCelestialObject = (objectDef: CelestialObjectDefinition) => {
const padding = 48; const padding = 48;
let attempts = 0; let attempts = 0;
let position = { x: worldSize.width / 2, y: worldSize.height / 2 }; let position = { x: worldSize.width / 2, y: worldSize.height / 2 };
@@ -129,33 +137,35 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
attempts += 1; attempts += 1;
} }
const dust = Bodies.circle(position.x, position.y, 4 + Math.random() * 4, { const body = Bodies.circle(position.x, position.y, objectDef.radius + (Math.random() - 0.5) * objectDef.radius * 0.2, {
restitution: 0.95, restitution: 0.92,
frictionAir: 0.01, frictionAir: objectDef.frictionAir,
friction: 0, friction: 0,
density: 0.0008, density: objectDef.density,
render: { render: {
visible: false visible: false
} }
}) as DustBody; }) as CelestialBody;
dust.plugin = { body.plugin = {
...dust.plugin, ...body.plugin,
isDust: true celestialKind: objectDef.kind
}; };
Body.setVelocity(dust, { Body.setVelocity(body, {
x: (Math.random() - 0.5) * 1.8, x: (Math.random() - 0.5) * (1.9 - objectDef.radius * 0.02),
y: (Math.random() - 0.5) * 1.8 y: (Math.random() - 0.5) * (1.9 - objectDef.radius * 0.02)
}); });
dustBodies.add(dust); celestialBodies.add(body);
World.add(engine.world, dust); World.add(engine.world, body);
}; };
const topOffDust = () => { const topOffField = () => {
while (dustBodies.size < DUST_COUNT) { for (const objectDef of CELESTIAL_OBJECTS) {
createDust(); while (getFieldCount(objectDef.kind) < objectDef.fieldTarget) {
createCelestialObject(objectDef);
}
} }
}; };
@@ -165,31 +175,43 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
Body.scale(blackHole, scale, scale); Body.scale(blackHole, scale, scale);
}; };
const absorbDust = (dust: DustBody) => { const canConsume = (kind: CelestialKind) => getUnlockedKinds(mass).includes(kind);
if (consumedBodies.has(dust)) {
const rejectBody = (body: CelestialBody) => {
const direction = Vector.normalise(Vector.sub(body.position, blackHole.position));
Body.setVelocity(body, Vector.mult(direction, 5.5));
};
const absorbBody = (body: CelestialBody) => {
if (consumedBodies.has(body)) {
return; return;
} }
consumedBodies.add(dust); consumedBodies.add(body);
dustBodies.delete(dust); celestialBodies.delete(body);
World.remove(engine.world, dust); World.remove(engine.world, body);
consumedDust += 1; consumedObjects += 1;
mass += DUST_REWARD; mass += getObjectDef(body.plugin.celestialKind as CelestialKind).rewardMass;
const targetRadius = STARTING_RADIUS + Math.sqrt(mass - STARTING_MASS) * 2.6; syncHoleScale(getRadiusForMass(mass));
syncHoleScale(targetRadius);
emitState(); emitState();
}; };
const handleCollision = (event: IEventCollision<Engine>) => { const handleCollision = (event: IEventCollision<Engine>) => {
for (const pair of event.pairs) { for (const pair of event.pairs) {
const [bodyA, bodyB] = [pair.bodyA as DustBody, pair.bodyB as DustBody]; const [bodyA, bodyB] = [pair.bodyA as CelestialBody, pair.bodyB as CelestialBody];
const dust = bodyA.plugin?.isDust ? bodyA : bodyB.plugin?.isDust ? bodyB : null; const body = bodyA.plugin?.celestialKind ? bodyA : bodyB.plugin?.celestialKind ? bodyB : null;
const other = dust === bodyA ? bodyB : bodyA; const other = body === bodyA ? bodyB : bodyA;
if (dust && other === blackHole) { if (body && other === blackHole) {
absorbDust(dust); const kind = body.plugin.celestialKind as CelestialKind;
if (canConsume(kind)) {
absorbBody(body);
} else {
rejectBody(body);
}
} }
} }
}; };
@@ -264,11 +286,31 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
context.stroke(); context.stroke();
} }
for (const dust of dustBodies) { for (const body of celestialBodies) {
context.fillStyle = "rgba(226, 232, 240, 0.95)"; const objectDef = getObjectDef(body.plugin.celestialKind as CelestialKind);
const isUnlocked = canConsume(objectDef.kind);
const bodyRadius = body.circleRadius ?? objectDef.radius;
context.fillStyle = objectDef.color;
context.beginPath(); context.beginPath();
context.arc(dust.position.x, dust.position.y, dust.circleRadius ?? 5, 0, Math.PI * 2); context.arc(body.position.x, body.position.y, bodyRadius, 0, Math.PI * 2);
context.fill(); context.fill();
if (objectDef.kind !== "dust") {
context.strokeStyle = objectDef.accent;
context.lineWidth = objectDef.kind === "star" ? 3 : 2;
context.beginPath();
context.arc(body.position.x, body.position.y, bodyRadius + (objectDef.kind === "planet" ? 5 : 3), 0, Math.PI * 2);
context.stroke();
}
if (!isUnlocked) {
context.strokeStyle = "rgba(248, 113, 113, 0.8)";
context.lineWidth = 2;
context.beginPath();
context.arc(body.position.x, body.position.y, bodyRadius + 7, 0, Math.PI * 2);
context.stroke();
}
} }
const pullRadius = getPullRadius(); const pullRadius = getPullRadius();
@@ -314,21 +356,26 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
Body.setVelocity(blackHole, { x: 0, y: 0 }); Body.setVelocity(blackHole, { x: 0, y: 0 });
} }
for (const dust of dustBodies) { for (const body of celestialBodies) {
const toHole = Vector.sub(blackHole.position, dust.position); const kind = body.plugin.celestialKind as CelestialKind;
const objectDef = getObjectDef(kind);
const toHole = Vector.sub(blackHole.position, body.position);
const distance = Math.max(1, Vector.magnitude(toHole)); const distance = Math.max(1, Vector.magnitude(toHole));
if (distance < getPullRadius()) { if (distance < getPullRadius()) {
const direction = Vector.normalise(toHole); const direction = Vector.normalise(toHole);
const strength = clamp((getPullRadius() - distance) / getPullRadius(), 0, 1) * 0.00016; const strength =
Body.applyForce(dust, dust.position, Vector.mult(direction, strength * dust.mass)); clamp((getPullRadius() - distance) / getPullRadius(), 0, 1) *
(canConsume(kind) ? 0.00016 : 0.00008) *
(1 / Math.max(1, objectDef.radius * 0.08));
Body.applyForce(body, body.position, Vector.mult(direction, strength * body.mass));
} }
} }
Engine.update(engine, delta); Engine.update(engine, delta);
if (timestamp - lastEmit > EMIT_INTERVAL_MS) { if (timestamp - lastEmit > EMIT_INTERVAL_MS) {
topOffDust(); topOffField();
emitState(); emitState();
lastEmit = timestamp; lastEmit = timestamp;
} }
@@ -338,7 +385,7 @@ export function initMatterScene(canvas: HTMLCanvasElement, onStateChange: (state
}; };
resizeCanvas(); resizeCanvas();
topOffDust(); topOffField();
emitState(); emitState();
Events.on(engine, "collisionStart", handleCollision); Events.on(engine, "collisionStart", handleCollision);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getTierForMass } from "./progression"; import { getCurrentPreyTier, getNextUnlock, getTierForMass } from "./progression";
describe("getTierForMass", () => { describe("getTierForMass", () => {
it("maps early mass to Dustling", () => { it("maps early mass to Dustling", () => {
@@ -8,8 +8,25 @@ describe("getTierForMass", () => {
it("promotes mass thresholds into later tiers", () => { it("promotes mass thresholds into later tiers", () => {
expect(getTierForMass(20)).toBe("Rift Seed"); expect(getTierForMass(20)).toBe("Rift Seed");
expect(getTierForMass(40)).toBe("Event Core"); expect(getTierForMass(24)).toBe("Event Core");
expect(getTierForMass(70)).toBe("World Eater"); expect(getTierForMass(50)).toBe("World Eater");
expect(getTierForMass(110)).toBe("Proto Star"); expect(getTierForMass(95)).toBe("Planet Breaker");
expect(getTierForMass(160)).toBe("Star Forger");
});
it("tracks prey unlocks by mass", () => {
expect(getCurrentPreyTier(12)).toBe("Dust");
expect(getCurrentPreyTier(24)).toBe("Meteorite");
expect(getCurrentPreyTier(50)).toBe("Asteroid");
expect(getCurrentPreyTier(95)).toBe("Planet");
expect(getCurrentPreyTier(160)).toBe("Star");
});
it("returns the next unlock target", () => {
expect(getNextUnlock(12)).toBe("Meteorite");
expect(getNextUnlock(24)).toBe("Asteroid");
expect(getNextUnlock(50)).toBe("Planet");
expect(getNextUnlock(95)).toBe("Star");
expect(getNextUnlock(200)).toBeNull();
}); });
}); });

View File

@@ -1,13 +1,19 @@
import { CELESTIAL_OBJECTS, type CelestialKind } from "./spaceObjects";
export function getTierForMass(mass: number) { export function getTierForMass(mass: number) {
if (mass >= 110) { if (mass >= 160) {
return "Proto Star"; return "Star Forger";
} }
if (mass >= 70) { if (mass >= 95) {
return "Planet Breaker";
}
if (mass >= 50) {
return "World Eater"; return "World Eater";
} }
if (mass >= 40) { if (mass >= 24) {
return "Event Core"; return "Event Core";
} }
@@ -17,3 +23,17 @@ export function getTierForMass(mass: number) {
return "Dustling"; return "Dustling";
} }
export function getUnlockedKinds(mass: number): CelestialKind[] {
return CELESTIAL_OBJECTS.filter((objectDef) => mass >= objectDef.unlockMass).map(
(objectDef) => objectDef.kind
);
}
export function getCurrentPreyTier(mass: number) {
return CELESTIAL_OBJECTS.filter((objectDef) => mass >= objectDef.unlockMass).at(-1)?.label ?? "Dust";
}
export function getNextUnlock(mass: number) {
return CELESTIAL_OBJECTS.find((objectDef) => mass < objectDef.unlockMass)?.label ?? null;
}

View File

@@ -0,0 +1,77 @@
export type CelestialKind = "dust" | "meteorite" | "asteroid" | "planet" | "star";
export interface CelestialObjectDefinition {
kind: CelestialKind;
label: string;
radius: number;
rewardMass: number;
unlockMass: number;
density: number;
frictionAir: number;
color: string;
accent: string;
fieldTarget: number;
}
export const CELESTIAL_OBJECTS: CelestialObjectDefinition[] = [
{
kind: "dust",
label: "Dust",
radius: 5,
rewardMass: 2,
unlockMass: 0,
density: 0.0008,
frictionAir: 0.01,
color: "#e2e8f0",
accent: "#ffffff",
fieldTarget: 24
},
{
kind: "meteorite",
label: "Meteorite",
radius: 10,
rewardMass: 6,
unlockMass: 24,
density: 0.0015,
frictionAir: 0.012,
color: "#f97316",
accent: "#fdba74",
fieldTarget: 10
},
{
kind: "asteroid",
label: "Asteroid",
radius: 16,
rewardMass: 14,
unlockMass: 50,
density: 0.0026,
frictionAir: 0.013,
color: "#94a3b8",
accent: "#cbd5e1",
fieldTarget: 6
},
{
kind: "planet",
label: "Planet",
radius: 24,
rewardMass: 30,
unlockMass: 95,
density: 0.0035,
frictionAir: 0.014,
color: "#38bdf8",
accent: "#a5f3fc",
fieldTarget: 3
},
{
kind: "star",
label: "Star",
radius: 34,
rewardMass: 60,
unlockMass: 160,
density: 0.0042,
frictionAir: 0.015,
color: "#facc15",
accent: "#fde68a",
fieldTarget: 1
}
];

View File

@@ -5,8 +5,10 @@ export interface GameState {
radius: number; radius: number;
pullRadius: number; pullRadius: number;
tier: string; tier: string;
consumedDust: number; consumedObjects: number;
dustRemaining: number; objectsRemaining: number;
preyTier: string;
nextUnlock: string | null;
fps: number; fps: number;
} }
@@ -15,7 +17,9 @@ export const defaultGameState: GameState = {
radius: 24, radius: 24,
pullRadius: 74.4, pullRadius: 74.4,
tier: getTierForMass(12), tier: getTierForMass(12),
consumedDust: 0, consumedObjects: 0,
dustRemaining: 0, objectsRemaining: 0,
preyTier: "Dust",
nextUnlock: "Meteorite",
fps: 60 fps: 60
}; };

View File

@@ -1,8 +1,8 @@
services: services:
app: app:
build: build:
context: .. context: .
dockerfile: docker/Dockerfile dockerfile: Dockerfile
environment: environment:
PORT: "3000" PORT: "3000"
LOG_LEVEL: info LOG_LEVEL: info

View File

@@ -4,7 +4,7 @@
- `client/` hosts the React application and game shell. - `client/` hosts the React application and game shell.
- `server/` hosts the Express API, production static asset serving, and future persistence services. - `server/` hosts the Express API, production static asset serving, and future persistence services.
- `docker/` contains a single-container production build and local compose entrypoint. - `Dockerfile` and `compose.yml` define the single-container production build and local compose entrypoint.
## Immediate Direction ## Immediate Direction

View File

@@ -18,9 +18,9 @@
"lint": "npm run lint:client && npm run lint:server", "lint": "npm run lint:client && npm run lint:server",
"lint:client": "npm --workspace client run lint", "lint:client": "npm --workspace client run lint",
"lint:server": "npm --workspace server run lint", "lint:server": "npm --workspace server run lint",
"docker-build": "docker compose -f docker/docker-compose.yml build", "docker-build": "docker compose build",
"docker-up": "docker compose -f docker/docker-compose.yml up --build", "docker-up": "docker compose up --build",
"docker-down": "docker compose -f docker/docker-compose.yml down" "docker-down": "docker compose down"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1" "concurrently": "^9.2.1"