lots of necessary plumbing

This commit is contained in:
2025-08-24 22:04:29 -04:00
parent 4bcf071668
commit a117f6703f
6 changed files with 201 additions and 157 deletions

View File

@@ -1,22 +1,10 @@
import { import { Accessor, createContext, For, onCleanup, Show } from "solid-js";
Accessor,
createContext,
createEffect,
createSignal,
For,
onCleanup,
Show,
} from "solid-js";
import { SimplePlayerView } from "../../../server/src/games/simple";
import api, { fromWebsocket } from "../api";
import { me, playerColor, profile } from "../profile";
import { fromEvents, Stream, stream } from "kefir";
import Bus from "kefir-bus";
import { createObservable, createObservableWithInit, cx, WSEvent } from "../fn";
import { EdenWS } from "@elysiajs/eden/treaty";
import { TWsIn, TWsOut } from "../../../server/src/table"; import { TWsIn, TWsOut } from "../../../server/src/table";
import Player from "./Player"; import api, { fromWebsocket } from "../api";
import { createObservable, createObservableWithInit, cx } from "../fn";
import { me } from "../profile";
import Game from "./Game"; import Game from "./Game";
import Player from "./Player";
export const TableContext = createContext<{ export const TableContext = createContext<{
players: Accessor<string[]>; players: Accessor<string[]>;
@@ -95,10 +83,10 @@ export default (props: { tableKey: string }) => {
<Show when={view() == null}> <Show when={view() == null}>
<div class="absolute center"> <div class="absolute center">
<button <button
onClick={() => ws.send({ startGame: true })} onClick={() => ws.send({ ready: true })}
class="button p-1 " class="button p-1 "
> >
Start Game! Ready
</button> </button>
</div> </div>
</Show> </Show>

View File

@@ -1,9 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { import {
getSimplePlayerView,
SimpleAction, SimpleAction,
SimpleConfiguration,
SimpleGameState, SimpleGameState,
getKnowledge,
getView,
} from "./games/simple"; } from "./games/simple";
import { human } from "./human"; import { human } from "./human";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -11,6 +11,7 @@ import db from "./db";
import { liveTable, WsOut, WsIn } from "./table"; import { liveTable, WsOut, WsIn } from "./table";
import { Human } from "@prisma/client"; import { Human } from "@prisma/client";
import _ from "lodash"; import _ from "lodash";
import { combine } from "kefir";
const api = new Elysia({ prefix: "/api" }) const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => { .post("/whoami", async ({ cookie: { token } }) => {
@@ -73,18 +74,29 @@ const api = new Elysia({ prefix: "/api" })
}, },
send, send,
}) { }) {
const table = liveTable<SimpleGameState, SimpleAction>(tableKey); const table = liveTable<
SimpleConfiguration,
SimpleGameState,
SimpleAction
>(tableKey);
table.outputs.playersPresent table.outputs.playersPresent.onValue((players) =>
.skipDuplicates((p1, p2) => _.isEqual(new Set(p1), new Set(p2))) send({ players })
.onValue((players) => send({ players }));
table.outputs.gameState.onValue((gameState) =>
send({
view:
gameState &&
getView(getKnowledge(gameState, humanKey), humanKey),
})
); );
table.outputs.playersReady.onValue((readys) =>
send({ playersReady: readys })
);
combine(
[table.outputs.gameState],
[table.outputs.gameConfig],
(state, config) =>
state &&
config &&
getSimplePlayerView(config, state, humanKey)
).onValue((view) => send({ view }));
table.inputs.connectionChanges.emit({ table.inputs.connectionChanges.emit({
humanKey, humanKey,
presence: "joined", presence: "joined",
@@ -104,15 +116,15 @@ const api = new Elysia({ prefix: "/api" })
body body
) { ) {
const { const {
inputs: { gameProposals, gameStarts, gameActions }, inputs: { readys, actions, quits },
} = liveTable(tableKey); } = liveTable(tableKey);
if ("proposeGame" in body) { if ("ready" in body) {
gameProposals.emit(body); readys.emit({ humanKey, ...body });
} else if ("startGame" in body) {
gameStarts.emit(body);
} else if ("action" in body) { } else if ("action" in body) {
gameActions.emit({ humanKey, ...body.action }); actions.emit({ humanKey, ...body.action });
} else if ("quit" in body) {
quits.emit({ humanKey });
} }
}, },

View File

@@ -1,104 +1,103 @@
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards"; import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
import { heq } from "@games/shared/utils"; import { heq } from "@games/shared/utils";
// omniscient game state export type SimpleConfiguration = {
export type SimpleGameState = { game: "simple";
prev?: { players: string[];
action: SimpleAction;
};
deck: Pile;
players: { [humanId: string]: Hand };
}; };
// a particular player's knowledge of the global game state // omniscient game state
export type vSimpleGameState = { export type SimpleGameState = {
humanId: string; deck: Pile;
turnIdx: number;
deck: Pile<vCard>; playerHands: { [humanKey: string]: Hand };
players: { [humanId: string]: Hand<vCard> };
}; };
// a particular player's point of view in the game // a particular player's point of view in the game
export type SimplePlayerView = { export type SimplePlayerView = {
humanId: string;
deckCount: number; deckCount: number;
playerHandCounts: { [humanId: string]: number }; playerTurn: string;
playerHandCounts: { [humanKey: string]: number };
myHand: Hand<Card>; myHand: Hand<Card>;
}; };
export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card }; export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
export const newGame = (players: string[]) => { export const newSimpleGameState = (
console.log("new game called with", JSON.stringify(players)); config: SimpleConfiguration
): SimpleGameState => {
const { players } = config;
return { return {
deck: shuffle(newDeck()), deck: shuffle(newDeck()),
players: Object.fromEntries(players.map((humanId) => [humanId, []])), turnIdx: 0,
} as SimpleGameState; playerHands: Object.fromEntries(
players.map((humanKey) => [humanKey, []])
),
};
}; };
export const getKnowledge = ( export const getSimplePlayerView = (
config: SimpleConfiguration,
state: SimpleGameState, state: SimpleGameState,
humanId: string humanKey: string
): vSimpleGameState => ({
humanId,
deck: state.deck.map((_) => null),
players: Object.fromEntries(
Object.entries(state.players).map(([id, hand]) => [
id,
hand.map(id === humanId ? (card) => card : (_) => null),
])
),
});
export const getView = (
state: vSimpleGameState,
humanId: string
): SimplePlayerView => ({ ): SimplePlayerView => ({
humanId,
deckCount: state.deck.length, deckCount: state.deck.length,
myHand: state.players[humanId] as Hand, playerTurn: config.players[state.turnIdx],
myHand: state.playerHands[humanKey] as Hand,
playerHandCounts: Object.fromEntries( playerHandCounts: Object.fromEntries(
Object.entries(state.players) Object.entries(state.playerHands)
.filter(([id]) => id != humanId) .filter(([id]) => id != humanKey)
.map(([id, hand]) => [id, hand.length]) .map(([id, hand]) => [id, hand.length])
), ),
}); });
export const resolveAction = ( export const resolveSimpleAction = ({
state: SimpleGameState, config,
humanId: string, state,
action: SimpleAction humanKey,
): SimpleGameState => { action,
console.log("attempting to resolve action", JSON.stringify(action)); }: {
if (!(humanId in state.players)) { config: SimpleConfiguration;
throw Error( state: SimpleGameState;
`${humanId} is not a player in this game; they cannot perform actions` humanKey: string;
action: SimpleAction;
}): SimpleGameState => {
const playerHand = state.playerHands[humanKey];
if (playerHand == null) {
throw new Error(
`${humanKey} is not a player in this game; they cannot perform actions`
); );
} }
const playerHand = state.players[humanId]; if (humanKey != config.players[state.turnIdx]) {
if (action.type == "draw") { throw new Error(`It's not ${humanKey}'s turn!`);
const [drawn, ...rest] = state.deck;
console.log("drew card", JSON.stringify(drawn));
return {
deck: rest,
players: {
...state.players,
[humanId]: [drawn, ...playerHand],
},
};
} }
const numPlayers = Object.keys(state.playerHands).length;
const newTurnIdx = (state.turnIdx + 1) % numPlayers;
if (action.type == "draw") {
const [drawn, ...rest] = state.deck;
return {
deck: rest,
playerHands: {
...state.playerHands,
[humanKey]: [drawn, ...playerHand],
},
turnIdx: newTurnIdx,
};
} else {
// action.type == discard // action.type == discard
const index = playerHand.findIndex(heq(action.card)); const cardIndex = playerHand.findIndex(heq(action.card));
return { return {
deck: [action.card, ...state.deck], deck: [action.card, ...state.deck],
players: { playerHands: {
...state.players, ...state.playerHands,
[humanId]: playerHand [humanKey]: playerHand
.slice(0, index) .slice(0, cardIndex)
.concat(playerHand.slice(index + 1)), .concat(playerHand.slice(cardIndex + 1)),
}, },
turnIdx: newTurnIdx,
}; };
}
}; };

View File

@@ -1,8 +1,7 @@
import api from "./api";
import { Elysia, env } from "elysia";
import { cors } from "@elysiajs/cors"; import { cors } from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static"; import { staticPlugin } from "@elysiajs/static";
import { error } from "node:console"; import { Elysia, env } from "elysia";
import api from "./api";
const port = env.PORT || 5001; const port = env.PORT || 5001;

View File

@@ -6,7 +6,7 @@ export const transform = <
>( >(
initValue: T, initValue: T,
...mutations: Mutations ...mutations: Mutations
): Observable<T, any> => ): Observable<T, unknown> =>
merge( merge(
mutations.map(([source, mutation]) => mutations.map(([source, mutation]) =>
source.map((event) => ({ event, mutation })) source.map((event) => ({ event, mutation }))

View File

@@ -2,59 +2,69 @@ import { t } from "elysia";
import { combine, Property } from "kefir"; import { combine, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus"; import Bus, { type Bus as TBus } from "kefir-bus";
import { import {
newGame, newSimpleGameState,
resolveAction, resolveSimpleAction,
SimpleAction, SimpleAction,
SimpleConfiguration,
SimpleGameState, SimpleGameState,
} from "./games/simple"; } from "./games/simple";
import { transform } from "./kefir-extension"; import { transform } from "./kefir-extension";
export const WsOut = t.Object({ export const WsOut = t.Object({
players: t.Optional(t.Array(t.String())), players: t.Optional(t.Array(t.String())),
playersReady: t.Optional(t.Record(t.String(), t.String())),
view: t.Optional(t.Any()), view: t.Optional(t.Any()),
}); });
export type TWsOut = typeof WsOut.static; export type TWsOut = typeof WsOut.static;
export const WsIn = t.Union([ export const WsIn = t.Union([
t.Object({ proposeGame: t.String() }), t.Object({ ready: t.Boolean() }),
t.Object({ startGame: t.Literal(true) }),
t.Object({ action: t.Any() }), t.Object({ action: t.Any() }),
t.Object({ quit: t.Literal(true) }),
]); ]);
export type TWsIn = typeof WsIn.static; export type TWsIn = typeof WsIn.static;
type Attributed = { humanKey: string }; type Attributed = { humanKey: string };
type TablePayload<GameState, GameAction> = { type TablePayload<GameConfig, GameState, GameAction> = {
inputs: { inputs: {
connectionChanges: TBus< connectionChanges: TBus<
Attributed & { presence: "joined" | "left" }, Attributed & { presence: "joined" | "left" },
never never
>; >;
gameProposals: TBus<{ proposeGame: string }, never>;
gameStarts: TBus<{ startGame: true }, never>; readys: TBus<Attributed & { ready: boolean }, never>;
gameActions: TBus<Attributed & GameAction, never>; actions: TBus<Attributed & GameAction, never>;
quits: TBus<Attributed, never>;
}; };
outputs: { outputs: {
playersPresent: Property<string[], never>; playersPresent: Property<string[], never>;
playersReady: Property<{ [key: string]: boolean }, unknown>;
gameConfig: Property<GameConfig | null, never>;
gameState: Property<GameState | null, never>; gameState: Property<GameState | null, never>;
}; };
}; };
const tables: { const tables: {
[key: string]: TablePayload<unknown, unknown>; [key: string]: TablePayload<unknown, unknown, unknown>;
} = {}; } = {};
export const liveTable = <GameState, GameAction>(key: string) => { export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
if (!(key in tables)) { if (!(key in tables)) {
const inputs: TablePayload<GameState, GameAction>["inputs"] = { const inputs: TablePayload<
GameConfig,
GameState,
GameAction
>["inputs"] = {
connectionChanges: Bus(), connectionChanges: Bus(),
gameProposals: Bus(), readys: Bus(),
gameStarts: Bus(), actions: Bus(),
gameActions: Bus(), quits: Bus(),
}; };
const { connectionChanges, gameProposals, gameStarts, gameActions } = const { connectionChanges, readys, actions, quits } = inputs;
inputs;
// ======= // =======
const playerConnectionCounts = connectionChanges.scan((prev, evt) => {
const playersPresent = connectionChanges
.scan((prev, evt) => {
if (evt.presence == "left" && prev[evt.humanKey] == 1) { if (evt.presence == "left" && prev[evt.humanKey] == 1) {
const { [evt.humanKey]: _, ...rest } = prev; const { [evt.humanKey]: _, ...rest } = prev;
return rest; return rest;
@@ -65,28 +75,62 @@ export const liveTable = <GameState, GameAction>(key: string) => {
(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 })
const playersPresent = playerConnectionCounts.map((counts) => .map((counts) => Object.keys(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).every((ready) => ready))
.map((_) => null);
const gameConfig = playersPresent.map((players) => ({
game: "simple",
players,
}));
const gameState = transform( const gameState = transform(
null as SimpleGameState | null, null as SimpleGameState | null,
[ [
combine([gameStarts], [playersPresent], (evt, players) => ({ combine([gameStarts], [gameConfig], (_, config) => config),
...evt, (prev, startConfig: SimpleConfiguration) =>
players, prev == null ? newSimpleGameState(startConfig) : prev,
})),
(prev, evt: { players: string[] }) =>
prev == null
? (newGame(evt.players) as SimpleGameState)
: prev,
], ],
[ [
gameActions, combine([actions], [gameConfig], (action, config) => ({
(prev, evt: Attributed & SimpleAction) => action,
config,
})),
(
prev,
evt: {
action: Attributed & SimpleAction;
config: SimpleConfiguration;
}
) =>
prev != null prev != null
? resolveAction(prev, evt.humanKey, evt) ? resolveSimpleAction({
config: evt.config,
state: prev,
action: evt.action,
humanKey: evt.action.humanKey,
})
: prev, : prev,
] ]
).toProperty(); ).toProperty();
@@ -95,17 +139,19 @@ export const liveTable = <GameState, GameAction>(key: string) => {
inputs, inputs,
outputs: { outputs: {
playersPresent, playersPresent,
playersReady,
gameConfig: gameConfig as Property<unknown, never>,
gameState: gameState as Property<unknown, never>, gameState: gameState as Property<unknown, never>,
}, },
}; };
// cleanup // cleanup
tables[key].outputs.playersPresent tables[key].outputs.playersPresent
.slidingWindow(2) .debounce(30000)
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0) .filter((players) => players.length === 0)
.onValue((_) => { .onValue((_) => {
delete tables[key]; delete tables[key];
}); });
} }
return tables[key] as TablePayload<GameState, GameAction>; return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
}; };