[wip] im tired boss
This commit is contained in:
@@ -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 }));
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
} satisfies Game<
|
||||
SimpleGameState,
|
||||
SimpleAction,
|
||||
SimpleError,
|
||||
SimplePlayerView
|
||||
>;
|
||||
>);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user