cooking with websockets
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
node_modules
|
|
||||||
Dockerfile
|
|
||||||
Makefile
|
|
||||||
README.md
|
|
||||||
.output
|
|
||||||
.vinxi
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.dockerignore
|
|
||||||
*.db
|
|
||||||
11
Dockerfile
11
Dockerfile
@@ -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"]
|
|
||||||
@@ -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")!);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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
30
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user