result states

This commit is contained in:
2025-08-31 22:23:30 -04:00
parent 0ea16ead64
commit b433a26fc6
7 changed files with 82 additions and 20 deletions

View File

@@ -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",

View File

@@ -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";

View File

@@ -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<string[]>(
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 (
<TableContext.Provider
@@ -127,6 +136,11 @@ export default (props: { tableKey: string }) => {
<Show when={view() != null}>
<Game />
</Show>
<Show when={results() != null}>
<span class="bg-[var(--light)] text-[var(--dark)] rounded-[24px] border-2 border-[var(--dark)] absolute center p-4 shadow-lg text-[4em] text-center">
{profile(results())()?.name} won!
</span>
</Show>
</TableContext.Provider>
);
};

View File

@@ -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<GameConfig | null, any>;
results: Property<GameResult, any>;
};
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<GameConfig, GameState, GameAction>["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 = <T>(obs: Observable<T, any>) =>
combine([obs], [gameImpl], (o, game) => [o, game] as const);
const resultsPool = pool<GameResult | null, any>();
const results = merge([constant(null), resultsPool]).toProperty();
const gameIsActivePool = pool<boolean, any>();
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<typeof gameImpl>) =>
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)
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();
.toProperty()
);
gameConfigPool.plug(
multiScan(
@@ -234,6 +269,7 @@ export const liveTable = <
playersPresent,
playersReady,
gameConfig,
results,
},
player: playerStreams,
},

View File

@@ -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: {

View File

@@ -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
>);

View File

@@ -50,3 +50,5 @@ export const setDiff = <T>(
});
export const set = <T>(arr: T[]) => new Set<T>(arr);
export const invert = <E>(obs: Observable<boolean, E>) => obs.map((o) => !o);