diff --git a/package.json b/package.json index e419b82..2ae47b1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,14 @@ "pnpm": { "overrides": { "object-hash": "^3.0.0" - } + }, + "onlyBuiltDependencies": [ + "@parcel/watcher", + "@prisma/client", + "@prisma/engines", + "esbuild", + "prisma" + ] }, "devDependencies": { "@types/object-hash": "^3.0.6" diff --git a/packages/client/src/components/Game.tsx b/packages/client/src/components/Game.tsx index 051fbfa..5503345 100644 --- a/packages/client/src/components/Game.tsx +++ b/packages/client/src/components/Game.tsx @@ -1,95 +1,25 @@ +import { Accessor, createContext } from "solid-js"; import { - Accessor, - createContext, - createEffect, - createResource, - createSignal, - For, - onCleanup, - Resource, - ResourceReturn, - Show, - untrack, -} from "solid-js"; -import { - GameState, - Action, - vGameState, - PlayerView, + SimpleAction, + SimplePlayerView, } 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"; export const GameContext = createContext<{ - view: Accessor; - submitAction: (action: Action) => Promise; + view: Accessor; + submitAction: (action: SimpleAction) => Promise; }>(); -const [playerProfiles, setPlayerProfiles] = createStore< - Record>> ->({}); - -export default (props: { tableKey: string }) => { - const [view, setView] = createSignal(); - const [players, setPlayers] = createSignal([]); - - 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 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 }); - - const Lobby = () => { - return ( -
- - - {(player) => ( -

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

- )} -
-
- ); - }; +export default () => { return ( - - }> - + Game started! + {/* submitAction({ type: "draw" })} /> - - - + */} + ); }; diff --git a/packages/client/src/components/Table.tsx b/packages/client/src/components/Table.tsx index daf69cd..117a385 100644 --- a/packages/client/src/components/Table.tsx +++ b/packages/client/src/components/Table.tsx @@ -7,21 +7,13 @@ import { For, onCleanup, Resource, - ResourceReturn, Show, untrack, } from "solid-js"; -import { - GameState, - Action, - vGameState, - PlayerView, -} from "../../../server/src/games/simple"; -import api, { me } from "../api"; -import Hand from "./Hand"; -import Pile from "./Pile"; -import { ApiType } from "../fn"; import { createStore } from "solid-js/store"; +import { SimplePlayerView } from "../../../server/src/games/simple"; +import api, { me } from "../api"; +import { ApiType } from "../fn"; import Game from "./Game"; const [playerProfiles, setPlayerProfiles] = createStore< @@ -30,15 +22,15 @@ const [playerProfiles, setPlayerProfiles] = createStore< export const TableContext = createContext<{ players: Accessor; - view: Accessor; + view: Accessor; // submitAction: (action: Action) => Promise; }>(); export default (props: { tableKey: string }) => { const [players, setPlayers] = createSignal([]); - const [view, setView] = createSignal(); + const [view, setView] = createSignal(); - const ws = api(props).subscribe(); + const ws = api.ws(props).subscribe(); onCleanup(() => ws.close()); ws.on("message", (evt) => { @@ -81,8 +73,8 @@ export default (props: { tableKey: string }) => {
@@ -103,7 +95,12 @@ export default (props: { tableKey: string }) => { >
- +
diff --git a/packages/client/src/routes/[game]/index.tsx b/packages/client/src/routes/[game]/index.tsx index 30dc219..ba283be 100644 --- a/packages/client/src/routes/[game]/index.tsx +++ b/packages/client/src/routes/[game]/index.tsx @@ -15,12 +15,7 @@ export default () => {

{param.game}

- +
    {(instance) => ( diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index bbb78a8..92c078c 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,9 +1,14 @@ import { Elysia, t } from "elysia"; -import { Action, GameState, getKnowledge, getView } from "./games/simple"; +import { + SimpleAction, + SimpleGameState, + getKnowledge, + getView, +} from "./games/simple"; import { human } from "./human"; import dayjs from "dayjs"; import db from "./db"; -import { liveTable } from "./table"; +import { liveTable, WebsocketIncomingMessage } from "./table"; const api = new Elysia({ prefix: "/api" }) .post("/whoami", async ({ cookie: { token } }) => { @@ -56,11 +61,34 @@ const api = new Elysia({ prefix: "/api" }) }) ) .get("/games", () => [{ key: "simple", name: "simple" }]) - .ws("/:tableKey", { + .ws("/ws/:tableKey", { response: t.Object({ players: t.Optional(t.Array(t.String())), view: t.Optional(t.Any()), }), + body: WebsocketIncomingMessage, + + message( + { + data: { + humanKey, + params: { tableKey }, + }, + }, + body + ) { + const { + inputs: { gameProposals, gameStarts, gameActions }, + } = liveTable(tableKey); + + if ("proposeGame" in body) { + gameProposals.emit(body); + } else if ("startGame" in body) { + gameStarts.emit(body); + } else if ("action" in body) { + gameActions.emit(body); + } + }, async open({ data: { @@ -69,7 +97,7 @@ const api = new Elysia({ prefix: "/api" }) }, send, }) { - const table = liveTable(tableKey); + const table = liveTable(tableKey); table.outputs.playersPresent.onValue((players) => send({ players }) @@ -81,8 +109,7 @@ const api = new Elysia({ prefix: "/api" }) getView(getKnowledge(gameState, humanKey), humanKey), }) ); - - table.input.emit({ humanKey, presence: "joined" }); + table.inputs.presenceChanges.emit({ humanKey, presence: "joined" }); }, async close({ data: { @@ -90,7 +117,7 @@ const api = new Elysia({ prefix: "/api" }) humanKey, }, }) { - liveTable(tableKey).input.emit({ + liveTable(tableKey).inputs.presenceChanges.emit({ humanKey, presence: "left", }); diff --git a/packages/server/src/games/simple.ts b/packages/server/src/games/simple.ts index c33bf64..17a1c08 100644 --- a/packages/server/src/games/simple.ts +++ b/packages/server/src/games/simple.ts @@ -1,19 +1,10 @@ import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards"; import { heq } from "@games/shared/utils"; -import { Elysia, t } from "elysia"; -import db from "../db"; -import { human } from "../human"; -import { ElysiaWS } from "elysia/dist/ws"; -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 = { +export type SimpleGameState = { prev?: { - action: Action; + action: SimpleAction; }; deck: Pile; @@ -21,7 +12,7 @@ export type GameState = { }; // a particular player's knowledge of the global game state -export type vGameState = { +export type vSimpleGameState = { humanId: string; deck: Pile; @@ -29,7 +20,7 @@ export type vGameState = { }; // a particular player's point of view in the game -export type PlayerView = { +export type SimplePlayerView = { humanId: string; deckCount: number; @@ -37,20 +28,20 @@ export type PlayerView = { myHand: Hand; }; -export type Action = { type: "draw" } | { type: "discard"; card: Card }; +export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card }; export const newGame = (players: string[]) => { console.log("new game called with", JSON.stringify(players)); return { deck: shuffle(newDeck()), players: Object.fromEntries(players.map((humanId) => [humanId, []])), - } as GameState; + } as SimpleGameState; }; export const getKnowledge = ( - state: GameState, + state: SimpleGameState, humanId: string -): vGameState => ({ +): vSimpleGameState => ({ humanId, deck: state.deck.map((_) => null), players: Object.fromEntries( @@ -61,7 +52,10 @@ export const getKnowledge = ( ), }); -export const getView = (state: vGameState, humanId: string): PlayerView => ({ +export const getView = ( + state: vSimpleGameState, + humanId: string +): SimplePlayerView => ({ humanId, deckCount: state.deck.length, myHand: state.players[humanId] as Hand, @@ -73,10 +67,10 @@ export const getView = (state: vGameState, humanId: string): PlayerView => ({ }); export const resolveAction = ( - state: GameState, + state: SimpleGameState, humanId: string, - action: Action -): GameState => { + action: SimpleAction +): SimpleGameState => { const playerHand = state.players[humanId]; if (action.type == "draw") { const [drawn, ...rest] = state.deck; diff --git a/packages/server/src/kefir-extension.ts b/packages/server/src/kefir-extension.ts new file mode 100644 index 0000000..b33cfb0 --- /dev/null +++ b/packages/server/src/kefir-extension.ts @@ -0,0 +1,29 @@ +import { merge, Observable } from "kefir"; + +export const transform = < + T, + Mutations extends [Observable, (prev: T, evt: any) => T][] +>( + initValue: T, + ...mutations: Mutations +): Observable => + merge( + mutations.map(([source, mutation]) => + source.map((event) => ({ event, mutation })) + ) + ).scan((prev, { event, mutation }) => mutation(prev, event), initValue); + +export const partition = + ( + classes: C, + partitionFn: (v: T) => C[number] + ) => + (obs: Observable) => { + const assigned = obs.map((obj) => ({ obj, cls: partitionFn(obj) })); + return Object.fromEntries( + classes.map((C) => [ + C, + assigned.filter(({ cls }) => cls == C).map(({ obj }) => obj), + ]) + ); + }; diff --git a/packages/server/src/table.ts b/packages/server/src/table.ts index 1d3e6c4..07b2c7f 100644 --- a/packages/server/src/table.ts +++ b/packages/server/src/table.ts @@ -1,28 +1,34 @@ -import { XOR } from "ts-xor"; +import { t } from "elysia"; +import { combine, Property } from "kefir"; import Bus, { type Bus as TBus } from "kefir-bus"; -import { merge, Observable, Property, Stream } from "kefir"; -import { Game } from "@prisma/client"; +import { + newGame, + resolveAction, + SimpleAction, + SimpleGameState, +} from "./games/simple"; +import { transform } from "./kefir-extension"; -type TableInputEvent = { humanKey: string } & XOR< - { presence: "joined" | "left" }, - { proposeGame: string }, - { startGame: true }, - { action: GameAction } ->; +export const WebsocketIncomingMessage = t.Union([ + t.Object({ proposeGame: t.String() }), + t.Object({ startGame: t.Literal(true) }), + t.Object({ action: t.Any() }), +]); -type InMemoryTable = { - playersPresent: string[]; - gameState: GameState | null; -}; - -type TableOutputEvents = { - playersPresent: Property; - gameState: Property; -}; - -type TablePayload = { - input: TBus, never>; - outputs: TableOutputEvents; +type TablePayload = { + inputs: { + presenceChanges: TBus< + { humanKey: string; presence: "joined" | "left" }, + never + >; + gameProposals: TBus<{ proposeGame: string }, never>; + gameStarts: TBus<{ startGame: true }, never>; + gameActions: TBus; + }; + outputs: { + playersPresent: Property; + gameState: Property; + }; }; const tables: { @@ -31,26 +37,55 @@ const tables: { export const liveTable = (key: string) => { if (!(key in tables)) { - const inputEvents = Bus, never>(); + const inputs: TablePayload["inputs"] = { + presenceChanges: Bus(), + gameProposals: Bus(), + gameStarts: Bus(), + gameActions: Bus(), + }; + const { presenceChanges, gameProposals, gameStarts, gameActions } = + inputs; + + // ======= + const playersPresent = presenceChanges.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[]); + + const gameState = transform( + null as GameState | null, + [ + gameStarts.thru((Evt) => + combine([Evt], [playersPresent], (evt, players) => ({ + ...evt, + players, + })) + ), + (prev, evt: { players: string[] }) => + prev == null ? (newGame(evt.players) as GameState) : prev, + ], + [ + gameActions, + (prev, evt: GameAction) => + prev != null + ? (resolveAction( + prev as unknown as SimpleGameState, + "evt", + evt as SimpleAction + ) as GameState) + : prev, + ] + ).toProperty(); tables[key] = { - input: inputEvents, + inputs, 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), + playersPresent, + gameState: gameState as Property, }, }; @@ -62,5 +97,5 @@ export const liveTable = (key: string) => { delete tables[key]; }); } - return tables[key] as TablePayload; + return tables[key] as TablePayload; };