[wip] im tired boss

This commit is contained in:
2025-08-30 15:49:55 -04:00
parent 5e33e33cce
commit 782dd738cc
5 changed files with 155 additions and 127 deletions

View File

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

View File

@@ -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<GameConfig, GameState, GameAction> = {
type TablePayload<
GameConfig = unknown,
GameView = unknown,
GameAction = unknown
> = {
inputs: {
connectionChanges: TBus<
Attributed & { presence: "joined" | "left" },
Attributed & {
presence: "joined" | "left";
},
never
>;
readys: TBus<Attributed & { ready: boolean }, any>;
readys: TBus<
Attributed & {
ready: boolean;
},
any
>;
actions: TBus<Attributed & GameAction, any>;
quits: TBus<Attributed, any>;
};
outputs: {
global: {
playersPresent: Property<string[], any>;
playersReady: Property<{ [key: string]: boolean } | null, any>;
playersReady: Property<
{
[key: string]: boolean;
} | null,
any
>;
gameConfig: Property<GameConfig | null, any>;
gameImpl: Property<Game, unknown>;
gameState: Property<GameState | null, any>;
};
player: {
[key: string]: {
view: Property<GameView, any>;
};
};
};
};
const tables: {
[key: string]: TablePayload<unknown, unknown, unknown>;
[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<GameConfig, GameState, GameAction>["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<null, any>();
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<typeof playersPresent>) =>
Object.fromEntries(players.map((p) => [p, prev?.[p] ?? false])),
],
[
readys,
(prev, evt: { humanKey: string; ready: boolean }) =>
(prev, evt: ValueWithin<typeof readys>) =>
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<typeof playersPresent>) =>
Object.fromEntries(players.map((p) => [p, false])),
]
)
.toProperty()
.log("playersReady");
gameStarts.plug(
playersReady
const gameStarts = playersReady
.filter(
(pr) =>
Object.values(pr ?? {}).length > 0 &&
Object.values(pr!).every((ready) => ready)
)
.map((_) => null)
.log("gameStarts")
);
.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<typeof gameImpl>) =>
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,
prev &&
(game.resolveAction({
state: prev,
action: evt.action,
}) as GameState)
: 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: {
global: {
playersPresent,
playersReady: playersReady.toProperty(),
gameConfig: gameConfig as Property<unknown, any>,
gameState: gameState as Property<unknown, any>,
gameImpl,
},
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<GameConfig, GameState, GameAction>;
};

View File

@@ -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<any, any, any, any, any> };
} satisfies {
[key: string]: (config: { game: string; players: string[] }) => Game;
};
export default GAMES;
export type GameKey = keyof typeof GAMES;

View File

@@ -22,7 +22,10 @@ export type SimplePlayerView = {
myHand: Hand<Card>;
};
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 {
export default (config: SimpleConfiguration) =>
({
title: "Simple",
rules: "You can draw, or you can discard. Then your turn is up.",
init: newSimpleGameState,
resolveAction: resolveSimpleAction,
getView: ({ config, state, humanKey }) =>
init: () => newSimpleGameState(config),
resolveAction: (p) => resolveSimpleAction({ ...p, config }),
getView: ({ state, humanKey }) =>
getSimplePlayerView(config, state, humanKey),
resolveQuit: () => null,
} satisfies Game<
SimpleConfiguration,
SimpleGameState,
SimpleAction,
SimpleError,
SimplePlayerView
>;
>);

View File

@@ -1,12 +1,18 @@
import { merge, Observable } from "kefir";
export const transform = <
T,
Mutations extends [Observable<any, any>, (prev: T, evt: any) => T][]
>(
initValue: T,
...mutations: Mutations
): Observable<T, unknown> =>
export type ValueWithin<O extends Observable<any, any>> = Parameters<
Parameters<O["map"]>[0]
>[0];
type Mutation<A, O extends Observable<any, any>> = [
O,
(prev: A, value: ValueWithin<O>) => A
];
export const multiScan = <A, M extends Mutation<A, any>[]>(
initValue: A,
...mutations: M
): Observable<A, any> =>
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;