diff --git a/pkg/server/src/api.ts b/pkg/server/src/api.ts index 69e1117..968e7e1 100644 --- a/pkg/server/src/api.ts +++ b/pkg/server/src/api.ts @@ -1,17 +1,11 @@ -import { Elysia, t } from "elysia"; -import { - getSimplePlayerView, - SimpleAction, - SimpleConfiguration, - SimpleGameState, -} from "@games/shared/games/simple"; -import dayjs from "dayjs"; -import db from "./db"; -import { liveTable, WsOut, WsIn } from "./table"; +import { Game } from "@games/shared/games"; import { Human } from "@prisma/client"; +import dayjs from "dayjs"; +import { Elysia, t } from "elysia"; import { combine } from "kefir"; import Bus from "kefir-bus"; -import { Game } from "@games/shared/games"; +import db from "./db"; +import { liveTable, WsIn, WsOut } from "./table"; export const WS = Bus< { @@ -96,18 +90,14 @@ const api = new Elysia({ prefix: "/api" }) presence: "joined", }); - table.outputs.playersPresent.onValue((players) => - send({ players }) + Object.entries(table.outputs.global).forEach(([type, stream]) => + stream.onValue((v) => send({ [type]: v })) ); - table.outputs.playersReady - .skipDuplicates() - .onValue((readys) => send({ playersReady: readys })); combine( [table.outputs.gameState], [table.outputs.gameImpl], - (state, { game: Game }) => - state && game.getView({ config, state, humanKey }) + (state, game: Game) => state && game.getView({ state, humanKey }) ) .toProperty() .onValue((view) => send({ view })); diff --git a/pkg/server/src/table.ts b/pkg/server/src/table.ts index e0826b6..c01a162 100644 --- a/pkg/server/src/table.ts +++ b/pkg/server/src/table.ts @@ -1,8 +1,9 @@ +import GAMES, { Game, GameKey } from "@games/shared/games"; +import { isEmpty, multiScan, ValueWithin } from "@games/shared/kefir"; import { t } from "elysia"; import { combine, pool, Property } from "kefir"; import Bus, { type Bus as TBus } from "kefir-bus"; -import { transform } from "@games/shared/kefir"; -import GAMES, { Game, GameKey } from "@games/shared/games"; +import { log } from "./logging"; export const WsOut = t.Object({ players: t.Optional(t.Array(t.String())), @@ -18,52 +19,74 @@ export const WsIn = t.Union([ export type TWsIn = typeof WsIn.static; type Attributed = { humanKey: string }; -type TablePayload = { +type TablePayload< + GameConfig = unknown, + GameView = unknown, + GameAction = unknown +> = { inputs: { connectionChanges: TBus< - Attributed & { presence: "joined" | "left" }, + Attributed & { + presence: "joined" | "left"; + }, never >; - readys: TBus; + readys: TBus< + Attributed & { + ready: boolean; + }, + any + >; actions: TBus; quits: TBus; }; outputs: { - playersPresent: Property; - playersReady: Property<{ [key: string]: boolean } | null, any>; - gameConfig: Property; - gameImpl: Property; - gameState: Property; + global: { + playersPresent: Property; + playersReady: Property< + { + [key: string]: boolean; + } | null, + any + >; + gameConfig: Property; + }; + player: { + [key: string]: { + view: Property; + }; + }; }; }; const tables: { - [key: string]: TablePayload; + [key: string]: TablePayload; } = {}; export const liveTable = < - GameConfig extends { game: string }, + GameConfig extends { + game: GameKey; + players: string[]; + }, GameState, - GameAction + GameAction extends Attributed >( key: string ) => { if (!(key in tables)) { - const inputs: TablePayload< - GameConfig, - GameState, - GameAction - >["inputs"] = { + const inputs: TablePayload["inputs"] = { connectionChanges: Bus(), readys: Bus(), actions: Bus(), quits: Bus(), }; const { connectionChanges, readys, actions, quits } = inputs; - quits.log("quits"); - // ======= + // ======= + const playerStreams = {}; + + // players who have at least one connection to the room const playersPresent = connectionChanges .scan((prev, evt) => { if (evt.presence == "left" && prev[evt.humanKey] == 1) { @@ -73,8 +96,7 @@ export const liveTable = < return { ...prev, [evt.humanKey]: - (prev[evt.humanKey] ?? 0) + - (evt.presence == "joined" ? 1 : -1), + (prev[evt.humanKey] ?? 0) + (evt.presence == "joined" ? 1 : -1), }; }, {} as { [key: string]: number }) .map((counts) => Object.keys(counts)) @@ -82,47 +104,47 @@ export const liveTable = < const gameEnds = quits.map((_) => null); - const gameStarts = pool(); - const playersReady = transform( - null as { [key: string]: boolean } | null, + const playersReady = multiScan( + null as { + [key: string]: boolean; + } | null, [ playersPresent, - (prev, players: string[]) => - Object.fromEntries( - players.map((p) => [p, prev?.[p] ?? false]) - ), + (prev, players: ValueWithin) => + Object.fromEntries(players.map((p) => [p, prev?.[p] ?? false])), ], [ readys, - (prev, evt: { humanKey: string; ready: boolean }) => + (prev, evt: ValueWithin) => prev?.[evt.humanKey] != null - ? { ...prev, [evt.humanKey]: evt.ready } + ? { + ...prev, + [evt.humanKey]: evt.ready, + } : prev, ], - [gameStarts, () => null], + [gameEnds, () => null], [ - combine([gameEnds], [playersPresent], (_, players) => players), - (_, players: string[]) => + playersPresent.sampledBy(gameEnds), + (_, players: ValueWithin) => Object.fromEntries(players.map((p) => [p, false])), ] ) .toProperty() .log("playersReady"); - gameStarts.plug( - playersReady - .filter( - (pr) => - Object.values(pr ?? {}).length > 0 && - Object.values(pr!).every((ready) => ready) - ) - .map((_) => null) - .log("gameStarts") - ); + const gameStarts = playersReady + .filter( + (pr) => + Object.values(pr ?? {}).length > 0 && + Object.values(pr!).every((ready) => ready) + ) + .map((_) => null) + .log("gameStarts"); const gameConfigPool = pool< { - game: string; + game: GameKey; players: string[]; }, any @@ -131,16 +153,16 @@ export const liveTable = < const gameImpl = gameConfig .filter((cfg) => cfg.game in GAMES) - .map((config) => ({ config, game: GAMES[config.game as GameKey] })) + .map((config) => GAMES[config.game as GameKey](config)) .toProperty(); - const gameState = transform( + const gameState = multiScan( null as GameState | null, [ // initialize game state when started gameImpl.sampledBy(gameStarts), - (prev, { config, game }) => - prev == null ? game.init(config) : prev, + (prev, game: ValueWithin) => + prev || (game.init() as GameState), ], [ combine([actions], [gameImpl], (action, impl) => ({ @@ -149,19 +171,19 @@ export const liveTable = < })), ( prev, - evt: { - action: Attributed & GameAction; - config: GameConfig; + { + game, + action, + }: { game: Game; + action: Attributed & GameAction; } ) => - prev != null - ? (evt.game.resolveAction({ - config: evt.config, - state: prev, - action: evt.action, - }) as GameState) - : prev, + prev && + (game.resolveAction({ + state: prev, + action, + }) as GameState), ], [quits, () => null] ).toProperty(); @@ -172,13 +194,17 @@ export const liveTable = < .toProperty(); gameConfigPool.plug( - transform( - { game: "simple", players: [] as string[] }, + multiScan( + { + game: "simple", + players: [] as string[], + }, [ - playersPresent.filterBy( - gameIsActive.map((active) => !active) - ), - (prev, players) => ({ ...prev, players }), + playersPresent.filterBy(gameIsActive.map((active) => !active)), + (prev, players) => ({ + ...prev, + players, + }), ] // TODO: Add player defined config changes ) @@ -187,23 +213,25 @@ export const liveTable = < tables[key] = { inputs, outputs: { - playersPresent, - playersReady: playersReady.toProperty(), - gameConfig: gameConfig as Property, - gameState: gameState as Property, - gameImpl, + global: { + playersPresent, + playersReady: playersReady.toProperty(), + gameConfig: gameConfig as Property, + }, + player: {}, }, }; - // cleanup - tables[key].outputs.playersPresent - .debounce(30000, { immediate: false }) - .filter((players) => players.length === 0) - .skip(1) - .onValue((_) => { - console.log("DELETING LIVE TABLE"); + // cleanup: delete the room if no one is in it for 30 seconds + tables[key].outputs.global.playersPresent + .skip(1) // don't consider the empty room upon creation + .debounce(30000) + .filter(isEmpty) + .onValue(() => { + log("DELETING LIVE TABLE"); delete tables[key]; }); } + return tables[key] as TablePayload; }; diff --git a/pkg/shared/games/index.ts b/pkg/shared/games/index.ts index 5f7f3ca..5da0d32 100644 --- a/pkg/shared/games/index.ts +++ b/pkg/shared/games/index.ts @@ -1,8 +1,6 @@ -import * as renaissance from "./renaissance"; import simple from "./simple"; export type Game< - C extends { game: string } = { game: string }, S = unknown, A extends { humanKey: string } = { humanKey: string }, E extends { error: any } = { error: any }, @@ -10,16 +8,18 @@ export type Game< > = { title: string; rules: string; - init: (config: C) => S; - resolveAction: (p: { config: C; state: S; action: A }) => S | E; - getView: (p: { config: C; state: S; humanKey: string }) => V; - resolveQuit: (p: { config: C; state: S; humanKey: string }) => S; + init: () => S; + resolveAction: (p: { state: S; action: A }) => S | E; + getView: (p: { state: S; humanKey: string }) => V; + resolveQuit: (p: { state: S; humanKey: string }) => S; }; export const GAMES = { // renaissance, simple, -} satisfies { [key: string]: Game }; +} satisfies { + [key: string]: (config: { game: string; players: string[] }) => Game; +}; export default GAMES; export type GameKey = keyof typeof GAMES; diff --git a/pkg/shared/games/simple.ts b/pkg/shared/games/simple.ts index cfaf3f8..7d80c93 100644 --- a/pkg/shared/games/simple.ts +++ b/pkg/shared/games/simple.ts @@ -22,7 +22,10 @@ export type SimplePlayerView = { myHand: Hand; }; -export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card }; +export type SimpleAction = { humanKey: string } & ( + | { type: "draw" } + | { type: "discard"; card: Card } +); export const newSimpleGameState = ( config: SimpleConfiguration @@ -55,14 +58,13 @@ export const getSimplePlayerView = ( export const resolveSimpleAction = ({ config, state, - humanKey, action, }: { config: SimpleConfiguration; state: SimpleGameState; - humanKey: string; action: SimpleAction; }): SimpleGameState => { + const { humanKey } = action; const playerHand = state.playerHands[humanKey]; if (playerHand == null) { throw new Error( @@ -105,18 +107,18 @@ export const resolveSimpleAction = ({ type SimpleError = { error: "whoops!" }; -export default { - title: "Simple", - rules: "You can draw, or you can discard. Then your turn is up.", - init: newSimpleGameState, - resolveAction: resolveSimpleAction, - getView: ({ config, state, humanKey }) => - getSimplePlayerView(config, state, humanKey), - resolveQuit: () => null, -} satisfies Game< - SimpleConfiguration, - SimpleGameState, - SimpleAction, - SimpleError, - SimplePlayerView ->; +export default (config: SimpleConfiguration) => + ({ + title: "Simple", + rules: "You can draw, or you can discard. Then your turn is up.", + init: () => newSimpleGameState(config), + resolveAction: (p) => resolveSimpleAction({ ...p, config }), + getView: ({ state, humanKey }) => + getSimplePlayerView(config, state, humanKey), + resolveQuit: () => null, + } satisfies Game< + SimpleGameState, + SimpleAction, + SimpleError, + SimplePlayerView + >); diff --git a/pkg/shared/kefir.ts b/pkg/shared/kefir.ts index 8194be5..5fc9234 100644 --- a/pkg/shared/kefir.ts +++ b/pkg/shared/kefir.ts @@ -1,12 +1,18 @@ import { merge, Observable } from "kefir"; -export const transform = < - T, - Mutations extends [Observable, (prev: T, evt: any) => T][] ->( - initValue: T, - ...mutations: Mutations -): Observable => +export type ValueWithin> = Parameters< + Parameters[0] +>[0]; + +type Mutation> = [ + O, + (prev: A, value: ValueWithin) => A +]; + +export const multiScan = []>( + initValue: A, + ...mutations: M +): Observable => merge( mutations.map(([source, mutation]) => source.map((event) => ({ event, mutation })) @@ -27,3 +33,5 @@ export const partition = ]) ); }; + +export const isEmpty = (container: { length: number }) => container.length == 0;