deep in kefir lore

This commit is contained in:
2025-08-22 00:19:40 -04:00
parent 7d8ac0db76
commit cc53470ddf
8 changed files with 187 additions and 173 deletions

View File

@@ -10,7 +10,14 @@
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"object-hash": "^3.0.0" "object-hash": "^3.0.0"
} },
"onlyBuiltDependencies": [
"@parcel/watcher",
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma"
]
}, },
"devDependencies": { "devDependencies": {
"@types/object-hash": "^3.0.6" "@types/object-hash": "^3.0.6"

View File

@@ -1,95 +1,25 @@
import { Accessor, createContext } from "solid-js";
import { import {
Accessor, SimpleAction,
createContext, SimplePlayerView,
createEffect,
createResource,
createSignal,
For,
onCleanup,
Resource,
ResourceReturn,
Show,
untrack,
} from "solid-js";
import {
GameState,
Action,
vGameState,
PlayerView,
} from "../../../server/src/games/simple"; } from "../../../server/src/games/simple";
import api from "../api";
import Hand from "./Hand";
import Pile from "./Pile";
import { ApiType } from "../fn";
import { createStore } from "solid-js/store";
export const GameContext = createContext<{ export const GameContext = createContext<{
view: Accessor<PlayerView | undefined>; view: Accessor<SimplePlayerView | undefined>;
submitAction: (action: Action) => Promise<any>; submitAction: (action: SimpleAction) => Promise<any>;
}>(); }>();
const [playerProfiles, setPlayerProfiles] = createStore< export default () => {
Record<string, Resource<ApiType<typeof api.profile.get>>>
>({});
export default (props: { tableKey: string }) => {
const [view, setView] = createSignal<PlayerView>();
const [players, setPlayers] = createSignal<string[]>([]);
createEffect(() => {
players().forEach((player) => {
if (!untrack(() => playerProfiles[player])) {
const [playerProfile] = createResource(() =>
api.profile
.get({ query: { otherHumanKey: player } })
.then((r) => r.data)
);
setPlayerProfiles((prev) => ({
...prev,
[player]: playerProfile,
}));
}
});
});
const ws = api(props).subscribe;
onCleanup(() => ws.close());
ws.on("message", (evt) => {
if (evt.data.players) {
setPlayers(evt.data.players);
}
if (evt.data.view) {
setView(evt.data.view);
}
});
const submitAction = (action: Action) => api.simple(props).post({ action });
const Lobby = () => {
return ( return (
<div class="fixed tc mt-20 flex flex-col items-center"> <>
<button class="button p-1 m-10">Start Game!</button> Game started!
<For each={players()}> {/* <Pile
{(player) => (
<p style={{ "font-size": "2em" }}>
{playerProfiles[player]?.()?.name}
</p>
)}
</For>
</div>
);
};
return (
<GameContext.Provider value={{ view, submitAction }}>
<Show when={view() != undefined} fallback={<Lobby />}>
<Pile
count={view()!.deckCount} count={view()!.deckCount}
class="cursor-pointer fixed center" class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })} onClick={() => submitAction({ type: "draw" })}
/> />
<Hand class="fixed bc" hand={view()!.myHand} /> <Hand class="fixed bc" hand={view()!.myHand} /> */}
</Show> </>
</GameContext.Provider>
); );
}; };

View File

@@ -7,21 +7,13 @@ import {
For, For,
onCleanup, onCleanup,
Resource, Resource,
ResourceReturn,
Show, Show,
untrack, untrack,
} from "solid-js"; } from "solid-js";
import {
GameState,
Action,
vGameState,
PlayerView,
} from "../../../server/src/games/simple";
import api, { me } from "../api";
import Hand from "./Hand";
import Pile from "./Pile";
import { ApiType } from "../fn";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { SimplePlayerView } from "../../../server/src/games/simple";
import api, { me } from "../api";
import { ApiType } from "../fn";
import Game from "./Game"; import Game from "./Game";
const [playerProfiles, setPlayerProfiles] = createStore< const [playerProfiles, setPlayerProfiles] = createStore<
@@ -30,15 +22,15 @@ const [playerProfiles, setPlayerProfiles] = createStore<
export const TableContext = createContext<{ export const TableContext = createContext<{
players: Accessor<string[]>; players: Accessor<string[]>;
view: Accessor<PlayerView | undefined>; view: Accessor<SimplePlayerView | undefined>;
// submitAction: (action: Action) => Promise<any>; // submitAction: (action: Action) => Promise<any>;
}>(); }>();
export default (props: { tableKey: string }) => { export default (props: { tableKey: string }) => {
const [players, setPlayers] = createSignal<string[]>([]); const [players, setPlayers] = createSignal<string[]>([]);
const [view, setView] = createSignal<PlayerView>(); const [view, setView] = createSignal<SimplePlayerView>();
const ws = api(props).subscribe(); const ws = api.ws(props).subscribe();
onCleanup(() => ws.close()); onCleanup(() => ws.close());
ws.on("message", (evt) => { ws.on("message", (evt) => {
@@ -81,8 +73,8 @@ export default (props: { tableKey: string }) => {
<div <div
style={{ style={{
transform: `translate(0, ${ transform: `translate(0, ${
verticalOffset() * 1500 verticalOffset() * 150
}px)`, }vh)`,
}} }}
class="w-20 h-20 rounded-full bg-red-900 flex justify-center items-center" class="w-20 h-20 rounded-full bg-red-900 flex justify-center items-center"
> >
@@ -103,7 +95,12 @@ export default (props: { tableKey: string }) => {
> >
<Show when={view() == null}> <Show when={view() == null}>
<div class="absolute center"> <div class="absolute center">
<button class="button p-1 ">Start Game!</button> <button
onClick={() => ws.send({ startGame: true })}
class="button p-1 "
>
Start Game!
</button>
</div> </div>
</Show> </Show>
</div> </div>

View File

@@ -15,12 +15,7 @@ export default () => {
<Suspense> <Suspense>
<div style={{ padding: "20px" }}> <div style={{ padding: "20px" }}>
<p class="text-[40px]">{param.game}</p> <p class="text-[40px]">{param.game}</p>
<button <button class="px-2 py-1.5 m-4 button">New Game</button>
class="px-2 py-1.5 m-4 button"
onClick={() => api.simple.newGame.post().then(refetch)}
>
New Game
</button>
<ul> <ul>
<For each={instances() ?? []}> <For each={instances() ?? []}>
{(instance) => ( {(instance) => (

View File

@@ -1,9 +1,14 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { Action, GameState, getKnowledge, getView } from "./games/simple"; import {
SimpleAction,
SimpleGameState,
getKnowledge,
getView,
} from "./games/simple";
import { human } from "./human"; import { human } from "./human";
import dayjs from "dayjs"; import dayjs from "dayjs";
import db from "./db"; import db from "./db";
import { liveTable } from "./table"; import { liveTable, WebsocketIncomingMessage } from "./table";
const api = new Elysia({ prefix: "/api" }) const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => { .post("/whoami", async ({ cookie: { token } }) => {
@@ -56,11 +61,34 @@ const api = new Elysia({ prefix: "/api" })
}) })
) )
.get("/games", () => [{ key: "simple", name: "simple" }]) .get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/:tableKey", { .ws("/ws/:tableKey", {
response: t.Object({ response: t.Object({
players: t.Optional(t.Array(t.String())), players: t.Optional(t.Array(t.String())),
view: t.Optional(t.Any()), view: t.Optional(t.Any()),
}), }),
body: WebsocketIncomingMessage,
message(
{
data: {
humanKey,
params: { tableKey },
},
},
body
) {
const {
inputs: { gameProposals, gameStarts, gameActions },
} = liveTable(tableKey);
if ("proposeGame" in body) {
gameProposals.emit(body);
} else if ("startGame" in body) {
gameStarts.emit(body);
} else if ("action" in body) {
gameActions.emit(body);
}
},
async open({ async open({
data: { data: {
@@ -69,7 +97,7 @@ const api = new Elysia({ prefix: "/api" })
}, },
send, send,
}) { }) {
const table = liveTable<GameState, Action>(tableKey); const table = liveTable<SimpleGameState, SimpleAction>(tableKey);
table.outputs.playersPresent.onValue((players) => table.outputs.playersPresent.onValue((players) =>
send({ players }) send({ players })
@@ -81,8 +109,7 @@ const api = new Elysia({ prefix: "/api" })
getView(getKnowledge(gameState, humanKey), humanKey), getView(getKnowledge(gameState, humanKey), humanKey),
}) })
); );
table.inputs.presenceChanges.emit({ humanKey, presence: "joined" });
table.input.emit({ humanKey, presence: "joined" });
}, },
async close({ async close({
data: { data: {
@@ -90,7 +117,7 @@ const api = new Elysia({ prefix: "/api" })
humanKey, humanKey,
}, },
}) { }) {
liveTable(tableKey).input.emit({ liveTable(tableKey).inputs.presenceChanges.emit({
humanKey, humanKey,
presence: "left", presence: "left",
}); });

View File

@@ -1,19 +1,10 @@
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";
import { Elysia, t } from "elysia";
import db from "../db";
import { human } from "../human";
import { ElysiaWS } from "elysia/dist/ws";
import K, { Property, Stream } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
import { Prisma, Instance } from "@prisma/client";
import type { XOR } from "ts-xor";
import { liveTable } from "../table";
// omniscient game state // omniscient game state
export type GameState = { export type SimpleGameState = {
prev?: { prev?: {
action: Action; action: SimpleAction;
}; };
deck: Pile; deck: Pile;
@@ -21,7 +12,7 @@ export type GameState = {
}; };
// a particular player's knowledge of the global game state // a particular player's knowledge of the global game state
export type vGameState = { export type vSimpleGameState = {
humanId: string; humanId: string;
deck: Pile<vCard>; deck: Pile<vCard>;
@@ -29,7 +20,7 @@ export type vGameState = {
}; };
// a particular player's point of view in the game // a particular player's point of view in the game
export type PlayerView = { export type SimplePlayerView = {
humanId: string; humanId: string;
deckCount: number; deckCount: number;
@@ -37,20 +28,20 @@ export type PlayerView = {
myHand: Hand<Card>; myHand: Hand<Card>;
}; };
export type Action = { type: "draw" } | { type: "discard"; card: Card }; export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
export const newGame = (players: string[]) => { export const newGame = (players: string[]) => {
console.log("new game called with", JSON.stringify(players)); console.log("new game called with", JSON.stringify(players));
return { return {
deck: shuffle(newDeck()), deck: shuffle(newDeck()),
players: Object.fromEntries(players.map((humanId) => [humanId, []])), players: Object.fromEntries(players.map((humanId) => [humanId, []])),
} as GameState; } as SimpleGameState;
}; };
export const getKnowledge = ( export const getKnowledge = (
state: GameState, state: SimpleGameState,
humanId: string humanId: string
): vGameState => ({ ): vSimpleGameState => ({
humanId, humanId,
deck: state.deck.map((_) => null), deck: state.deck.map((_) => null),
players: Object.fromEntries( players: Object.fromEntries(
@@ -61,7 +52,10 @@ export const getKnowledge = (
), ),
}); });
export const getView = (state: vGameState, humanId: string): PlayerView => ({ export const getView = (
state: vSimpleGameState,
humanId: string
): SimplePlayerView => ({
humanId, humanId,
deckCount: state.deck.length, deckCount: state.deck.length,
myHand: state.players[humanId] as Hand, myHand: state.players[humanId] as Hand,
@@ -73,10 +67,10 @@ export const getView = (state: vGameState, humanId: string): PlayerView => ({
}); });
export const resolveAction = ( export const resolveAction = (
state: GameState, state: SimpleGameState,
humanId: string, humanId: string,
action: Action action: SimpleAction
): GameState => { ): SimpleGameState => {
const playerHand = state.players[humanId]; const playerHand = state.players[humanId];
if (action.type == "draw") { if (action.type == "draw") {
const [drawn, ...rest] = state.deck; const [drawn, ...rest] = state.deck;

View File

@@ -0,0 +1,29 @@
import { merge, Observable } from "kefir";
export const transform = <
T,
Mutations extends [Observable<any, any>, (prev: T, evt: any) => T][]
>(
initValue: T,
...mutations: Mutations
): Observable<T, any> =>
merge(
mutations.map(([source, mutation]) =>
source.map((event) => ({ event, mutation }))
)
).scan((prev, { event, mutation }) => mutation(prev, event), initValue);
export const partition =
<C extends readonly [...string[]], T, E>(
classes: C,
partitionFn: (v: T) => C[number]
) =>
(obs: Observable<T, E>) => {
const assigned = obs.map((obj) => ({ obj, cls: partitionFn(obj) }));
return Object.fromEntries(
classes.map((C) => [
C,
assigned.filter(({ cls }) => cls == C).map(({ obj }) => obj),
])
);
};

View File

@@ -1,28 +1,34 @@
import { XOR } from "ts-xor"; import { t } from "elysia";
import { combine, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus"; import Bus, { type Bus as TBus } from "kefir-bus";
import { merge, Observable, Property, Stream } from "kefir"; import {
import { Game } from "@prisma/client"; newGame,
resolveAction,
SimpleAction,
SimpleGameState,
} from "./games/simple";
import { transform } from "./kefir-extension";
type TableInputEvent<GameAction> = { humanKey: string } & XOR< export const WebsocketIncomingMessage = t.Union([
{ presence: "joined" | "left" }, t.Object({ proposeGame: t.String() }),
{ proposeGame: string }, t.Object({ startGame: t.Literal(true) }),
{ startGame: true }, t.Object({ action: t.Any() }),
{ action: GameAction } ]);
>;
type InMemoryTable<GameState> = { type TablePayload<GameState, GameAction> = {
playersPresent: string[]; inputs: {
gameState: GameState | null; presenceChanges: TBus<
}; { humanKey: string; presence: "joined" | "left" },
never
type TableOutputEvents<GameState> = { >;
gameProposals: TBus<{ proposeGame: string }, never>;
gameStarts: TBus<{ startGame: true }, never>;
gameActions: TBus<GameAction, never>;
};
outputs: {
playersPresent: Property<string[], never>; playersPresent: Property<string[], never>;
gameState: Property<GameState | null, never>; gameState: Property<GameState | null, never>;
}; };
type TablePayload<GameAction, GameState> = {
input: TBus<TableInputEvent<GameAction>, never>;
outputs: TableOutputEvents<GameState>;
}; };
const tables: { const tables: {
@@ -31,26 +37,55 @@ const tables: {
export const liveTable = <GameState, GameAction>(key: string) => { export const liveTable = <GameState, GameAction>(key: string) => {
if (!(key in tables)) { if (!(key in tables)) {
const inputEvents = Bus<TableInputEvent<GameAction>, never>(); const inputs: TablePayload<GameState, GameAction>["inputs"] = {
presenceChanges: Bus(),
gameProposals: Bus(),
gameStarts: Bus(),
gameActions: Bus(),
};
const { presenceChanges, gameProposals, gameStarts, gameActions } =
inputs;
tables[key] = { // =======
input: inputEvents, const playersPresent = presenceChanges.scan((prev, evt) => {
outputs: {
playersPresent: inputEvents
.filter((evt) => Boolean(evt.presence))
.scan((prev, evt) => {
if (evt.presence == "joined") { if (evt.presence == "joined") {
prev.push(evt.humanKey); prev.push(evt.humanKey);
} else if (evt.presence == "left") { } else if (evt.presence == "left") {
prev.splice(prev.indexOf(evt.humanKey), 1); prev.splice(prev.indexOf(evt.humanKey), 1);
} }
return prev; return prev;
}, [] as string[]), }, [] as string[]);
gameState: inputEvents
.filter((evt) => Boolean(evt.startGame || evt.action)) const gameState = transform(
.scan((prev, evt) => { null as GameState | null,
return prev; [
}, 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>,
}, },
}; };
@@ -62,5 +97,5 @@ export const liveTable = <GameState, GameAction>(key: string) => {
delete tables[key]; delete tables[key];
}); });
} }
return tables[key] as TablePayload<GameAction, GameState>; return tables[key] as TablePayload<GameState, GameAction>;
}; };