Files
games/packages/server/src/table.ts

112 lines
2.7 KiB
TypeScript

import { t } from "elysia";
import { combine, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
import {
newGame,
resolveAction,
SimpleAction,
SimpleGameState,
} from "./games/simple";
import { transform } from "./kefir-extension";
export const WsOut = t.Object({
players: t.Optional(t.Array(t.String())),
view: t.Optional(t.Any()),
});
export type TWsOut = typeof WsOut.static;
export const WsIn = t.Union([
t.Object({ proposeGame: t.String() }),
t.Object({ startGame: t.Literal(true) }),
t.Object({ action: t.Any() }),
]);
export type TWsIn = typeof WsIn.static;
type Attributed = { humanKey: string };
type TablePayload<GameState, GameAction> = {
inputs: {
connectionChanges: TBus<
Attributed & { presence: "joined" | "left" },
never
>;
gameProposals: TBus<{ proposeGame: string }, never>;
gameStarts: TBus<{ startGame: true }, never>;
gameActions: TBus<Attributed & GameAction, never>;
};
outputs: {
playersPresent: Property<string[], never>;
gameState: Property<GameState | null, never>;
};
};
const tables: {
[key: string]: TablePayload<unknown, unknown>;
} = {};
export const liveTable = <GameState, GameAction>(key: string) => {
if (!(key in tables)) {
const inputs: TablePayload<GameState, GameAction>["inputs"] = {
connectionChanges: Bus(),
gameProposals: Bus(),
gameStarts: Bus(),
gameActions: Bus(),
};
const { connectionChanges, gameProposals, gameStarts, gameActions } =
inputs;
// =======
const playerConnectionCounts = connectionChanges.scan((prev, evt) => {
if (evt.presence == "left" && prev[evt.humanKey] == 1) {
const { [evt.humanKey]: _, ...rest } = prev;
return rest;
}
return {
...prev,
[evt.humanKey]:
(prev[evt.humanKey] ?? 0) +
(evt.presence == "joined" ? 1 : -1),
};
}, {} as { [key: string]: number });
const playersPresent = playerConnectionCounts.map((counts) =>
Object.keys(counts)
);
const gameState = transform(
null as SimpleGameState | null,
[
combine([gameStarts], [playersPresent], (evt, players) => ({
...evt,
players,
})),
(prev, evt: { players: string[] }) =>
prev == null
? (newGame(evt.players) as SimpleGameState)
: prev,
],
[
gameActions,
(prev, evt: Attributed & SimpleAction) =>
prev != null
? resolveAction(prev, evt.humanKey, evt)
: prev,
]
).toProperty();
tables[key] = {
inputs,
outputs: {
playersPresent,
gameState: gameState as Property<unknown, never>,
},
};
// cleanup
tables[key].outputs.playersPresent
.slidingWindow(2)
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0)
.onValue((_) => {
delete tables[key];
});
}
return tables[key] as TablePayload<GameState, GameAction>;
};