[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,
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";
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" })
.post("/whoami", async ({ cookie: { token } }) => {
@@ -35,7 +45,14 @@ const api = new Elysia({ prefix: "/api" })
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(
"/setName",
({ body: { name }, humanKey }) =>
@@ -64,7 +81,6 @@ const api = new Elysia({ prefix: "/api" })
return safeProfile;
})
)
.get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/ws/:tableKey", {
async open({
data: {
@@ -73,11 +89,7 @@ const api = new Elysia({ prefix: "/api" })
},
send,
}) {
const table = liveTable<
SimpleConfiguration,
SimpleGameState,
SimpleAction
>(tableKey);
const table = liveTable(tableKey);
table.inputs.connectionChanges.emit({
humanKey,
@@ -87,27 +99,24 @@ const api = new Elysia({ prefix: "/api" })
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)
[table.outputs.gameImpl],
(state, { game: Game }) =>
state && game.getView({ config, state, humanKey })
)
.toProperty()
.onValue((view) => send({ view }));
},
response: WsOut,
body: WsIn,
response: WsOut,
message(
message: (
{
data: {
humanKey,
@@ -115,21 +124,9 @@ const api = new Elysia({ prefix: "/api" })
},
},
body
) {
const {
inputs: { readys, actions, quits },
} = liveTable(tableKey);
) => WS.emit({ ...body, type: "message", humanKey, 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({
close({
data: {
params: { tableKey },
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 { staticPlugin } from "@elysiajs/static";
import { Elysia, env } from "elysia";
import api from "./api";
import staticFiles from "./static";
import * as log from "./logging";
const port = env.PORT || 5001;
const app = new Elysia()
new Elysia()
.use(
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);
// })
.onError(({ error }) => {
console.error(error);
return error;
})
.onRequest(({ request }) => log.log(request))
.onError(({ error }) => log.err(error))
.get("/ping", () => "pong")
.use(api)
.get("/*", () => Bun.file("./public/index.html"))
.use(
staticPlugin({
assets: "public",
prefix: "/",
alwaysStatic: true,
})
)
.use(staticFiles)
.listen(port);
console.log("server started on", port);
log.log(`server started on ${port}`);

View File

@@ -1,29 +0,0 @@
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),
])
);
};

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