This commit is contained in:
2025-09-01 22:53:57 -04:00
parent b433a26fc6
commit fd342e7d47
24 changed files with 132 additions and 332 deletions

View File

@@ -1,19 +0,0 @@
-- CreateTable
CREATE TABLE "Game" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"rules" TEXT
);
-- CreateTable
CREATE TABLE "Instance" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"gameId" INTEGER NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Game_name_key" ON "Game"("name");

View File

@@ -1,34 +0,0 @@
/*
Warnings:
- The primary key for the `Game` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `id` on the `Game` table. All the data in the column will be lost.
- You are about to drop the column `gameId` on the `Instance` table. All the data in the column will be lost.
- Added the required column `key` to the `Game` table without a default value. This is not possible if the table is not empty.
- Added the required column `gameKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Game" (
"key" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"rules" TEXT
);
INSERT INTO "new_Game" ("createdAt", "name", "rules", "updatedAt") SELECT "createdAt", "name", "rules", "updatedAt" FROM "Game";
DROP TABLE "Game";
ALTER TABLE "new_Game" RENAME TO "Game";
CREATE TABLE "new_Instance" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("gameState", "id") SELECT "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,20 +0,0 @@
/*
Warnings:
- The primary key for the `Instance` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,28 +0,0 @@
/*
Warnings:
- Added the required column `createdByKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Human" (
"key" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdByKey" TEXT NOT NULL,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_createdByKey_fkey" FOREIGN KEY ("createdByKey") REFERENCES "Human" ("key") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,12 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Human" (
"key" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL DEFAULT ''
);
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
DROP TABLE "Human";
ALTER TABLE "new_Human" RENAME TO "Human";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,45 +0,0 @@
/*
Warnings:
- The required column `token` was added to the `Human` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- CreateTable
CREATE TABLE "_HumanToInstance" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_HumanToInstance_A_fkey" FOREIGN KEY ("A") REFERENCES "Human" ("key") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_HumanToInstance_B_fkey" FOREIGN KEY ("B") REFERENCES "Instance" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Human" (
"key" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '__name__',
"lastActive" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
DROP TABLE "Human";
ALTER TABLE "new_Human" RENAME TO "Human";
CREATE UNIQUE INDEX "Human_token_key" ON "Human"("token");
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdByKey" TEXT NOT NULL,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("createdByKey", "gameKey", "gameState", "id") SELECT "createdByKey", "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "_HumanToInstance_AB_unique" ON "_HumanToInstance"("A", "B");
-- CreateIndex
CREATE INDEX "_HumanToInstance_B_index" ON "_HumanToInstance"("B");

View File

@@ -1,15 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdByKey" TEXT NOT NULL,
"gameKey" TEXT NOT NULL,
"gameState" JSONB,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("createdByKey", "gameKey", "gameState", "id") SELECT "createdByKey", "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -1,37 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Game {
key String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
rules String?
instances Instance[]
}
model Human {
key String @id @default(cuid())
token String @unique @default(cuid())
name String @default("__name__")
lastActive DateTime @default(now())
playingInstances Instance[]
}
model Instance {
id String @id @default(cuid())
createdByKey String
gameKey String
players Human[]
game Game @relation(fields: [gameKey], references: [key])
gameState Json?
}

View File

@@ -1,15 +1,8 @@
{
"name": "@games/server",
"scripts": {
"dev": "concurrently 'pnpm run devserver' 'pnpm run dbstudio'",
"devserver": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
"dbstudio": "pnpm dlx prisma studio --browser none",
"dbdeploy": "pnpm dlx prisma migrate deploy",
"dbtypes": "pnpm dlx prisma generate",
"dbsync": "pnpm dlx prisma migrate dev",
"dbwipe": "pnpm dlx prisma migrate reset",
"prod": "NODE_ENV=production bun run src/index.ts",
"start": "concurrently 'pnpm run prod' 'pnpm run dbstudio'"
"dev": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
"start": "NODE_ENV=production bun run src/index.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.3.3",

View File

@@ -1,6 +0,0 @@
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: path.join("db", "schema.prisma"),
});

View File

@@ -4,9 +4,9 @@ import dayjs from "dayjs";
import { Elysia, t } from "elysia";
import { combine } from "kefir";
import Bus from "kefir-bus";
import db from "./db";
import { liveTable, WsIn, WsOut } from "./table";
import { err } from "./logging";
import { generateTokenAndKey, resolveToken, tokenExists } from "./human";
export const WS = Bus<
{
@@ -19,63 +19,23 @@ export const WS = Bus<
const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => {
let human: Human | null;
if (
token.value == null ||
(human = await db.human.findUnique({
where: {
token: token.value,
},
})) == null
) {
human = await db.human.create({
data: {},
});
console.log("WHOAMI");
let key: string | undefined;
if (token.value == null || (key = resolveToken(token.value)) == null) {
const [newToken, newKey] = generateTokenAndKey();
token.set({
value: human.token,
value: newToken,
expires: dayjs().add(1, "year").toDate(),
httpOnly: true,
});
return newKey;
}
return human.key;
return key;
})
.derive(async ({ cookie: { token }, status }) => {
const humanKey = await db.human
.findUnique({
where: { token: token.value },
})
.then((human) => human?.key);
const humanKey = token.value && resolveToken(token.value);
return humanKey != null ? { humanKey } : status(401);
})
.post(
"/setName",
({ body: { name }, humanKey }) =>
db.human.update({
where: {
key: humanKey,
},
data: {
name,
},
}),
{
body: t.Object({
name: t.String(),
}),
}
)
.get("/profile", ({ humanKey, query: { otherHumanKey } }) =>
db.human
.findFirst({ where: { key: otherHumanKey ?? humanKey } })
.then((human) => {
if (human == null) {
return null;
}
const { token, ...safeProfile } = human;
return safeProfile;
})
)
.ws("/ws/:tableKey", {
body: WsIn,
response: WsOut,

View File

@@ -1,5 +0,0 @@
"use server";
import { PrismaClient } from "@prisma/client";
export default new PrismaClient();

17
pkg/server/src/human.ts Normal file
View File

@@ -0,0 +1,17 @@
const tokenToHumanKey: { [token: string]: string } = {};
const playerKeys: Set<string> = new Set();
export const generateTokenAndKey = () => {
let token: string, key: string;
do {
[token, key] = [crypto.randomUUID(), crypto.randomUUID()];
tokenToHumanKey[token] = key;
playerKeys.add(key);
} while (!(token in tokenToHumanKey || playerKeys.has(key)));
return [token, key];
};
export const resolveToken = (token: string) =>
tokenToHumanKey[token] as string | undefined;
export const tokenExists = (token: string) => token in tokenToHumanKey;
export const keyExists = (key: string) => playerKeys.has(key);

View File

@@ -15,6 +15,7 @@ import { log } from "./logging";
export const WsOut = t.Object({
playersPresent: t.Optional(t.Array(t.String())),
playerNames: t.Optional(t.Record(t.String(), t.String())),
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))),
gameConfig: t.Optional(t.Any()),
view: t.Optional(t.Any()),
@@ -22,6 +23,7 @@ export const WsOut = t.Object({
});
export type TWsOut = typeof WsOut.static;
export const WsIn = t.Union([
t.Object({ name: t.String() }),
t.Object({ ready: t.Boolean() }),
t.Object({ action: t.Any() }),
t.Object({ quit: t.Literal(true) }),
@@ -55,6 +57,7 @@ type TablePayload<
>;
gameConfig: Property<GameConfig | null, any>;
results: Property<GameResult, any>;
playerNames: Property<{ [key: string]: string }, any>;
};
player: {
[key: string]: {
@@ -133,11 +136,12 @@ export const liveTable = <
});
});
const { ready, action, quit } = partition(
["ready", "action", "quit"],
const { name, ready, action, quit } = partition(
["name", "ready", "action", "quit"],
messages
) as unknown as {
// yuck
name: Observable<Attributed & { name: string }, any>;
ready: Observable<Attributed & { ready: boolean }, any>;
action: Observable<Attributed & { action: GameAction }, any>;
quit: Observable<Attributed, any>;
@@ -145,17 +149,23 @@ export const liveTable = <
const gameEnds = quit.map((_) => null);
const gameIsActivePool = pool<boolean, any>();
const gameIsActive = merge([
constant(false),
gameIsActivePool,
]).toProperty();
const playersReady = multiScan(
null as {
[key: string]: boolean;
} | null,
[
playersPresent, // TODO: filter to only outside active games
playersPresent.filterBy(invert(gameIsActive)),
(prev, players: ValueWithin<typeof playersPresent>) =>
Object.fromEntries(players.map((p) => [p, prev?.[p] ?? false])),
],
[
ready, // TODO: filter to only outside active games
ready.filterBy(invert(gameIsActive)),
(prev, evt: ValueWithin<typeof ready>) =>
prev?.[evt.humanKey] != null
? {
@@ -172,6 +182,13 @@ export const liveTable = <
]
).toProperty();
const playerNames = name
.scan(
(prev, n) => ({ ...prev, [n.humanKey]: n.name }),
{} as { [key: string]: string }
)
.toProperty();
const gameStarts = playersReady
.filter(
(pr) =>
@@ -194,12 +211,6 @@ export const liveTable = <
const resultsPool = pool<GameResult | null, any>();
const results = merge([constant(null), resultsPool]).toProperty();
const gameIsActivePool = pool<boolean, any>();
const gameIsActive = merge([
constant(false),
gameIsActivePool,
]).toProperty();
const gameState = multiScan(
null as GameState | null,
[
@@ -270,6 +281,7 @@ export const liveTable = <
playersReady,
gameConfig,
results,
playerNames,
},
player: playerStreams,
},