From a117f6703fd68691ee749da071a00532085e5687 Mon Sep 17 00:00:00 2001 From: Daniel McCrystal Date: Sun, 24 Aug 2025 22:04:29 -0400 Subject: [PATCH] lots of necessary plumbing --- packages/client/src/components/Table.tsx | 26 ++--- packages/server/src/api.ts | 48 +++++--- packages/server/src/games/simple.ts | 143 +++++++++++------------ packages/server/src/index.ts | 5 +- packages/server/src/kefir-extension.ts | 2 +- packages/server/src/table.ts | 134 ++++++++++++++------- 6 files changed, 201 insertions(+), 157 deletions(-) diff --git a/packages/client/src/components/Table.tsx b/packages/client/src/components/Table.tsx index 2782a04..eefa1e2 100644 --- a/packages/client/src/components/Table.tsx +++ b/packages/client/src/components/Table.tsx @@ -1,22 +1,10 @@ -import { - Accessor, - createContext, - createEffect, - createSignal, - For, - onCleanup, - Show, -} from "solid-js"; -import { SimplePlayerView } from "../../../server/src/games/simple"; -import api, { fromWebsocket } from "../api"; -import { me, playerColor, profile } from "../profile"; -import { fromEvents, Stream, stream } from "kefir"; -import Bus from "kefir-bus"; -import { createObservable, createObservableWithInit, cx, WSEvent } from "../fn"; -import { EdenWS } from "@elysiajs/eden/treaty"; +import { Accessor, createContext, For, onCleanup, Show } from "solid-js"; import { TWsIn, TWsOut } from "../../../server/src/table"; -import Player from "./Player"; +import api, { fromWebsocket } from "../api"; +import { createObservable, createObservableWithInit, cx } from "../fn"; +import { me } from "../profile"; import Game from "./Game"; +import Player from "./Player"; export const TableContext = createContext<{ players: Accessor; @@ -95,10 +83,10 @@ export default (props: { tableKey: string }) => {
diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 1454fe2..6fea2cb 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,9 +1,9 @@ import { Elysia, t } from "elysia"; import { + getSimplePlayerView, SimpleAction, + SimpleConfiguration, SimpleGameState, - getKnowledge, - getView, } from "./games/simple"; import { human } from "./human"; import dayjs from "dayjs"; @@ -11,6 +11,7 @@ import db from "./db"; import { liveTable, WsOut, WsIn } from "./table"; import { Human } from "@prisma/client"; import _ from "lodash"; +import { combine } from "kefir"; const api = new Elysia({ prefix: "/api" }) .post("/whoami", async ({ cookie: { token } }) => { @@ -73,18 +74,29 @@ const api = new Elysia({ prefix: "/api" }) }, send, }) { - const table = liveTable(tableKey); + const table = liveTable< + SimpleConfiguration, + SimpleGameState, + SimpleAction + >(tableKey); - table.outputs.playersPresent - .skipDuplicates((p1, p2) => _.isEqual(new Set(p1), new Set(p2))) - .onValue((players) => send({ players })); - table.outputs.gameState.onValue((gameState) => - send({ - view: - gameState && - getView(getKnowledge(gameState, humanKey), humanKey), - }) + table.outputs.playersPresent.onValue((players) => + send({ players }) ); + + table.outputs.playersReady.onValue((readys) => + send({ playersReady: readys }) + ); + + combine( + [table.outputs.gameState], + [table.outputs.gameConfig], + (state, config) => + state && + config && + getSimplePlayerView(config, state, humanKey) + ).onValue((view) => send({ view })); + table.inputs.connectionChanges.emit({ humanKey, presence: "joined", @@ -104,15 +116,15 @@ const api = new Elysia({ prefix: "/api" }) body ) { const { - inputs: { gameProposals, gameStarts, gameActions }, + inputs: { readys, actions, quits }, } = liveTable(tableKey); - if ("proposeGame" in body) { - gameProposals.emit(body); - } else if ("startGame" in body) { - gameStarts.emit(body); + if ("ready" in body) { + readys.emit({ humanKey, ...body }); } else if ("action" in body) { - gameActions.emit({ humanKey, ...body.action }); + actions.emit({ humanKey, ...body.action }); + } else if ("quit" in body) { + quits.emit({ humanKey }); } }, diff --git a/packages/server/src/games/simple.ts b/packages/server/src/games/simple.ts index 9320e61..050d47d 100644 --- a/packages/server/src/games/simple.ts +++ b/packages/server/src/games/simple.ts @@ -1,104 +1,103 @@ import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards"; import { heq } from "@games/shared/utils"; -// omniscient game state -export type SimpleGameState = { - prev?: { - action: SimpleAction; - }; - - deck: Pile; - players: { [humanId: string]: Hand }; +export type SimpleConfiguration = { + game: "simple"; + players: string[]; }; -// a particular player's knowledge of the global game state -export type vSimpleGameState = { - humanId: string; - - deck: Pile; - players: { [humanId: string]: Hand }; +// omniscient game state +export type SimpleGameState = { + deck: Pile; + turnIdx: number; + playerHands: { [humanKey: string]: Hand }; }; // a particular player's point of view in the game export type SimplePlayerView = { - humanId: string; - deckCount: number; - playerHandCounts: { [humanId: string]: number }; + playerTurn: string; + playerHandCounts: { [humanKey: string]: number }; myHand: Hand; }; export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card }; -export const newGame = (players: string[]) => { - console.log("new game called with", JSON.stringify(players)); +export const newSimpleGameState = ( + config: SimpleConfiguration +): SimpleGameState => { + const { players } = config; return { deck: shuffle(newDeck()), - players: Object.fromEntries(players.map((humanId) => [humanId, []])), - } as SimpleGameState; + turnIdx: 0, + playerHands: Object.fromEntries( + players.map((humanKey) => [humanKey, []]) + ), + }; }; -export const getKnowledge = ( +export const getSimplePlayerView = ( + config: SimpleConfiguration, state: SimpleGameState, - humanId: string -): vSimpleGameState => ({ - humanId, - deck: state.deck.map((_) => null), - players: Object.fromEntries( - Object.entries(state.players).map(([id, hand]) => [ - id, - hand.map(id === humanId ? (card) => card : (_) => null), - ]) - ), -}); - -export const getView = ( - state: vSimpleGameState, - humanId: string + humanKey: string ): SimplePlayerView => ({ - humanId, deckCount: state.deck.length, - myHand: state.players[humanId] as Hand, + playerTurn: config.players[state.turnIdx], + myHand: state.playerHands[humanKey] as Hand, playerHandCounts: Object.fromEntries( - Object.entries(state.players) - .filter(([id]) => id != humanId) + Object.entries(state.playerHands) + .filter(([id]) => id != humanKey) .map(([id, hand]) => [id, hand.length]) ), }); -export const resolveAction = ( - state: SimpleGameState, - humanId: string, - action: SimpleAction -): SimpleGameState => { - console.log("attempting to resolve action", JSON.stringify(action)); - if (!(humanId in state.players)) { - throw Error( - `${humanId} is not a player in this game; they cannot perform actions` +export const resolveSimpleAction = ({ + config, + state, + humanKey, + action, +}: { + config: SimpleConfiguration; + state: SimpleGameState; + humanKey: string; + action: SimpleAction; +}): SimpleGameState => { + const playerHand = state.playerHands[humanKey]; + if (playerHand == null) { + throw new Error( + `${humanKey} is not a player in this game; they cannot perform actions` ); } - const playerHand = state.players[humanId]; - if (action.type == "draw") { - const [drawn, ...rest] = state.deck; - console.log("drew card", JSON.stringify(drawn)); - return { - deck: rest, - players: { - ...state.players, - [humanId]: [drawn, ...playerHand], - }, - }; + if (humanKey != config.players[state.turnIdx]) { + throw new Error(`It's not ${humanKey}'s turn!`); } - // action.type == discard - const index = playerHand.findIndex(heq(action.card)); - return { - deck: [action.card, ...state.deck], - players: { - ...state.players, - [humanId]: playerHand - .slice(0, index) - .concat(playerHand.slice(index + 1)), - }, - }; + const numPlayers = Object.keys(state.playerHands).length; + const newTurnIdx = (state.turnIdx + 1) % numPlayers; + + if (action.type == "draw") { + const [drawn, ...rest] = state.deck; + + return { + deck: rest, + playerHands: { + ...state.playerHands, + [humanKey]: [drawn, ...playerHand], + }, + turnIdx: newTurnIdx, + }; + } else { + // action.type == discard + const cardIndex = playerHand.findIndex(heq(action.card)); + return { + deck: [action.card, ...state.deck], + playerHands: { + ...state.playerHands, + [humanKey]: playerHand + .slice(0, cardIndex) + .concat(playerHand.slice(cardIndex + 1)), + }, + turnIdx: newTurnIdx, + }; + } }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4e9d7d5..9a9d5a3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,8 +1,7 @@ -import api from "./api"; -import { Elysia, env } from "elysia"; import { cors } from "@elysiajs/cors"; import { staticPlugin } from "@elysiajs/static"; -import { error } from "node:console"; +import { Elysia, env } from "elysia"; +import api from "./api"; const port = env.PORT || 5001; diff --git a/packages/server/src/kefir-extension.ts b/packages/server/src/kefir-extension.ts index b33cfb0..8194be5 100644 --- a/packages/server/src/kefir-extension.ts +++ b/packages/server/src/kefir-extension.ts @@ -6,7 +6,7 @@ export const transform = < >( initValue: T, ...mutations: Mutations -): Observable => +): Observable => merge( mutations.map(([source, mutation]) => source.map((event) => ({ event, mutation })) diff --git a/packages/server/src/table.ts b/packages/server/src/table.ts index d6b1f58..38e097d 100644 --- a/packages/server/src/table.ts +++ b/packages/server/src/table.ts @@ -2,91 +2,135 @@ import { t } from "elysia"; import { combine, Property } from "kefir"; import Bus, { type Bus as TBus } from "kefir-bus"; import { - newGame, - resolveAction, + newSimpleGameState, + resolveSimpleAction, SimpleAction, + SimpleConfiguration, SimpleGameState, } from "./games/simple"; import { transform } from "./kefir-extension"; export const WsOut = t.Object({ players: t.Optional(t.Array(t.String())), + playersReady: t.Optional(t.Record(t.String(), t.String())), view: t.Optional(t.Any()), }); export type TWsOut = typeof WsOut.static; export const WsIn = t.Union([ - t.Object({ proposeGame: t.String() }), - t.Object({ startGame: t.Literal(true) }), + t.Object({ ready: t.Boolean() }), t.Object({ action: t.Any() }), + t.Object({ quit: t.Literal(true) }), ]); export type TWsIn = typeof WsIn.static; type Attributed = { humanKey: string }; -type TablePayload = { +type TablePayload = { inputs: { connectionChanges: TBus< Attributed & { presence: "joined" | "left" }, never >; - gameProposals: TBus<{ proposeGame: string }, never>; - gameStarts: TBus<{ startGame: true }, never>; - gameActions: TBus; + + readys: TBus; + actions: TBus; + quits: TBus; }; outputs: { playersPresent: Property; + playersReady: Property<{ [key: string]: boolean }, unknown>; + gameConfig: Property; gameState: Property; }; }; const tables: { - [key: string]: TablePayload; + [key: string]: TablePayload; } = {}; -export const liveTable = (key: string) => { +export const liveTable = (key: string) => { if (!(key in tables)) { - const inputs: TablePayload["inputs"] = { + const inputs: TablePayload< + GameConfig, + GameState, + GameAction + >["inputs"] = { connectionChanges: Bus(), - gameProposals: Bus(), - gameStarts: Bus(), - gameActions: Bus(), + readys: Bus(), + actions: Bus(), + quits: Bus(), }; - const { connectionChanges, gameProposals, gameStarts, gameActions } = - inputs; + const { connectionChanges, readys, actions, quits } = inputs; // ======= - const playerConnectionCounts = connectionChanges.scan((prev, evt) => { - if (evt.presence == "left" && prev[evt.humanKey] == 1) { - const { [evt.humanKey]: _, ...rest } = prev; - return rest; - } - return { - ...prev, - [evt.humanKey]: - (prev[evt.humanKey] ?? 0) + - (evt.presence == "joined" ? 1 : -1), - }; - }, {} as { [key: string]: number }); - const playersPresent = playerConnectionCounts.map((counts) => - Object.keys(counts) + + const playersPresent = connectionChanges + .scan((prev, evt) => { + if (evt.presence == "left" && prev[evt.humanKey] == 1) { + const { [evt.humanKey]: _, ...rest } = prev; + return rest; + } + return { + ...prev, + [evt.humanKey]: + (prev[evt.humanKey] ?? 0) + + (evt.presence == "joined" ? 1 : -1), + }; + }, {} as { [key: string]: number }) + .map((counts) => Object.keys(counts)); + + const playersReady = transform( + {} as { [key: string]: boolean }, + [ + playersPresent, + (prev, players: string[]) => + Object.fromEntries( + players.map((p) => [p, prev[p] ?? false]) + ), + ], + [ + readys, + (prev, evt: { humanKey: string; ready: boolean }) => + prev[evt.humanKey] != null + ? { ...prev, [evt.humanKey]: evt.ready } + : prev, + ] ); + const gameStarts = playersReady + .filter((pr) => Object.values(pr).every((ready) => ready)) + .map((_) => null); + + const gameConfig = playersPresent.map((players) => ({ + game: "simple", + players, + })); + const gameState = transform( null as SimpleGameState | null, [ - combine([gameStarts], [playersPresent], (evt, players) => ({ - ...evt, - players, - })), - (prev, evt: { players: string[] }) => - prev == null - ? (newGame(evt.players) as SimpleGameState) - : prev, + combine([gameStarts], [gameConfig], (_, config) => config), + (prev, startConfig: SimpleConfiguration) => + prev == null ? newSimpleGameState(startConfig) : prev, ], [ - gameActions, - (prev, evt: Attributed & SimpleAction) => + combine([actions], [gameConfig], (action, config) => ({ + action, + config, + })), + ( + prev, + evt: { + action: Attributed & SimpleAction; + config: SimpleConfiguration; + } + ) => prev != null - ? resolveAction(prev, evt.humanKey, evt) + ? resolveSimpleAction({ + config: evt.config, + state: prev, + action: evt.action, + humanKey: evt.action.humanKey, + }) : prev, ] ).toProperty(); @@ -95,17 +139,19 @@ export const liveTable = (key: string) => { inputs, outputs: { playersPresent, + playersReady, + gameConfig: gameConfig as Property, gameState: gameState as Property, }, }; // cleanup tables[key].outputs.playersPresent - .slidingWindow(2) - .filter(([prev, curr]) => prev.length > 0 && curr.length == 0) + .debounce(30000) + .filter((players) => players.length === 0) .onValue((_) => { delete tables[key]; }); } - return tables[key] as TablePayload; + return tables[key] as TablePayload; };