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