diff --git a/packages/client/src/app.tsx b/packages/client/src/app.tsx index 09de5f3..a29522c 100644 --- a/packages/client/src/app.tsx +++ b/packages/client/src/app.tsx @@ -52,12 +52,8 @@ const App = () => { > import("./routes/index"))} /> import("./routes/[game]/index"))} - /> - import("./routes/[game]/[instance]"))} + path="/:tableKey" + component={lazy(() => import("./routes/[table]"))} /> ); diff --git a/packages/client/src/components/Game.tsx b/packages/client/src/components/Game.tsx index e02748f..051fbfa 100644 --- a/packages/client/src/components/Game.tsx +++ b/packages/client/src/components/Game.tsx @@ -9,6 +9,7 @@ import { Resource, ResourceReturn, Show, + untrack, } from "solid-js"; import { GameState, @@ -28,35 +29,38 @@ export const GameContext = createContext<{ }>(); const [playerProfiles, setPlayerProfiles] = createStore< - Record< - string, - ReturnType>>[0] - > + Record>> >({}); -export default (props: { instanceId: string }) => { +export default (props: { tableKey: string }) => { const [view, setView] = createSignal(); const [players, setPlayers] = createSignal([]); createEffect(() => { players().forEach((player) => { - if (!playerProfiles[player]) { + if (!untrack(() => playerProfiles[player])) { const [playerProfile] = createResource(() => api.profile .get({ query: { otherHumanKey: player } }) .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()); ws.on("message", (evt) => { if (evt.data.players) { setPlayers(evt.data.players); } + if (evt.data.view) { + setView(evt.data.view); + } }); const submitAction = (action: Action) => api.simple(props).post({ action }); @@ -66,7 +70,11 @@ export default (props: { instanceId: string }) => {
- {(player) =>

{playerProfiles[player]?.()?.name}

} + {(player) => ( +

+ {playerProfiles[player]?.()?.name} +

+ )}
); diff --git a/packages/client/src/components/Table.tsx b/packages/client/src/components/Table.tsx index e69de29..66857b7 100644 --- a/packages/client/src/components/Table.tsx +++ b/packages/client/src/components/Table.tsx @@ -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>> +>({}); + +export const TableContext = createContext<{ + players: Accessor; + view: Accessor; + // submitAction: (action: Action) => Promise; +}>(); + +export default (props: { tableKey: string }) => { + const [players, setPlayers] = createSignal([]); + const [view, setView] = createSignal(); + + 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 ( +
+ + + {(player) => ( +

+ {playerProfiles[player]?.()?.name} +

+ )} +
+
+ ); + }; + return ( + + }> + + + + ); +}; diff --git a/packages/client/src/routes/[table].tsx b/packages/client/src/routes/[table].tsx new file mode 100644 index 0000000..1b8981d --- /dev/null +++ b/packages/client/src/routes/[table].tsx @@ -0,0 +1,16 @@ +import { A, useParams } from "@solidjs/router"; + +import Table from "../components/Table"; + +export default () => { + const { tableKey } = useParams(); + + return ( + <> + + + Back + + + ); +}; diff --git a/packages/server/package.json b/packages/server/package.json index abbd070..a0356b8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "@types/bun": "latest", "@types/kefir": "^3.8.11", "concurrently": "^9.2.0", - "prisma": "6.13.0" + "prisma": "6.13.0", + "ts-xor": "^1.3.0" } } diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index f752284..d7b1d81 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,8 +1,9 @@ import { Elysia, t } from "elysia"; -import { simpleApi } from "./games/simple"; +import { Action, GameState, getKnowledge, getView } from "./games/simple"; import { human } from "./human"; import dayjs from "dayjs"; import db from "./db"; +import { liveTable } from "./table"; const api = new Elysia({ prefix: "/api" }) .post("/whoami", async ({ cookie: { token } }) => { @@ -48,26 +49,46 @@ const api = new Elysia({ prefix: "/api" }) }) ) .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 }) => - db.instance.findMany({ - where: { - game: { - name: game, - }, - players: { - some: { - key: humanKey, - }, - }, + async open({ + data: { + params: { tableKey }, + humanKey, }, - select: { - id: true, - }, - }) - ) + send, + }) { + const table = liveTable(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 type Api = typeof api; diff --git a/packages/server/src/games/simple.ts b/packages/server/src/games/simple.ts index e4883fc..c33bf64 100644 --- a/packages/server/src/games/simple.ts +++ b/packages/server/src/games/simple.ts @@ -4,8 +4,11 @@ import { Elysia, t } from "elysia"; import db from "../db"; import { human } from "../human"; 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 { Prisma, Instance } from "@prisma/client"; +import type { XOR } from "ts-xor"; +import { liveTable } from "../table"; // omniscient game state 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, deckCount: state.deck.length, myHand: state.players[humanId] as Hand, @@ -74,14 +77,6 @@ export const resolveAction = ( humanId: string, action: Action ): 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]; if (action.type == "draw") { 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(), - }), - } - ) - ); diff --git a/packages/server/src/table.ts b/packages/server/src/table.ts new file mode 100644 index 0000000..1d3e6c4 --- /dev/null +++ b/packages/server/src/table.ts @@ -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 = { humanKey: string } & XOR< + { presence: "joined" | "left" }, + { proposeGame: string }, + { startGame: true }, + { action: GameAction } +>; + +type InMemoryTable = { + playersPresent: string[]; + gameState: GameState | null; +}; + +type TableOutputEvents = { + playersPresent: Property; + gameState: Property; +}; + +type TablePayload = { + input: TBus, never>; + outputs: TableOutputEvents; +}; + +const tables: { + [key: string]: TablePayload; +} = {}; + +export const liveTable = (key: string) => { + if (!(key in tables)) { + const inputEvents = Bus, 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; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3411674..6673d3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: prisma: specifier: 6.13.0 version: 6.13.0(typescript@5.9.2) + ts-xor: + specifier: ^1.3.0 + version: 1.3.0 packages/shared: dependencies: @@ -1162,6 +1165,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-xor@1.3.0: + resolution: {integrity: sha512-RLXVjliCzc1gfKQFLRpfeD0rrWmjnSTgj7+RFhoq3KRkUYa8LE/TIidYOzM5h+IdFBDSjjSgk9Lto9sdMfDFEA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2364,6 +2370,8 @@ snapshots: tree-kill@1.2.2: {} + ts-xor@1.3.0: {} + tslib@2.8.1: {} type-fest@4.41.0: {}