package -> pkg
This commit is contained in:
3
pkg/server/.gitignore
vendored
Normal file
3
pkg/server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# deps
|
||||
node_modules/
|
||||
public
|
||||
19
pkg/server/db/migrations/20250805231347_init/migration.sql
Normal file
19
pkg/server/db/migrations/20250805231347_init/migration.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 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");
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
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;
|
||||
28
pkg/server/db/migrations/20250809192322_humans/migration.sql
Normal file
28
pkg/server/db/migrations/20250809192322_humans/migration.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
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;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 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;
|
||||
45
pkg/server/db/migrations/20250818213152_tokens/migration.sql
Normal file
45
pkg/server/db/migrations/20250818213152_tokens/migration.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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");
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 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;
|
||||
3
pkg/server/db/migrations/migration_lock.toml
Normal file
3
pkg/server/db/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
37
pkg/server/db/schema.prisma
Normal file
37
pkg/server/db/schema.prisma
Normal file
@@ -0,0 +1,37 @@
|
||||
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?
|
||||
}
|
||||
36
pkg/server/package.json
Normal file
36
pkg/server/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@games/shared": "workspace:*",
|
||||
"@prisma/client": "6.13.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"elysia": "^1.3.8",
|
||||
"elysia-ip": "^1.0.10",
|
||||
"elysia-rate-limit": "^4.4.0",
|
||||
"kefir": "^3.8.8",
|
||||
"kefir-bus": "^2.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"object-hash": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/kefir": "^3.8.11",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"concurrently": "^9.2.0",
|
||||
"prisma": "6.13.0",
|
||||
"ts-xor": "^1.3.0"
|
||||
}
|
||||
}
|
||||
6
pkg/server/prisma.config.ts
Normal file
6
pkg/server/prisma.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: path.join("db", "schema.prisma"),
|
||||
});
|
||||
146
pkg/server/src/api.ts
Normal file
146
pkg/server/src/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import {
|
||||
getSimplePlayerView,
|
||||
SimpleAction,
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
} from "@games/shared/games/simple";
|
||||
import { human } from "./human";
|
||||
import dayjs from "dayjs";
|
||||
import db from "./db";
|
||||
import { liveTable, WsOut, WsIn } from "./table";
|
||||
import { Human } from "@prisma/client";
|
||||
import { combine } from "kefir";
|
||||
|
||||
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: {},
|
||||
});
|
||||
token.set({
|
||||
value: human.token,
|
||||
expires: dayjs().add(1, "year").toDate(),
|
||||
httpOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
return human.key;
|
||||
})
|
||||
.use(human)
|
||||
.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;
|
||||
})
|
||||
)
|
||||
.get("/games", () => [{ key: "simple", name: "simple" }])
|
||||
.ws("/ws/:tableKey", {
|
||||
async open({
|
||||
data: {
|
||||
params: { tableKey },
|
||||
humanKey,
|
||||
},
|
||||
send,
|
||||
}) {
|
||||
const table = liveTable<
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
SimpleAction
|
||||
>(tableKey);
|
||||
|
||||
table.inputs.connectionChanges.emit({
|
||||
humanKey,
|
||||
presence: "joined",
|
||||
});
|
||||
|
||||
table.outputs.playersPresent.onValue((players) =>
|
||||
send({ players })
|
||||
);
|
||||
|
||||
table.outputs.playersReady
|
||||
.skipDuplicates()
|
||||
.onValue((readys) => send({ playersReady: readys }));
|
||||
|
||||
combine(
|
||||
[table.outputs.gameState],
|
||||
[table.outputs.gameConfig],
|
||||
(state, config) =>
|
||||
state &&
|
||||
config &&
|
||||
getSimplePlayerView(config, state, humanKey)
|
||||
)
|
||||
.toProperty()
|
||||
.onValue((view) => send({ view }));
|
||||
},
|
||||
|
||||
response: WsOut,
|
||||
body: WsIn,
|
||||
|
||||
message(
|
||||
{
|
||||
data: {
|
||||
humanKey,
|
||||
params: { tableKey },
|
||||
},
|
||||
},
|
||||
body
|
||||
) {
|
||||
const {
|
||||
inputs: { readys, actions, quits },
|
||||
} = liveTable(tableKey);
|
||||
|
||||
if ("ready" in body) {
|
||||
readys.emit({ humanKey, ...body });
|
||||
} else if ("action" in body) {
|
||||
actions.emit({ humanKey, ...body.action });
|
||||
} else if ("quit" in body) {
|
||||
quits.emit({ humanKey });
|
||||
}
|
||||
},
|
||||
|
||||
async close({
|
||||
data: {
|
||||
params: { tableKey },
|
||||
humanKey,
|
||||
},
|
||||
}) {
|
||||
liveTable(tableKey).inputs.connectionChanges.emit({
|
||||
humanKey,
|
||||
presence: "left",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default api;
|
||||
export type Api = typeof api;
|
||||
5
pkg/server/src/db.ts
Normal file
5
pkg/server/src/db.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export default new PrismaClient();
|
||||
13
pkg/server/src/human.ts
Normal file
13
pkg/server/src/human.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Elysia from "elysia";
|
||||
import db from "./db";
|
||||
|
||||
export const human = new Elysia({ name: "human" })
|
||||
.derive(async ({ cookie: { token }, status }) => {
|
||||
const humanKey = await db.human
|
||||
.findUnique({
|
||||
where: { token: token.value },
|
||||
})
|
||||
.then((human) => human?.key);
|
||||
return humanKey != null ? { humanKey } : status(401);
|
||||
})
|
||||
.as("scoped");
|
||||
33
pkg/server/src/index.ts
Normal file
33
pkg/server/src/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { Elysia, env } from "elysia";
|
||||
import api from "./api";
|
||||
|
||||
const port = env.PORT || 5001;
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
cors({
|
||||
origin: ["http://localhost:3000", "https://games.drm.dev"],
|
||||
})
|
||||
)
|
||||
// .onRequest(({ request }) => {
|
||||
// console.log(request.method, request.url);
|
||||
// })
|
||||
.onError(({ error }) => {
|
||||
console.error(error);
|
||||
return error;
|
||||
})
|
||||
.get("/ping", () => "pong")
|
||||
.use(api)
|
||||
.get("/*", () => Bun.file("./public/index.html"))
|
||||
.use(
|
||||
staticPlugin({
|
||||
assets: "public",
|
||||
prefix: "/",
|
||||
alwaysStatic: true,
|
||||
})
|
||||
)
|
||||
.listen(port);
|
||||
|
||||
console.log("server started on", port);
|
||||
29
pkg/server/src/kefir-extension.ts
Normal file
29
pkg/server/src/kefir-extension.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { merge, Observable } from "kefir";
|
||||
|
||||
export const transform = <
|
||||
T,
|
||||
Mutations extends [Observable<any, any>, (prev: T, evt: any) => T][]
|
||||
>(
|
||||
initValue: T,
|
||||
...mutations: Mutations
|
||||
): Observable<T, unknown> =>
|
||||
merge(
|
||||
mutations.map(([source, mutation]) =>
|
||||
source.map((event) => ({ event, mutation }))
|
||||
)
|
||||
).scan((prev, { event, mutation }) => mutation(prev, event), initValue);
|
||||
|
||||
export const partition =
|
||||
<C extends readonly [...string[]], T, E>(
|
||||
classes: C,
|
||||
partitionFn: (v: T) => C[number]
|
||||
) =>
|
||||
(obs: Observable<T, E>) => {
|
||||
const assigned = obs.map((obj) => ({ obj, cls: partitionFn(obj) }));
|
||||
return Object.fromEntries(
|
||||
classes.map((C) => [
|
||||
C,
|
||||
assigned.filter(({ cls }) => cls == C).map(({ obj }) => obj),
|
||||
])
|
||||
);
|
||||
};
|
||||
0
pkg/server/src/logging.ts
Normal file
0
pkg/server/src/logging.ts
Normal file
202
pkg/server/src/table.ts
Normal file
202
pkg/server/src/table.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { t } from "elysia";
|
||||
import { combine, pool, Property } from "kefir";
|
||||
import Bus, { type Bus as TBus } from "kefir-bus";
|
||||
import {
|
||||
newSimpleGameState,
|
||||
resolveSimpleAction,
|
||||
SimpleAction,
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
} from "@games/shared/games/simple";
|
||||
import { transform } from "./kefir-extension";
|
||||
|
||||
export const WsOut = t.Object({
|
||||
players: t.Optional(t.Array(t.String())),
|
||||
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))),
|
||||
view: t.Optional(t.Any()),
|
||||
});
|
||||
export type TWsOut = typeof WsOut.static;
|
||||
export const WsIn = t.Union([
|
||||
t.Object({ ready: t.Boolean() }),
|
||||
t.Object({ action: t.Any() }),
|
||||
t.Object({ quit: t.Literal(true) }),
|
||||
]);
|
||||
export type TWsIn = typeof WsIn.static;
|
||||
|
||||
type Attributed = { humanKey: string };
|
||||
type TablePayload<GameConfig, GameState, GameAction> = {
|
||||
inputs: {
|
||||
connectionChanges: TBus<
|
||||
Attributed & { presence: "joined" | "left" },
|
||||
never
|
||||
>;
|
||||
|
||||
readys: TBus<Attributed & { ready: boolean }, any>;
|
||||
actions: TBus<Attributed & GameAction, any>;
|
||||
quits: TBus<Attributed, any>;
|
||||
};
|
||||
outputs: {
|
||||
playersPresent: Property<string[], any>;
|
||||
playersReady: Property<{ [key: string]: boolean } | null, any>;
|
||||
gameConfig: Property<GameConfig | null, any>;
|
||||
gameState: Property<GameState | null, any>;
|
||||
};
|
||||
};
|
||||
|
||||
const tables: {
|
||||
[key: string]: TablePayload<unknown, unknown, unknown>;
|
||||
} = {};
|
||||
|
||||
export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
|
||||
if (!(key in tables)) {
|
||||
const inputs: TablePayload<
|
||||
GameConfig,
|
||||
GameState,
|
||||
GameAction
|
||||
>["inputs"] = {
|
||||
connectionChanges: Bus(),
|
||||
readys: Bus(),
|
||||
actions: Bus(),
|
||||
quits: Bus(),
|
||||
};
|
||||
const { connectionChanges, readys, actions, quits } = inputs;
|
||||
quits.log("quits");
|
||||
// =======
|
||||
|
||||
const playersPresent = connectionChanges
|
||||
.scan((prev, evt) => {
|
||||
if (evt.presence == "left" && prev[evt.humanKey] == 1) {
|
||||
const { [evt.humanKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[evt.humanKey]:
|
||||
(prev[evt.humanKey] ?? 0) +
|
||||
(evt.presence == "joined" ? 1 : -1),
|
||||
};
|
||||
}, {} as { [key: string]: number })
|
||||
.map((counts) => Object.keys(counts))
|
||||
.toProperty();
|
||||
|
||||
const gameEnds = quits.map((_) => null);
|
||||
|
||||
const gameStarts = pool<null, any>();
|
||||
const playersReady = transform(
|
||||
null as { [key: string]: boolean } | null,
|
||||
[
|
||||
playersPresent,
|
||||
(prev, players: string[]) =>
|
||||
Object.fromEntries(
|
||||
players.map((p) => [p, prev?.[p] ?? false])
|
||||
),
|
||||
],
|
||||
[
|
||||
readys,
|
||||
(prev, evt: { humanKey: string; ready: boolean }) =>
|
||||
prev?.[evt.humanKey] != null
|
||||
? { ...prev, [evt.humanKey]: evt.ready }
|
||||
: prev,
|
||||
],
|
||||
[gameStarts, () => null],
|
||||
[
|
||||
combine([gameEnds], [playersPresent], (_, players) => players),
|
||||
(_, players: string[]) =>
|
||||
Object.fromEntries(players.map((p) => [p, false])),
|
||||
]
|
||||
)
|
||||
.toProperty()
|
||||
.log("playersReady");
|
||||
|
||||
gameStarts.plug(
|
||||
playersReady
|
||||
.filter(
|
||||
(pr) =>
|
||||
Object.values(pr ?? {}).length > 0 &&
|
||||
Object.values(pr!).every((ready) => ready)
|
||||
)
|
||||
.map((_) => null)
|
||||
.log("gameStarts")
|
||||
);
|
||||
|
||||
const gameConfigPool = pool<
|
||||
{
|
||||
game: string;
|
||||
players: string[];
|
||||
},
|
||||
never
|
||||
>();
|
||||
|
||||
const gameConfig = gameConfigPool.toProperty();
|
||||
|
||||
const gameState = transform(
|
||||
null as SimpleGameState | null,
|
||||
[
|
||||
combine([gameStarts], [gameConfigPool], (_, config) => config),
|
||||
(prev, startConfig: SimpleConfiguration) =>
|
||||
prev == null ? newSimpleGameState(startConfig) : prev,
|
||||
],
|
||||
[
|
||||
combine([actions], [gameConfigPool], (action, config) => ({
|
||||
action,
|
||||
config,
|
||||
})),
|
||||
(
|
||||
prev,
|
||||
evt: {
|
||||
action: Attributed & SimpleAction;
|
||||
config: SimpleConfiguration;
|
||||
}
|
||||
) =>
|
||||
prev != null
|
||||
? resolveSimpleAction({
|
||||
config: evt.config,
|
||||
state: prev,
|
||||
action: evt.action,
|
||||
humanKey: evt.action.humanKey,
|
||||
})
|
||||
: prev,
|
||||
],
|
||||
[quits, () => null]
|
||||
).toProperty();
|
||||
gameState
|
||||
.map((state) => JSON.stringify(state).substring(0, 10))
|
||||
.log("gameState");
|
||||
|
||||
const gameIsActive = gameState
|
||||
.map((gs) => gs != null)
|
||||
.skipDuplicates()
|
||||
.toProperty()
|
||||
.log("gameIsActive");
|
||||
|
||||
gameConfigPool.plug(
|
||||
playersPresent
|
||||
.filterBy(gameIsActive.map((active) => !active))
|
||||
.map((players) => ({
|
||||
game: "simple",
|
||||
players,
|
||||
}))
|
||||
);
|
||||
|
||||
tables[key] = {
|
||||
inputs,
|
||||
outputs: {
|
||||
playersPresent,
|
||||
playersReady: playersReady.toProperty(),
|
||||
gameConfig: gameConfig as Property<unknown, any>,
|
||||
gameState: gameState as Property<unknown, any>,
|
||||
},
|
||||
};
|
||||
|
||||
// cleanup
|
||||
tables[key].outputs.playersPresent
|
||||
.debounce(30000, { immediate: false })
|
||||
.filter((players) => players.length === 0)
|
||||
.skip(1)
|
||||
.onValue((_) => {
|
||||
console.log("DELETING LIVE TABLE");
|
||||
delete tables[key];
|
||||
});
|
||||
}
|
||||
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
|
||||
};
|
||||
9
pkg/server/tsconfig.json
Normal file
9
pkg/server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user