Restructure into pnpm monorepo with game shell, docs, and SpacetimeDB backend

- Restructure flat static prototype into pnpm workspace monorepo
- apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection
- apps/docs: design docs and prototypes
- apps/site: landing page
- packages/ui: shared Button and Panel primitives
- services/spacetimedb: backend module (9 tables, 11 reducers)
- Archive legacy static files to archive/legacy-static/
- Game loop: connect, undock, target, approach, dock, mine, sell
- Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
This commit is contained in:
2026-05-31 17:56:56 -04:00
parent 436f282fa8
commit 316a44661b
234 changed files with 3717 additions and 101 deletions

View File

@@ -0,0 +1,595 @@
import { schema, table, t } from "spacetimedb/server";
const STARTER_SYSTEM_ID = "solace";
const STARTER_STATION_ID = "solace-prime";
const STARTER_STATION_POI_ID = "poi-solace-prime";
const STARTER_BELT_POI_ID = "poi-solace-belt-alpha";
const APPROACH_DURATION_MS = 5000n;
const MINING_DURATION_MS = 6000n;
const STARTER_WALLET_ISK = 25000n;
const STARTER_CARGO_CAPACITY = 2500n;
const MINING_YIELD_QUANTITY = 1000n;
const VELDSPAR_ITEM_ID = "ore-veldspar";
const VELDSPAR_ITEM_NAME = "Veldspar";
const VELDSPAR_UNIT_PRICE = 12n;
const player = table(
{ name: "player", public: true },
{
identity: t.identity().primaryKey(),
display_name: t.string(),
created_at: t.timestamp(),
updated_at: t.timestamp(),
last_connected_at: t.timestamp(),
is_connected: t.bool(),
},
);
const ship = table(
{ name: "ship", public: true },
{
ship_id: t.u64().primaryKey().autoInc(),
owner_identity: t.identity().index("btree"),
ship_name: t.string(),
hull_type: t.string(),
current_system_id: t.string(),
docked_station_id: t.string(),
current_poi_id: t.string(),
selected_poi_id: t.string(),
flight_mode: t.string(),
x: t.f32(),
y: t.f32(),
z: t.f32(),
cargo_capacity: t.u64(),
},
);
const system = table(
{ name: "system", public: true },
{
system_id: t.string().primaryKey(),
name: t.string(),
security_level: t.f32(),
},
);
const station = table(
{ name: "station", public: true },
{
station_id: t.string().primaryKey(),
system_id: t.string().index("btree"),
name: t.string(),
},
);
const point_of_interest = table(
{ name: "point_of_interest", public: true },
{
poi_id: t.string().primaryKey(),
system_id: t.string().index("btree"),
name: t.string(),
poi_type: t.string(),
x: t.f32(),
y: t.f32(),
z: t.f32(),
station_id: t.string(),
},
);
const cargo_item = table(
{ name: "cargo_item", public: true },
{
cargo_item_id: t.u64().primaryKey().autoInc(),
owner_identity: t.identity().index("btree"),
ship_id: t.u64().index("btree"),
item_id: t.string(),
item_name: t.string(),
category: t.string(),
quantity: t.u64(),
unit_price: t.u64(),
},
);
const wallet = table(
{ name: "wallet", public: true },
{
owner_identity: t.identity().primaryKey(),
isk: t.u64(),
updated_at: t.timestamp(),
},
);
const ship_operation = table(
{ name: "ship_operation", public: true },
{
ship_id: t.u64().primaryKey(),
operation_type: t.string(),
target_poi_id: t.string(),
started_at: t.timestamp(),
duration_ms: t.u64(),
completes_at_ms: t.u64(),
},
);
const server_event = table(
{ name: "server_event", public: true },
{
event_id: t.u64().primaryKey().autoInc(),
at: t.timestamp(),
actor_identity: t.identity().index("btree"),
event_type: t.string(),
message: t.string(),
},
);
const spacetimedb = schema({
player,
ship,
system,
station,
point_of_interest,
cargo_item,
wallet,
ship_operation,
server_event,
});
export default spacetimedb;
export const seedWorld = spacetimedb.reducer({}, (ctx) => {
seedStarterWorld(ctx);
writeEvent(ctx, "world_seeded", "Starter system and station are available.");
});
export const connectPlayer = spacetimedb.reducer({ displayName: t.string() }, (ctx, { displayName }) => {
seedStarterWorld(ctx);
const trimmedName = normalizeDisplayName(displayName);
const existingPlayer = ctx.db.player.identity.find(ctx.sender);
const timestamp = ctx.timestamp;
if (existingPlayer) {
ctx.db.player.identity.update({
...existingPlayer,
display_name: trimmedName,
updated_at: timestamp,
last_connected_at: timestamp,
is_connected: true,
});
} else {
ctx.db.player.insert({
identity: ctx.sender,
display_name: trimmedName,
created_at: timestamp,
updated_at: timestamp,
last_connected_at: timestamp,
is_connected: true,
});
}
const existingShip = findStarterShip(ctx, ctx.sender);
if (!existingShip) {
ctx.db.ship.insert({
ship_id: 0n,
owner_identity: ctx.sender,
ship_name: `${trimmedName}'s Ibis`,
hull_type: "Starter Frigate",
current_system_id: STARTER_SYSTEM_ID,
docked_station_id: STARTER_STATION_ID,
current_poi_id: STARTER_STATION_POI_ID,
selected_poi_id: "",
flight_mode: "docked",
x: 0,
y: 0,
z: 0,
cargo_capacity: STARTER_CARGO_CAPACITY,
});
}
if (!ctx.db.wallet.owner_identity.find(ctx.sender)) {
ctx.db.wallet.insert({
owner_identity: ctx.sender,
isk: STARTER_WALLET_ISK,
updated_at: timestamp,
});
}
writeEvent(ctx, "player_connected", `${trimmedName} connected to VOID::NAV.`);
});
export const renamePlayer = spacetimedb.reducer({ displayName: t.string() }, (ctx, { displayName }) => {
const existingPlayer = ctx.db.player.identity.find(ctx.sender);
const trimmedName = normalizeDisplayName(displayName);
if (!existingPlayer) {
throw new Error("Player must connect before renaming.");
}
ctx.db.player.identity.update({
...existingPlayer,
display_name: trimmedName,
updated_at: ctx.timestamp,
});
writeEvent(ctx, "player_renamed", `Pilot renamed to ${trimmedName}.`);
});
export const ping = spacetimedb.reducer({}, (ctx) => {
writeEvent(ctx, "ping", "Client ping reached the SpacetimeDB module.");
});
export const undock = spacetimedb.reducer({}, (ctx) => {
const ship = requirePlayerShip(ctx);
if (ship.docked_station_id.length === 0) {
throw new Error("Ship is already undocked.");
}
ctx.db.ship.ship_id.update({
...ship,
docked_station_id: "",
flight_mode: "flight",
});
writeEvent(ctx, "ship_undocked", `${ship.ship_name} undocked from ${ship.docked_station_id}.`);
});
export const selectTarget = spacetimedb.reducer({ poiId: t.string() }, (ctx, { poiId }) => {
const ship = requirePlayerShip(ctx);
const poi = requirePoiInCurrentSystem(ctx, ship, poiId);
ctx.db.ship.ship_id.update({
...ship,
selected_poi_id: poi.poi_id,
});
writeEvent(ctx, "target_selected", `${ship.ship_name} selected ${poi.name}.`);
});
export const startApproach = spacetimedb.reducer({}, (ctx) => {
const ship = requirePlayerShip(ctx);
if (ship.docked_station_id.length > 0) {
throw new Error("Ship must be undocked before approaching a target.");
}
if (ship.selected_poi_id.length === 0) {
throw new Error("Select a target before starting approach.");
}
const target = requirePoiInCurrentSystem(ctx, ship, ship.selected_poi_id);
if (ctx.db.ship_operation.ship_id.find(ship.ship_id)) {
throw new Error("Ship already has an active operation.");
}
const nowMs = ctx.timestamp.toMillis();
ctx.db.ship_operation.insert({
ship_id: ship.ship_id,
operation_type: "approach",
target_poi_id: target.poi_id,
started_at: ctx.timestamp,
duration_ms: APPROACH_DURATION_MS,
completes_at_ms: nowMs + APPROACH_DURATION_MS,
});
ctx.db.ship.ship_id.update({
...ship,
flight_mode: "approaching",
});
writeEvent(ctx, "approach_started", `${ship.ship_name} started approach to ${target.name}.`);
});
export const completeApproach = spacetimedb.reducer({}, (ctx) => {
const ship = requirePlayerShip(ctx);
const operation = requireOperation(ctx, ship.ship_id, "approach");
requireElapsed(ctx, operation.completes_at_ms, "Approach is still in progress.");
const target = requirePoiInCurrentSystem(ctx, ship, operation.target_poi_id);
ctx.db.ship_operation.ship_id.delete(ship.ship_id);
ctx.db.ship.ship_id.update({
...ship,
current_poi_id: target.poi_id,
flight_mode: "flight",
x: target.x,
y: target.y,
z: target.z,
});
writeEvent(ctx, "approach_completed", `${ship.ship_name} arrived at ${target.name}.`);
});
export const dock = spacetimedb.reducer({}, (ctx) => {
const ship = requirePlayerShip(ctx);
if (ship.docked_station_id.length > 0) {
throw new Error("Ship is already docked.");
}
if (ctx.db.ship_operation.ship_id.find(ship.ship_id)) {
throw new Error("Cannot dock while an operation is active.");
}
const currentPoi = requireCurrentPoi(ctx, ship);
if (currentPoi.station_id.length === 0) {
throw new Error("Current point of interest is not dockable.");
}
ctx.db.ship.ship_id.update({
...ship,
docked_station_id: currentPoi.station_id,
flight_mode: "docked",
});
writeEvent(ctx, "ship_docked", `${ship.ship_name} docked at ${currentPoi.name}.`);
});
export const startMining = spacetimedb.reducer({}, (ctx) => {
const ship = requirePlayerShip(ctx);
if (ship.docked_station_id.length > 0) {
throw new Error("Ship must be undocked before mining.");
}
if (ctx.db.ship_operation.ship_id.find(ship.ship_id)) {
throw new Error("Ship already has an active operation.");
}
const currentPoi = requireCurrentPoi(ctx, ship);
if (currentPoi.poi_type !== "asteroid_belt") {
throw new Error("Mining requires being at an asteroid belt.");
}
const freeCapacity = getFreeCargoCapacity(ctx, ship);
if (freeCapacity <= 0n) {
throw new Error("Cargo hold is full.");
}
const nowMs = ctx.timestamp.toMillis();
ctx.db.ship_operation.insert({
ship_id: ship.ship_id,
operation_type: "mining",
target_poi_id: currentPoi.poi_id,
started_at: ctx.timestamp,
duration_ms: MINING_DURATION_MS,
completes_at_ms: nowMs + MINING_DURATION_MS,
});
ctx.db.ship.ship_id.update({
...ship,
flight_mode: "mining",
});
writeEvent(ctx, "mining_started", `${ship.ship_name} started mining ${currentPoi.name}.`);
});
export const completeMiningCycle = spacetimedb.reducer({}, (ctx) => {
const ship = requirePlayerShip(ctx);
const operation = requireOperation(ctx, ship.ship_id, "mining");
requireElapsed(ctx, operation.completes_at_ms, "Mining cycle is still in progress.");
const currentPoi = requireCurrentPoi(ctx, ship);
if (currentPoi.poi_type !== "asteroid_belt") {
throw new Error("Mining can only complete at an asteroid belt.");
}
const freeCapacity = getFreeCargoCapacity(ctx, ship);
if (freeCapacity <= 0n) {
throw new Error("Cargo hold is full.");
}
const quantity = minBigint(MINING_YIELD_QUANTITY, freeCapacity);
const existingCargo = findCargoItem(ctx, ship.ship_id, VELDSPAR_ITEM_ID);
if (existingCargo) {
ctx.db.cargo_item.cargo_item_id.update({
...existingCargo,
quantity: existingCargo.quantity + quantity,
unit_price: VELDSPAR_UNIT_PRICE,
});
} else {
ctx.db.cargo_item.insert({
cargo_item_id: 0n,
owner_identity: ctx.sender,
ship_id: ship.ship_id,
item_id: VELDSPAR_ITEM_ID,
item_name: VELDSPAR_ITEM_NAME,
category: "ore",
quantity,
unit_price: VELDSPAR_UNIT_PRICE,
});
}
ctx.db.ship_operation.ship_id.delete(ship.ship_id);
ctx.db.ship.ship_id.update({
...ship,
flight_mode: "flight",
});
writeEvent(ctx, "mining_completed", `${ship.ship_name} mined ${quantity.toString()} ${VELDSPAR_ITEM_NAME}.`);
});
export const sellOreToNpcMarket = spacetimedb.reducer(
{ itemId: t.string(), quantity: t.u64() },
(ctx, { itemId, quantity }) => {
const ship = requirePlayerShip(ctx);
if (ship.docked_station_id.length === 0) {
throw new Error("Ship must be docked before selling ore.");
}
if (quantity <= 0n) {
throw new Error("Sell quantity must be greater than zero.");
}
const cargo = findCargoItem(ctx, ship.ship_id, itemId);
if (!cargo || cargo.category !== "ore") {
throw new Error("Ore cargo item not found.");
}
if (cargo.quantity < quantity) {
throw new Error("Cannot sell more ore than the ship is carrying.");
}
const playerWallet = ctx.db.wallet.owner_identity.find(ctx.sender);
if (!playerWallet) {
throw new Error("Player wallet was not found.");
}
if (cargo.quantity === quantity) {
ctx.db.cargo_item.cargo_item_id.delete(cargo.cargo_item_id);
} else {
ctx.db.cargo_item.cargo_item_id.update({
...cargo,
quantity: cargo.quantity - quantity,
});
}
const payout = quantity * cargo.unit_price;
ctx.db.wallet.owner_identity.update({
...playerWallet,
isk: playerWallet.isk + payout,
updated_at: ctx.timestamp,
});
writeEvent(ctx, "ore_sold", `Sold ${quantity.toString()} ${cargo.item_name} for ${payout.toString()} ISK.`);
},
);
function seedStarterWorld(ctx: ReducerContextLike) {
if (!ctx.db.system.system_id.find(STARTER_SYSTEM_ID)) {
ctx.db.system.insert({
system_id: STARTER_SYSTEM_ID,
name: "Solace",
security_level: 0.9,
});
}
if (!ctx.db.station.station_id.find(STARTER_STATION_ID)) {
ctx.db.station.insert({
station_id: STARTER_STATION_ID,
system_id: STARTER_SYSTEM_ID,
name: "Solace Prime Orbital",
});
}
if (!ctx.db.point_of_interest.poi_id.find(STARTER_STATION_POI_ID)) {
ctx.db.point_of_interest.insert({
poi_id: STARTER_STATION_POI_ID,
system_id: STARTER_SYSTEM_ID,
name: "Solace Prime Orbital",
poi_type: "station",
x: 0,
y: 0,
z: 0,
station_id: STARTER_STATION_ID,
});
}
if (!ctx.db.point_of_interest.poi_id.find(STARTER_BELT_POI_ID)) {
ctx.db.point_of_interest.insert({
poi_id: STARTER_BELT_POI_ID,
system_id: STARTER_SYSTEM_ID,
name: "Solace Belt Alpha",
poi_type: "asteroid_belt",
x: 34,
y: -2,
z: -18,
station_id: "",
});
}
}
function findStarterShip(ctx: ReducerContextLike, ownerIdentity: unknown) {
return Array.from(ctx.db.ship.iter()).find((row) => identitiesEqual(row.owner_identity, ownerIdentity));
}
function requirePlayerShip(ctx: ReducerContextLike) {
const playerShip = findStarterShip(ctx, ctx.sender);
if (!playerShip) {
throw new Error("Player must connect before controlling a ship.");
}
return playerShip;
}
function requirePoiInCurrentSystem(ctx: ReducerContextLike, ship: ReturnType<typeof requirePlayerShip>, poiId: string) {
const poi = ctx.db.point_of_interest.poi_id.find(poiId);
if (!poi) {
throw new Error(`Point of interest not found: ${poiId}.`);
}
if (poi.system_id !== ship.current_system_id) {
throw new Error("Selected point of interest is not in the ship's current system.");
}
return poi;
}
function requireCurrentPoi(ctx: ReducerContextLike, ship: ReturnType<typeof requirePlayerShip>) {
if (ship.current_poi_id.length === 0) {
throw new Error("Ship is not at a point of interest.");
}
return requirePoiInCurrentSystem(ctx, ship, ship.current_poi_id);
}
function requireOperation(ctx: ReducerContextLike, shipId: bigint, operationType: string) {
const operation = ctx.db.ship_operation.ship_id.find(shipId);
if (!operation) {
throw new Error(`No active ${operationType} operation found.`);
}
if (operation.operation_type !== operationType) {
throw new Error(`Active operation is ${operation.operation_type}, not ${operationType}.`);
}
return operation;
}
function requireElapsed(ctx: ReducerContextLike, completesAtMs: bigint, message: string) {
if (ctx.timestamp.toMillis() < completesAtMs) {
throw new Error(message);
}
}
function findCargoItem(ctx: ReducerContextLike, shipId: bigint, itemId: string) {
return Array.from(ctx.db.cargo_item.iter()).find((row) => row.ship_id === shipId && row.item_id === itemId);
}
function getShipCargoQuantity(ctx: ReducerContextLike, shipId: bigint) {
return Array.from(ctx.db.cargo_item.iter())
.filter((row) => row.ship_id === shipId)
.reduce((total, row) => total + row.quantity, 0n);
}
function getFreeCargoCapacity(ctx: ReducerContextLike, ship: ReturnType<typeof requirePlayerShip>) {
const usedCapacity = getShipCargoQuantity(ctx, ship.ship_id);
return ship.cargo_capacity > usedCapacity ? ship.cargo_capacity - usedCapacity : 0n;
}
function minBigint(left: bigint, right: bigint) {
return left < right ? left : right;
}
function identitiesEqual(left: unknown, right: unknown) {
if (left && right && typeof left === "object" && "isEqual" in left && typeof left.isEqual === "function") {
return left.isEqual(right);
}
return left === right;
}
function writeEvent(ctx: ReducerContextLike, eventType: string, message: string) {
ctx.db.server_event.insert({
event_id: 0n,
at: ctx.timestamp,
actor_identity: ctx.sender,
event_type: eventType,
message,
});
}
function normalizeDisplayName(displayName: string) {
const trimmedName = displayName.trim();
return trimmedName.length > 0 ? trimmedName.slice(0, 32) : "New Pilot";
}
type ReducerContextLike = Parameters<Parameters<typeof spacetimedb.reducer>[1]>[0];