cooking with kefir

This commit is contained in:
2025-08-20 21:56:23 -04:00
parent 265aad4522
commit 35a5af154f
9 changed files with 245 additions and 188 deletions

View File

@@ -52,12 +52,8 @@ const App = () => {
> >
<Route path="/" component={lazy(() => import("./routes/index"))} /> <Route path="/" component={lazy(() => import("./routes/index"))} />
<Route <Route
path="/:game" path="/:tableKey"
component={lazy(() => import("./routes/[game]/index"))} component={lazy(() => import("./routes/[table]"))}
/>
<Route
path="/:game/:instance"
component={lazy(() => import("./routes/[game]/[instance]"))}
/> />
</Router> </Router>
); );

View File

@@ -9,6 +9,7 @@ import {
Resource, Resource,
ResourceReturn, ResourceReturn,
Show, Show,
untrack,
} from "solid-js"; } from "solid-js";
import { import {
GameState, GameState,
@@ -28,35 +29,38 @@ export const GameContext = createContext<{
}>(); }>();
const [playerProfiles, setPlayerProfiles] = createStore< const [playerProfiles, setPlayerProfiles] = createStore<
Record< Record<string, Resource<ApiType<typeof api.profile.get>>>
string,
ReturnType<typeof createResource<ApiType<typeof api.profile.get>>>[0]
>
>({}); >({});
export default (props: { instanceId: string }) => { export default (props: { tableKey: string }) => {
const [view, setView] = createSignal<PlayerView>(); const [view, setView] = createSignal<PlayerView>();
const [players, setPlayers] = createSignal<string[]>([]); const [players, setPlayers] = createSignal<string[]>([]);
createEffect(() => { createEffect(() => {
players().forEach((player) => { players().forEach((player) => {
if (!playerProfiles[player]) { if (!untrack(() => playerProfiles[player])) {
const [playerProfile] = createResource(() => const [playerProfile] = createResource(() =>
api.profile api.profile
.get({ query: { otherHumanKey: player } }) .get({ query: { otherHumanKey: player } })
.then((r) => r.data) .then((r) => r.data)
); );
setPlayerProfiles(player, playerProfile); setPlayerProfiles((prev) => ({
...prev,
[player]: playerProfile,
}));
} }
}); });
}); });
const ws = api.simple(props).subscribe(); const ws = api(props).subscribe;
onCleanup(() => ws.close()); onCleanup(() => ws.close());
ws.on("message", (evt) => { ws.on("message", (evt) => {
if (evt.data.players) { if (evt.data.players) {
setPlayers(evt.data.players); setPlayers(evt.data.players);
} }
if (evt.data.view) {
setView(evt.data.view);
}
}); });
const submitAction = (action: Action) => api.simple(props).post({ action }); const submitAction = (action: Action) => api.simple(props).post({ action });
@@ -66,7 +70,11 @@ export default (props: { instanceId: string }) => {
<div class="fixed tc mt-20 flex flex-col items-center"> <div class="fixed tc mt-20 flex flex-col items-center">
<button class="button p-1 m-10">Start Game!</button> <button class="button p-1 m-10">Start Game!</button>
<For each={players()}> <For each={players()}>
{(player) => <p>{playerProfiles[player]?.()?.name}</p>} {(player) => (
<p style={{ "font-size": "2em" }}>
{playerProfiles[player]?.()?.name}
</p>
)}
</For> </For>
</div> </div>
); );

View File

@@ -0,0 +1,90 @@
import {
Accessor,
createContext,
createEffect,
createResource,
createSignal,
For,
onCleanup,
Resource,
ResourceReturn,
Show,
untrack,
} from "solid-js";
import {
GameState,
Action,
vGameState,
PlayerView,
} from "../../../server/src/games/simple";
import api from "../api";
import Hand from "./Hand";
import Pile from "./Pile";
import { ApiType } from "../fn";
import { createStore } from "solid-js/store";
import Game from "./Game";
const [playerProfiles, setPlayerProfiles] = createStore<
Record<string, Resource<ApiType<typeof api.profile.get>>>
>({});
export const TableContext = createContext<{
players: Accessor<string[]>;
view: Accessor<PlayerView | undefined>;
// submitAction: (action: Action) => Promise<any>;
}>();
export default (props: { tableKey: string }) => {
const [players, setPlayers] = createSignal<string[]>([]);
const [view, setView] = createSignal<PlayerView>();
const ws = api(props).subscribe();
onCleanup(() => ws.close());
ws.on("message", (evt) => {
if (evt.data.players) {
setPlayers(evt.data.players);
}
if (evt.data.view) {
setView(evt.data.view);
}
});
createEffect(() => {
players().forEach((player) => {
if (!untrack(() => playerProfiles[player])) {
const [playerProfile] = createResource(() =>
api.profile
.get({ query: { otherHumanKey: player } })
.then((r) => r.data)
);
setPlayerProfiles((prev) => ({
...prev,
[player]: playerProfile,
}));
}
});
});
const Lobby = () => {
return (
<div class="fixed tc mt-20 flex flex-col items-center">
<button class="button p-1 m-10">Start Game!</button>
<For each={players()}>
{(player) => (
<p style={{ "font-size": "2em" }}>
{playerProfiles[player]?.()?.name}
</p>
)}
</For>
</div>
);
};
return (
<TableContext.Provider value={{ view, players }}>
<Show when={view() != null} fallback={<Lobby />}>
<Game tableKey={props.tableKey} />
</Show>
</TableContext.Provider>
);
};

View File

@@ -0,0 +1,16 @@
import { A, useParams } from "@solidjs/router";
import Table from "../components/Table";
export default () => {
const { tableKey } = useParams();
return (
<>
<Table tableKey={tableKey} />
<A href={"/"} class="fixed tl m-4 px-2 py-1.5 button">
Back
</A>
</>
);
};

View File

@@ -28,6 +28,7 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/kefir": "^3.8.11", "@types/kefir": "^3.8.11",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"prisma": "6.13.0" "prisma": "6.13.0",
"ts-xor": "^1.3.0"
} }
} }

View File

@@ -1,8 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { simpleApi } from "./games/simple"; import { Action, GameState, getKnowledge, getView } from "./games/simple";
import { human } from "./human"; import { human } from "./human";
import dayjs from "dayjs"; import dayjs from "dayjs";
import db from "./db"; import db from "./db";
import { liveTable } from "./table";
const api = new Elysia({ prefix: "/api" }) const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => { .post("/whoami", async ({ cookie: { token } }) => {
@@ -48,26 +49,46 @@ const api = new Elysia({ prefix: "/api" })
}) })
) )
.get("/games", () => [{ key: "simple", name: "simple" }]) .get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/:tableKey", {
response: t.Object({
players: t.Optional(t.Array(t.String())),
view: t.Optional(t.Any()),
}),
.get("/instances", ({ query: { game }, humanKey }) => async open({
db.instance.findMany({ data: {
where: { params: { tableKey },
game: { humanKey,
name: game,
},
players: {
some: {
key: humanKey,
},
},
}, },
select: { send,
id: true, }) {
}, const table = liveTable<GameState, Action>(tableKey);
})
)
.use(simpleApi); table.outputs.playersPresent.onValue((players) =>
send({ players })
);
table.outputs.gameState.onValue((gameState) =>
send({
view:
gameState &&
getView(getKnowledge(gameState, humanKey), humanKey),
})
);
table.input.emit({ humanKey, presence: "joined" });
},
async close({
data: {
params: { tableKey },
humanKey,
},
}) {
liveTable(tableKey).input.emit({
humanKey,
presence: "left",
});
},
});
export default api; export default api;
export type Api = typeof api; export type Api = typeof api;

View File

@@ -4,8 +4,11 @@ import { Elysia, t } from "elysia";
import db from "../db"; import db from "../db";
import { human } from "../human"; import { human } from "../human";
import { ElysiaWS } from "elysia/dist/ws"; import { ElysiaWS } from "elysia/dist/ws";
import K, { Stream } from "kefir"; import K, { Property, Stream } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus"; import Bus, { type Bus as TBus } from "kefir-bus";
import { Prisma, Instance } from "@prisma/client";
import type { XOR } from "ts-xor";
import { liveTable } from "../table";
// omniscient game state // omniscient game state
export type GameState = { export type GameState = {
@@ -58,7 +61,7 @@ export const getKnowledge = (
), ),
}); });
const getView = (state: vGameState, humanId: string): PlayerView => ({ export const getView = (state: vGameState, humanId: string): PlayerView => ({
humanId, humanId,
deckCount: state.deck.length, deckCount: state.deck.length,
myHand: state.players[humanId] as Hand, myHand: state.players[humanId] as Hand,
@@ -74,14 +77,6 @@ export const resolveAction = (
humanId: string, humanId: string,
action: Action action: Action
): GameState => { ): GameState => {
// if (action.prevHash != hash(state)) {
// throw new Error(
// `action thinks it's applying to ${
// action.prevHash
// }, but we're checking it against ${hash(state)}`
// );
// }
const playerHand = state.players[humanId]; const playerHand = state.players[humanId];
if (action.type == "draw") { if (action.type == "draw") {
const [drawn, ...rest] = state.deck; const [drawn, ...rest] = state.deck;
@@ -106,147 +101,3 @@ export const resolveAction = (
}, },
}; };
}; };
const instanceEvents: {
[instanceId: string]: TBus<
{ view?: PlayerView; players?: string[] },
never
>;
} = {};
export const simpleApi = new Elysia({ prefix: "/simple" })
.use(human)
.post("/newGame", ({ humanKey }) => {
return db.instance.create({
data: {
gameKey: "simple",
createdByKey: humanKey,
players: {
connect: [{ key: humanKey }],
},
},
});
})
.group("/:instanceId", (app) =>
app
.ws("/", {
async open(ws) {
console.log("Got ws connection");
ws.send("Hello!");
// send initial state
const instanceId = ws.data.params.instanceId;
const humanKey = ws.data.humanKey;
const instance = await db.instance.update({
data: {
players: {
connect: { key: humanKey },
},
},
where: {
id: instanceId,
},
select: {
createdByKey: true,
gameState: true,
players: {
select: {
key: true,
},
},
},
});
if (instance == null) {
ws.close(1011, "no such instance");
return;
}
// register this socket as a listener for events of this instance
if (!instanceEvents[instanceId]) {
instanceEvents[instanceId] = Bus();
}
// @ts-ignore
ws.data.cb = instanceEvents[instanceId].onValue((evt) =>
ws.send(evt)
);
ws.send({ creator: instance.createdByKey });
if (instance.gameState != null) {
ws.send(
getView(
getKnowledge(
instance.gameState as GameState,
humanKey
),
humanKey
)
);
}
instanceEvents[instanceId]?.emit({
players: instance.players.map((p) => p.key),
});
},
close(ws) {
console.log("Got ws close");
const instanceId = ws.data.params.instanceId;
// @ts-ignore
instanceEvents[instanceId]?.offValue(ws.data.cb);
db.instance
.update({
where: {
id: instanceId,
},
data: {
players: {
disconnect: { key: ws.data.humanKey },
},
},
select: {
players: {
select: {
key: true,
},
},
},
})
.then((instance) => {
instanceEvents[instanceId]?.emit({
players: instance.players.map((p) => p.key),
});
});
},
})
.post(
"/",
({ params: { instanceId }, body: { action }, humanKey }) =>
db.instance
.findUniqueOrThrow({
where: {
id: instanceId,
},
})
.then(async (game) => {
const newState = resolveAction(
game.gameState as GameState,
humanKey,
action
);
await db.instance.update({
data: { gameState: newState },
where: {
id: instanceId,
},
});
return getView(
getKnowledge(newState, humanKey),
humanKey
);
}),
{
body: t.Object({
action: t.Any(),
}),
}
)
);

View File

@@ -0,0 +1,66 @@
import { XOR } from "ts-xor";
import Bus, { type Bus as TBus } from "kefir-bus";
import { merge, Observable, Property, Stream } from "kefir";
import { Game } from "@prisma/client";
type TableInputEvent<GameAction> = { humanKey: string } & XOR<
{ presence: "joined" | "left" },
{ proposeGame: string },
{ startGame: true },
{ action: GameAction }
>;
type InMemoryTable<GameState> = {
playersPresent: string[];
gameState: GameState | null;
};
type TableOutputEvents<GameState> = {
playersPresent: Property<string[], never>;
gameState: Property<GameState | null, never>;
};
type TablePayload<GameAction, GameState> = {
input: TBus<TableInputEvent<GameAction>, never>;
outputs: TableOutputEvents<GameState>;
};
const tables: {
[key: string]: TablePayload<unknown, unknown>;
} = {};
export const liveTable = <GameState, GameAction>(key: string) => {
if (!(key in tables)) {
const inputEvents = Bus<TableInputEvent<GameAction>, never>();
tables[key] = {
input: inputEvents,
outputs: {
playersPresent: inputEvents
.filter((evt) => Boolean(evt.presence))
.scan((prev, evt) => {
if (evt.presence == "joined") {
prev.push(evt.humanKey);
} else if (evt.presence == "left") {
prev.splice(prev.indexOf(evt.humanKey), 1);
}
return prev;
}, [] as string[]),
gameState: inputEvents
.filter((evt) => Boolean(evt.startGame || evt.action))
.scan((prev, evt) => {
return prev;
}, null as GameState | null),
},
};
// cleanup
tables[key].outputs.playersPresent
.slidingWindow(2)
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0)
.onValue((_) => {
delete tables[key];
});
}
return tables[key] as TablePayload<GameAction, GameState>;
};

8
pnpm-lock.yaml generated
View File

@@ -106,6 +106,9 @@ importers:
prisma: prisma:
specifier: 6.13.0 specifier: 6.13.0
version: 6.13.0(typescript@5.9.2) version: 6.13.0(typescript@5.9.2)
ts-xor:
specifier: ^1.3.0
version: 1.3.0
packages/shared: packages/shared:
dependencies: dependencies:
@@ -1162,6 +1165,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true hasBin: true
ts-xor@1.3.0:
resolution: {integrity: sha512-RLXVjliCzc1gfKQFLRpfeD0rrWmjnSTgj7+RFhoq3KRkUYa8LE/TIidYOzM5h+IdFBDSjjSgk9Lto9sdMfDFEA==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2364,6 +2370,8 @@ snapshots:
tree-kill@1.2.2: {} tree-kill@1.2.2: {}
ts-xor@1.3.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
type-fest@4.41.0: {} type-fest@4.41.0: {}