cooking with websockets

This commit is contained in:
2025-08-18 23:31:28 -04:00
parent 3f1635880a
commit 287c19fc0d
12 changed files with 205 additions and 81 deletions

View File

@@ -1,10 +0,0 @@
node_modules
Dockerfile
Makefile
README.md
.output
.vinxi
.git
.gitignore
.dockerignore
*.db

View File

@@ -1,11 +0,0 @@
FROM node:22-alpine
WORKDIR /app
EXPOSE 3000
COPY package.json ./
RUN --mount=type=cache,target=/root/.npm npm install
COPY . .
RUN --mount=type=cache,target=/app/.vinxi npm run build
CMD ["npm", "run", "start"]

View File

@@ -34,30 +34,33 @@ const Profile = () => {
); );
}; };
const App = () => ( const App = () => {
<Router api.whoami.post();
root={(props) => ( return (
<> <Router
<Suspense>{props.children}</Suspense> root={(props) => (
<Profile /> <>
<Suspense>{props.children}</Suspense>
<Profile />
{/* Version */} {/* Version */}
<span class="fixed br m-2 font-mono text-xs"> <span class="fixed br m-2 font-mono text-xs">
{"v" + pkg.version} {"v" + pkg.version}
</span> </span>
</> </>
)} )}
> >
<Route path="/" component={lazy(() => import("./routes/index"))} /> <Route path="/" component={lazy(() => import("./routes/index"))} />
<Route <Route
path="/:game" path="/:game"
component={lazy(() => import("./routes/[game]/index"))} component={lazy(() => import("./routes/[game]/index"))}
/> />
<Route <Route
path="/:game/:instance" path="/:game/:instance"
component={lazy(() => import("./routes/[game]/[instance]"))} component={lazy(() => import("./routes/[game]/[instance]"))}
/> />
</Router> </Router>
); );
};
api.whoami.post().then(() => render(App, document.getElementById("app")!)); render(App, document.getElementById("app")!);

View File

@@ -1,4 +1,12 @@
import { Accessor, createContext, createResource, Show } from "solid-js"; import {
Accessor,
createContext,
createResource,
createSignal,
For,
onCleanup,
Show,
} from "solid-js";
import { import {
GameState, GameState,
Action, Action,
@@ -15,28 +23,36 @@ export const GameContext = createContext<{
}>(); }>();
export default (props: { instanceId: string }) => { export default (props: { instanceId: string }) => {
const [view, { mutate }] = createResource(() => const [view, setView] = createSignal<PlayerView>();
api const [players, setPlayers] = createSignal<string[]>([]);
.simple(props)
.get()
.then((res) => res.data as PlayerView)
);
const submitAction = (action: Action) =>
api
.simple(props)
.post({ action })
.then((res) => res.status == 200 && mutate(res.data as PlayerView));
const ws = api.simple(props).subscribe();
onCleanup(() => ws.close());
ws.on("message", (evt) => {
if (evt.data.players) {
setPlayers(evt.data.players);
}
});
const submitAction = (action: Action) => api.simple(props).post({ action });
const Lobby = () => {
return (
<div class="fixed center">
<For each={players()}>{(player) => <p>{player}</p>}</For>
</div>
);
};
return ( return (
<GameContext.Provider value={{ view, submitAction }}> <GameContext.Provider value={{ view, submitAction }}>
<Show when={view.latest != undefined}> <Show when={view() != undefined} fallback={<Lobby />}>
<Pile <Pile
count={view.latest!.deckCount} count={view()!.deckCount}
class="cursor-pointer fixed center" class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })} onClick={() => submitAction({ type: "draw" })}
/> />
<Hand class="fixed bc" hand={view.latest!.myHand} /> <Hand class="fixed bc" hand={view()!.myHand} />
</Show> </Show>
</GameContext.Provider> </GameContext.Provider>
); );

View File

@@ -16,7 +16,7 @@ export default () => {
<div style={{ padding: "20px" }}> <div style={{ padding: "20px" }}>
<p class="text-[40px]">{param.game}</p> <p class="text-[40px]">{param.game}</p>
<button <button
class="px-2 py-1.5 m-4 button rounded" class="px-2 py-1.5 m-4 button"
onClick={() => api.simple.newGame.post().then(refetch)} onClick={() => api.simple.newGame.post().then(refetch)}
> >
New Game New Game

View File

@@ -29,7 +29,6 @@ a:visited {
background-color: white; background-color: white;
color: black; color: black;
box-shadow: 0px 5px 10px black; box-shadow: 0px 5px 10px black;
border-radius: 10%;
transition: background-color 0.15s, color 0.15s, transform 0.15s; transition: background-color 0.15s, color 0.15s, transform 0.15s;
} }
.button:hover { .button:hover {

View File

@@ -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;

View File

@@ -32,6 +32,6 @@ model Instance {
players Human[] players Human[]
game Game @relation(fields: [gameKey], references: [key]) game Game @relation(fields: [gameKey], references: [key])
gameState Json gameState Json?
} }

View File

@@ -20,10 +20,13 @@
"elysia": "^1.3.8", "elysia": "^1.3.8",
"elysia-ip": "^1.0.10", "elysia-ip": "^1.0.10",
"elysia-rate-limit": "^4.4.0", "elysia-rate-limit": "^4.4.0",
"kefir": "^3.8.8",
"kefir-bus": "^2.3.1",
"object-hash": "^3.0.0" "object-hash": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/kefir": "^3.8.11",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"prisma": "6.13.0" "prisma": "6.13.0"
} }

View File

@@ -3,6 +3,9 @@ import { heq } from "@games/shared/utils";
import { Elysia, t } from "elysia"; 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 K, { Stream } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
// omniscient game state // omniscient game state
export type GameState = { export type GameState = {
@@ -104,13 +107,18 @@ export const resolveAction = (
}; };
}; };
const instanceEvents: {
[instanceId: string]: TBus<
{ view?: PlayerView; players?: string[] },
never
>;
} = {};
export const simpleApi = new Elysia({ prefix: "/simple" }) export const simpleApi = new Elysia({ prefix: "/simple" })
.use(human) .use(human)
.post("/newGame", ({ humanKey }) => { .post("/newGame", ({ humanKey }) => {
console.log("KEY", humanKey);
return db.instance.create({ return db.instance.create({
data: { data: {
gameState: newGame([humanKey]),
gameKey: "simple", gameKey: "simple",
createdByKey: humanKey, createdByKey: humanKey,
players: { players: {
@@ -119,25 +127,96 @@ export const simpleApi = new Elysia({ prefix: "/simple" })
}, },
}); });
}) })
.group("/:instanceId", (app) => .group("/:instanceId", (app) =>
app app
.get("/", ({ params: { instanceId }, humanKey }) => .ws("/", {
db.instance async open(ws) {
.findUnique({ 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: { where: {
id: instanceId, id: instanceId,
}, },
}) select: {
.then((game) => createdByKey: true,
getView( gameState: true,
getKnowledge( players: {
game!.gameState as GameState, 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 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( .post(
"/", "/",
({ params: { instanceId }, body: { action }, humanKey }) => ({ params: { instanceId }, body: { action }, humanKey }) =>

View File

@@ -9,7 +9,7 @@ const port = env.PORT || 5001;
const app = new Elysia() const app = new Elysia()
.use( .use(
cors({ cors({
origin: ["localhost:3000", "games.drm.dev"], origin: ["http://localhost:3000", "https://games.drm.dev"],
}) })
) )
.onRequest(({ request }) => { .onRequest(({ request }) => {

30
pnpm-lock.yaml generated
View File

@@ -84,6 +84,12 @@ importers:
elysia-rate-limit: elysia-rate-limit:
specifier: ^4.4.0 specifier: ^4.4.0
version: 4.4.0(elysia@1.3.11(exact-mirror@0.1.5(@sinclair/typebox@0.34.38))(file-type@21.0.0)(typescript@5.9.2)) version: 4.4.0(elysia@1.3.11(exact-mirror@0.1.5(@sinclair/typebox@0.34.38))(file-type@21.0.0)(typescript@5.9.2))
kefir:
specifier: ^3.8.8
version: 3.8.8
kefir-bus:
specifier: ^2.3.1
version: 2.3.1(kefir@3.8.8)
object-hash: object-hash:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@@ -91,6 +97,9 @@ importers:
'@types/bun': '@types/bun':
specifier: latest specifier: latest
version: 1.2.20(@types/react@19.1.9) version: 1.2.20(@types/react@19.1.9)
'@types/kefir':
specifier: ^3.8.11
version: 3.8.11
concurrently: concurrently:
specifier: ^9.2.0 specifier: ^9.2.0
version: 9.2.0 version: 9.2.0
@@ -450,6 +459,9 @@ packages:
'@types/js-cookie@3.0.6': '@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/kefir@3.8.11':
resolution: {integrity: sha512-5TRdFXQYsVUvqIH6nYjslHzBgn4hnptcutXnqAhfbKdWD/799c44hFhQGF3887E2t/Q4jSp3RvNFCaQ+b9w6vQ==}
'@types/node@24.2.0': '@types/node@24.2.0':
resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==} resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==}
@@ -884,6 +896,14 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
kefir-bus@2.3.1:
resolution: {integrity: sha512-wLCQfEw8PddSNeyjDCH2WNgNg3Rb/c+OaG5WEPfEwod+LQfGX4isHcHRWsYNLmdFEw3/KyA+9qDSy+VC4NsifA==}
peerDependencies:
kefir: ^3.5.1
kefir@3.8.8:
resolution: {integrity: sha512-xWga7QCZsR2Wjy2vNL3Kq/irT+IwxwItEWycRRlT5yhqHZK2fmEhziP+LzcJBWSTAMranGKtGTQ6lFpyJS3+jA==}
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
@@ -1601,6 +1621,10 @@ snapshots:
'@types/js-cookie@3.0.6': {} '@types/js-cookie@3.0.6': {}
'@types/kefir@3.8.11':
dependencies:
'@types/node': 24.2.0
'@types/node@24.2.0': '@types/node@24.2.0':
dependencies: dependencies:
undici-types: 7.10.0 undici-types: 7.10.0
@@ -2076,6 +2100,12 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
kefir-bus@2.3.1(kefir@3.8.8):
dependencies:
kefir: 3.8.8
kefir@3.8.8: {}
kolorist@1.8.0: {} kolorist@1.8.0: {}
local-pkg@1.1.1: local-pkg@1.1.1: