Files
games/packages/server/src/table.ts
2025-08-25 18:36:59 -04:00

163 lines
3.9 KiB
TypeScript

import { t } from "elysia";
import { combine, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
import {
newSimpleGameState,
resolveSimpleAction,
SimpleAction,
SimpleConfiguration,
SimpleGameState,
} from "./games/simple";
import { transform } from "./kefir-extension";
export const WsOut = t.Object({
players: t.Optional(t.Array(t.String())),
playersReady: t.Optional(t.Record(t.String(), t.Boolean())),
view: t.Optional(t.Any()),
});
export type TWsOut = typeof WsOut.static;
export const WsIn = t.Union([
t.Object({ ready: t.Boolean() }),
t.Object({ action: t.Any() }),
t.Object({ quit: t.Literal(true) }),
]);
export type TWsIn = typeof WsIn.static;
type Attributed = { humanKey: string };
type TablePayload<GameConfig, GameState, GameAction> = {
inputs: {
connectionChanges: TBus<
Attributed & { presence: "joined" | "left" },
never
>;
readys: TBus<Attributed & { ready: boolean }, never>;
actions: TBus<Attributed & GameAction, never>;
quits: TBus<Attributed, never>;
};
outputs: {
playersPresent: Property<string[], never>;
playersReady: Property<{ [key: string]: boolean }, unknown>;
gameConfig: Property<GameConfig | null, never>;
gameState: Property<GameState | null, never>;
};
};
const tables: {
[key: string]: TablePayload<unknown, unknown, unknown>;
} = {};
export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
if (!(key in tables)) {
const inputs: TablePayload<
GameConfig,
GameState,
GameAction
>["inputs"] = {
connectionChanges: Bus(),
readys: Bus(),
actions: Bus(),
quits: Bus(),
};
const { connectionChanges, readys, actions, quits } = inputs;
// =======
const playersPresent = 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 })
.map((counts) => Object.keys(counts));
const playersReady = transform(
{} as { [key: string]: boolean },
[
playersPresent,
(prev, players: string[]) =>
Object.fromEntries(
players.map((p) => [p, prev[p] ?? false])
),
],
[
readys,
(prev, evt: { humanKey: string; ready: boolean }) =>
prev[evt.humanKey] != null
? { ...prev, [evt.humanKey]: evt.ready }
: prev,
]
);
const gameStarts = playersReady
.filter(
(pr) =>
Object.values(pr).length > 0 &&
Object.values(pr).every((ready) => ready)
)
.map((_) => null);
const gameConfig = playersPresent.map((players) => ({
game: "simple",
players,
}));
const gameState = transform(
null as SimpleGameState | null,
[
combine([gameStarts], [gameConfig], (_, config) => config),
(prev, startConfig: SimpleConfiguration) =>
prev == null ? newSimpleGameState(startConfig) : prev,
],
[
combine([actions], [gameConfig], (action, config) => ({
action,
config,
})),
(
prev,
evt: {
action: Attributed & SimpleAction;
config: SimpleConfiguration;
}
) =>
prev != null
? resolveSimpleAction({
config: evt.config,
state: prev,
action: evt.action,
humanKey: evt.action.humanKey,
})
: prev,
]
).toProperty();
tables[key] = {
inputs,
outputs: {
playersPresent,
playersReady,
gameConfig: gameConfig as Property<unknown, never>,
gameState: gameState as Property<unknown, never>,
},
};
// cleanup
tables[key].outputs.playersPresent
.debounce(30000, { immediate: false })
.filter((players) => players.length === 0)
.skip(1)
.onValue((_) => {
console.log("DELETING LIVE TABLE");
delete tables[key];
});
}
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
};