[wip] kefir cleanup

This commit is contained in:
2025-08-29 23:50:23 -04:00
parent 90be478e9a
commit 5e33e33cce
10 changed files with 2310 additions and 308 deletions

View File

@@ -5,12 +5,22 @@ import {
SimpleConfiguration, SimpleConfiguration,
SimpleGameState, SimpleGameState,
} from "@games/shared/games/simple"; } from "@games/shared/games/simple";
import { human } from "./human";
import dayjs from "dayjs"; import dayjs from "dayjs";
import db from "./db"; import db from "./db";
import { liveTable, WsOut, WsIn } from "./table"; import { liveTable, WsOut, WsIn } from "./table";
import { Human } from "@prisma/client"; import { Human } from "@prisma/client";
import { combine } from "kefir"; import { combine } from "kefir";
import Bus from "kefir-bus";
import { Game } from "@games/shared/games";
export const WS = Bus<
{
type: "open" | "message" | "error" | "close";
humanKey: string;
tableKey: string;
},
unknown
>();
const api = new Elysia({ prefix: "/api" }) const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => { .post("/whoami", async ({ cookie: { token } }) => {
@@ -35,7 +45,14 @@ const api = new Elysia({ prefix: "/api" })
return human.key; return human.key;
}) })
.use(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);
})
.post( .post(
"/setName", "/setName",
({ body: { name }, humanKey }) => ({ body: { name }, humanKey }) =>
@@ -64,7 +81,6 @@ const api = new Elysia({ prefix: "/api" })
return safeProfile; return safeProfile;
}) })
) )
.get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/ws/:tableKey", { .ws("/ws/:tableKey", {
async open({ async open({
data: { data: {
@@ -73,11 +89,7 @@ const api = new Elysia({ prefix: "/api" })
}, },
send, send,
}) { }) {
const table = liveTable< const table = liveTable(tableKey);
SimpleConfiguration,
SimpleGameState,
SimpleAction
>(tableKey);
table.inputs.connectionChanges.emit({ table.inputs.connectionChanges.emit({
humanKey, humanKey,
@@ -87,27 +99,24 @@ const api = new Elysia({ prefix: "/api" })
table.outputs.playersPresent.onValue((players) => table.outputs.playersPresent.onValue((players) =>
send({ players }) send({ players })
); );
table.outputs.playersReady table.outputs.playersReady
.skipDuplicates() .skipDuplicates()
.onValue((readys) => send({ playersReady: readys })); .onValue((readys) => send({ playersReady: readys }));
combine( combine(
[table.outputs.gameState], [table.outputs.gameState],
[table.outputs.gameConfig], [table.outputs.gameImpl],
(state, config) => (state, { game: Game }) =>
state && state && game.getView({ config, state, humanKey })
config &&
getSimplePlayerView(config, state, humanKey)
) )
.toProperty() .toProperty()
.onValue((view) => send({ view })); .onValue((view) => send({ view }));
}, },
response: WsOut,
body: WsIn, body: WsIn,
response: WsOut,
message( message: (
{ {
data: { data: {
humanKey, humanKey,
@@ -115,21 +124,9 @@ const api = new Elysia({ prefix: "/api" })
}, },
}, },
body body
) { ) => WS.emit({ ...body, type: "message", humanKey, tableKey }),
const {
inputs: { readys, actions, quits },
} = liveTable(tableKey);
if ("ready" in body) { close({
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: { data: {
params: { tableKey }, params: { tableKey },
humanKey, humanKey,

View File

@@ -1,13 +0,0 @@
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");

View File

@@ -1,33 +1,28 @@
import { cors } from "@elysiajs/cors"; import { cors } from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
import { Elysia, env } from "elysia"; import { Elysia, env } from "elysia";
import api from "./api"; import api from "./api";
import staticFiles from "./static";
import * as log from "./logging";
const port = env.PORT || 5001; const port = env.PORT || 5001;
const app = new Elysia() new Elysia()
.use( .use(
cors({ cors({
origin: ["http://localhost:3000", "https://games.drm.dev"], origin: [
"http://localhost:3000", // dev
"https://games.drm.dev", // prod
],
}) })
) )
// .onRequest(({ request }) => {
// console.log(request.method, request.url); .onRequest(({ request }) => log.log(request))
// }) .onError(({ error }) => log.err(error))
.onError(({ error }) => {
console.error(error);
return error;
})
.get("/ping", () => "pong") .get("/ping", () => "pong")
.use(api) .use(api)
.get("/*", () => Bun.file("./public/index.html")) .use(staticFiles)
.use(
staticPlugin({
assets: "public",
prefix: "/",
alwaysStatic: true,
})
)
.listen(port); .listen(port);
console.log("server started on", port); log.log(`server started on ${port}`);

View File

@@ -0,0 +1,13 @@
import { combine, pool } from "kefir";
import Bus from "kefir-bus";
export const LogPool = pool();
const LogBus = Bus();
const LogStream = combine([LogPool, LogBus]);
export const log = (value: unknown) => LogBus.emit(value);
export const err = (value: unknown) =>
LogBus.emitEvent({ type: "error", value });
LogPool.log();

12
pkg/server/src/static.ts Normal file
View File

@@ -0,0 +1,12 @@
import staticPlugin from "@elysiajs/static";
import Elysia from "elysia";
export default new Elysia()
.get("/*", () => Bun.file("./public/index.html"))
.use(
staticPlugin({
assets: "public",
prefix: "/",
alwaysStatic: true,
})
);

View File

@@ -1,14 +1,8 @@
import { t } from "elysia"; import { t } from "elysia";
import { combine, pool, Property } from "kefir"; import { combine, pool, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus"; import Bus, { type Bus as TBus } from "kefir-bus";
import { import { transform } from "@games/shared/kefir";
newSimpleGameState, import GAMES, { Game, GameKey } from "@games/shared/games";
resolveSimpleAction,
SimpleAction,
SimpleConfiguration,
SimpleGameState,
} from "@games/shared/games/simple";
import { transform } from "./kefir-extension";
export const WsOut = t.Object({ export const WsOut = t.Object({
players: t.Optional(t.Array(t.String())), players: t.Optional(t.Array(t.String())),
@@ -39,6 +33,7 @@ type TablePayload<GameConfig, GameState, GameAction> = {
playersPresent: Property<string[], any>; playersPresent: Property<string[], any>;
playersReady: Property<{ [key: string]: boolean } | null, any>; playersReady: Property<{ [key: string]: boolean } | null, any>;
gameConfig: Property<GameConfig | null, any>; gameConfig: Property<GameConfig | null, any>;
gameImpl: Property<Game, unknown>;
gameState: Property<GameState | null, any>; gameState: Property<GameState | null, any>;
}; };
}; };
@@ -47,7 +42,13 @@ const tables: {
[key: string]: TablePayload<unknown, unknown, unknown>; [key: string]: TablePayload<unknown, unknown, unknown>;
} = {}; } = {};
export const liveTable = <GameConfig, GameState, GameAction>(key: string) => { export const liveTable = <
GameConfig extends { game: string },
GameState,
GameAction
>(
key: string
) => {
if (!(key in tables)) { if (!(key in tables)) {
const inputs: TablePayload< const inputs: TablePayload<
GameConfig, GameConfig,
@@ -124,58 +125,63 @@ export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
game: string; game: string;
players: string[]; players: string[];
}, },
never any
>(); >();
const gameConfig = gameConfigPool.toProperty(); const gameConfig = gameConfigPool.toProperty();
const gameImpl = gameConfig
.filter((cfg) => cfg.game in GAMES)
.map((config) => ({ config, game: GAMES[config.game as GameKey] }))
.toProperty();
const gameState = transform( const gameState = transform(
null as SimpleGameState | null, null as GameState | null,
[ [
combine([gameStarts], [gameConfigPool], (_, config) => config), // initialize game state when started
(prev, startConfig: SimpleConfiguration) => gameImpl.sampledBy(gameStarts),
prev == null ? newSimpleGameState(startConfig) : prev, (prev, { config, game }) =>
prev == null ? game.init(config) : prev,
], ],
[ [
combine([actions], [gameConfigPool], (action, config) => ({ combine([actions], [gameImpl], (action, impl) => ({
action, action,
config, ...impl,
})), })),
( (
prev, prev,
evt: { evt: {
action: Attributed & SimpleAction; action: Attributed & GameAction;
config: SimpleConfiguration; config: GameConfig;
game: Game;
} }
) => ) =>
prev != null prev != null
? resolveSimpleAction({ ? (evt.game.resolveAction({
config: evt.config, config: evt.config,
state: prev, state: prev,
action: evt.action, action: evt.action,
humanKey: evt.action.humanKey, }) as GameState)
})
: prev, : prev,
], ],
[quits, () => null] [quits, () => null]
).toProperty(); ).toProperty();
gameState
.map((state) => JSON.stringify(state).substring(0, 10))
.log("gameState");
const gameIsActive = gameState const gameIsActive = gameState
.map((gs) => gs != null) .map((gs) => gs != null)
.skipDuplicates() .skipDuplicates()
.toProperty() .toProperty();
.log("gameIsActive");
gameConfigPool.plug( gameConfigPool.plug(
playersPresent transform(
.filterBy(gameIsActive.map((active) => !active)) { game: "simple", players: [] as string[] },
.map((players) => ({ [
game: "simple", playersPresent.filterBy(
players, gameIsActive.map((active) => !active)
})) ),
(prev, players) => ({ ...prev, players }),
]
// TODO: Add player defined config changes
)
); );
tables[key] = { tables[key] = {
@@ -185,6 +191,7 @@ export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
playersReady: playersReady.toProperty(), playersReady: playersReady.toProperty(),
gameConfig: gameConfig as Property<unknown, any>, gameConfig: gameConfig as Property<unknown, any>,
gameState: gameState as Property<unknown, any>, gameState: gameState as Property<unknown, any>,
gameImpl,
}, },
}; };

View File

@@ -2,11 +2,11 @@ import * as renaissance from "./renaissance";
import simple from "./simple"; import simple from "./simple";
export type Game< export type Game<
C extends { game: string }, C extends { game: string } = { game: string },
S, S = unknown,
A, A extends { humanKey: string } = { humanKey: string },
E extends { error: any }, E extends { error: any } = { error: any },
V V = unknown
> = { > = {
title: string; title: string;
rules: string; rules: string;
@@ -16,10 +16,10 @@ export type Game<
resolveQuit: (p: { config: C; state: S; humanKey: string }) => S; resolveQuit: (p: { config: C; state: S; humanKey: string }) => S;
}; };
const games = { export const GAMES = {
// renaissance, // renaissance,
simple, simple,
} satisfies { [key: string]: Game<any, any, any, any, any> }; } satisfies { [key: string]: Game<any, any, any, any, any> };
export default games; export default GAMES;
export type GameId = keyof typeof games; export type GameKey = keyof typeof GAMES;

View File

@@ -2,6 +2,10 @@
"name": "@games/shared", "name": "@games/shared",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"kefir": "^3.8.8",
"object-hash": "^3.0.0" "object-hash": "^3.0.0"
},
"devDependencies": {
"@types/kefir": "^3.8.11"
} }
} }

2397
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff