Files
games/packages/server/src/table.ts
2025-08-22 00:19:40 -04:00

102 lines
2.4 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 WebsocketIncomingMessage = t.Union([
t.Object({ proposeGame: t.String() }),
t.Object({ startGame: t.Literal(true) }),
t.Object({ action: t.Any() }),
]);
type TablePayload<GameState, GameAction> = {
inputs: {
presenceChanges: TBus<
{ humanKey: string; presence: "joined" | "left" },
never
>;
gameProposals: TBus<{ proposeGame: string }, never>;
gameStarts: TBus<{ startGame: true }, never>;
gameActions: TBus<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"] = {
presenceChanges: Bus(),
gameProposals: Bus(),
gameStarts: Bus(),
gameActions: Bus(),
};
const { presenceChanges, gameProposals, gameStarts, gameActions } =
inputs;
// =======
const playersPresent = presenceChanges.scan((prev, evt) => {
if (evt.presence == "joined") {
prev.push(evt.humanKey);
} else if (evt.presence == "left") {
prev.splice(prev.indexOf(evt.humanKey), 1);
}
return prev;
}, [] as string[]);
const gameState = transform(
null as GameState | null,
[
gameStarts.thru((Evt) =>
combine([Evt], [playersPresent], (evt, players) => ({
...evt,
players,
}))
),
(prev, evt: { players: string[] }) =>
prev == null ? (newGame(evt.players) as GameState) : prev,
],
[
gameActions,
(prev, evt: GameAction) =>
prev != null
? (resolveAction(
prev as unknown as SimpleGameState,
"evt",
evt as SimpleAction
) as GameState)
: 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>;
};