diff --git a/package.json b/package.json index 8ac3ace..98157f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "games", "type": "module", - "version": "0.0.6", + "version": "0.0.7", "scripts": { "dev": "pnpm --parallel dev", "build": "pnpm run -F client build", diff --git a/pkg/client/src/components/Game.tsx b/pkg/client/src/components/Game.tsx index 2f4c3a3..3216ebc 100644 --- a/pkg/client/src/components/Game.tsx +++ b/pkg/client/src/components/Game.tsx @@ -2,6 +2,7 @@ import { Accessor, createContext, For, useContext } from "solid-js"; import type { SimpleAction, SimplePlayerView, + SimpleResult, } from "@games/shared/games/simple"; import { me, profile } from "~/profile"; import Hand from "./Hand"; diff --git a/pkg/client/src/components/Table.tsx b/pkg/client/src/components/Table.tsx index 7453c72..160c16f 100644 --- a/pkg/client/src/components/Table.tsx +++ b/pkg/client/src/components/Table.tsx @@ -1,5 +1,5 @@ import type { TWsIn, TWsOut } from "@games/server/src/table"; -import { fromPromise, Stream } from "kefir"; +import { fromPromise, merge, Stream } from "kefir"; import { Accessor, createContext, @@ -11,7 +11,7 @@ import { } from "solid-js"; import api, { fromWebsocket } from "~/api"; import { createObservable, createObservableWithInit, cx } from "~/fn"; -import { me, mePromise } from "~/profile"; +import { me, mePromise, profile } from "~/profile"; import Game from "./Game"; import Player from "./Player"; import games from "@games/shared/games/index"; @@ -39,6 +39,7 @@ export default (props: { tableKey: string }) => { const presenceEvents = wsEvents.filter((evt) => evt.playersPresent != null); const gameEvents = wsEvents.filter((evt) => evt.view !== undefined); + const resultEvents = wsEvents.filter((evt) => evt.results !== undefined); const players = createObservableWithInit( presenceEvents.map((evt) => evt.playersPresent!), @@ -57,6 +58,14 @@ export default (props: { tableKey: string }) => { createEffect(() => sendWs({ ready: ready() })); const view = createObservable(gameEvents.map((evt) => evt.view)); + const results = createObservable( + merge([ + gameEvents + .filter((evt) => "view" in evt && evt.view == null) + .map(() => undefined), + resultEvents.map((evt) => evt.results), + ]) + ); return ( { + + + {profile(results())()?.name} won! + + ); }; diff --git a/pkg/server/src/table.ts b/pkg/server/src/table.ts index 1b61e70..1a07e36 100644 --- a/pkg/server/src/table.ts +++ b/pkg/server/src/table.ts @@ -1,5 +1,6 @@ import GAMES, { Game, GameKey } from "@games/shared/games"; import { + invert, isEmpty, multiScan, partition, @@ -8,7 +9,7 @@ import { ValueWithin, } from "@games/shared/kefirs"; import { t } from "elysia"; -import { combine, Observable, pool, Property } from "kefir"; +import { combine, constant, merge, Observable, pool, Property } from "kefir"; import Bus, { type Bus as TBus } from "kefir-bus"; import { log } from "./logging"; @@ -17,6 +18,7 @@ export const WsOut = t.Object({ playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))), gameConfig: t.Optional(t.Any()), view: t.Optional(t.Any()), + results: t.Optional(t.Any()), }); export type TWsOut = typeof WsOut.static; export const WsIn = t.Union([ @@ -30,7 +32,8 @@ type Attributed = { humanKey: string }; type TablePayload< GameConfig = unknown, GameView = unknown, - GameAction = unknown + GameAction = unknown, + GameResult = unknown > = { inputs: { connectionChanges: TBus< @@ -51,6 +54,7 @@ type TablePayload< any >; gameConfig: Property; + results: Property; }; player: { [key: string]: { @@ -71,12 +75,18 @@ export const liveTable = < }, GameState, GameAction extends Attributed, - GameView + GameView, + GameResult >( key: string ) => { if (!(key in tables)) { - const inputs: TablePayload["inputs"] = { + const inputs: TablePayload< + GameConfig, + GameState, + GameAction, + GameResult + >["inputs"] = { connectionChanges: Bus(), messages: Bus(), }; @@ -110,7 +120,7 @@ export const liveTable = < .onValue(({ added, removed }) => { added.forEach((p) => { playerStreams[p] = { - view: combine([gameState], [gameImpl], (a, b) => [a, b] as const) + view: withGame(gameState) .map( ([state, game]) => state && (game.getView({ state, humanKey: p }) as GameView) @@ -178,16 +188,29 @@ export const liveTable = < .map((config) => GAMES[config.game as GameKey](config)) .toProperty(); + const withGame = (obs: Observable) => + combine([obs], [gameImpl], (o, game) => [o, game] as const); + + const resultsPool = pool(); + const results = merge([constant(null), resultsPool]).toProperty(); + + const gameIsActivePool = pool(); + const gameIsActive = merge([ + constant(false), + gameIsActivePool, + ]).toProperty(); + const gameState = multiScan( null as GameState | null, [ // initialize game state when started - gameImpl.sampledBy(gameStarts), + gameImpl.sampledBy(gameStarts).filterBy(invert(gameIsActive)), (prev, game: ValueWithin) => prev || (game.init() as GameState), ], [ - combine([action], [gameImpl], (act, game) => [act, game] as const), + // handle actions from players + action.filterBy(gameIsActive).thru(withGame), ( prev, [{ action, humanKey }, game]: [ @@ -202,13 +225,25 @@ export const liveTable = < humanKey, }) as GameState), ], - [quit, () => null] + [results.filterBy(gameIsActive), () => null], // handle game ending criteria + [quit, () => null] // handle players leaving the room ).toProperty(); - const gameIsActive = gameState - .map((gs) => gs != null) - .skipDuplicates() - .toProperty(); + resultsPool.plug( + gameState + .filter((state) => state != null) + .thru(withGame) + .map(([state, game]) => game.getResult(state) as unknown as GameResult) + .filter((result) => result != null) + .merge(quit.map(() => null)) + ); + + gameIsActivePool.plug( + combine([gameState, results]) + .map(([state, result]) => state != null && result == null) + .skipDuplicates() + .toProperty() + ); gameConfigPool.plug( multiScan( @@ -234,6 +269,7 @@ export const liveTable = < playersPresent, playersReady, gameConfig, + results, }, player: playerStreams, }, diff --git a/pkg/shared/games/index.ts b/pkg/shared/games/index.ts index bfa5093..841f4bd 100644 --- a/pkg/shared/games/index.ts +++ b/pkg/shared/games/index.ts @@ -1,10 +1,11 @@ import simple from "./simple"; export type Game< - S = unknown, - A = unknown, - E extends { error: any } = { error: any }, - V = unknown + S = unknown, // state + A = unknown, // action + E extends { error: any } = { error: any }, // error + V = unknown, // view + R = unknown // results > = { title: string; rules: string; @@ -12,6 +13,7 @@ export type Game< resolveAction: (p: { state: S; action: A; humanKey: string }) => S | E; getView: (p: { state: S; humanKey: string }) => V; resolveQuit: (p: { state: S; humanKey: string }) => S; + getResult: (state: S) => R | undefined; }; export const GAMES: { diff --git a/pkg/shared/games/simple.ts b/pkg/shared/games/simple.ts index 712dd0f..883db44 100644 --- a/pkg/shared/games/simple.ts +++ b/pkg/shared/games/simple.ts @@ -101,6 +101,8 @@ export const resolveSimpleAction = ({ } }; +export type SimpleResult = string; + type SimpleError = { error: "whoops!" }; export default (config: SimpleConfiguration) => @@ -112,9 +114,14 @@ export default (config: SimpleConfiguration) => getView: ({ state, humanKey }) => getSimplePlayerView(config, state, humanKey), resolveQuit: () => null, + getResult: (state) => + Object.entries(state.playerHands).find( + ([_, hand]) => hand.length === 2 + )?.[0], } satisfies Game< SimpleGameState, SimpleAction, SimpleError, - SimplePlayerView + SimplePlayerView, + SimpleResult >); diff --git a/pkg/shared/kefirs.ts b/pkg/shared/kefirs.ts index 19244ff..c748b27 100644 --- a/pkg/shared/kefirs.ts +++ b/pkg/shared/kefirs.ts @@ -50,3 +50,5 @@ export const setDiff = ( }); export const set = (arr: T[]) => new Set(arr); + +export const invert = (obs: Observable) => obs.map((o) => !o);