Compare commits

..

5 Commits

Author SHA1 Message Date
0f015841ff when in doubt make it a property I guess 2025-08-25 22:37:42 -04:00
6c45e7b114 bug fixes 2025-08-25 18:36:59 -04:00
a117f6703f lots of necessary plumbing 2025-08-24 22:04:29 -04:00
4bcf071668 fix multi tab presence 2025-08-24 15:09:58 -04:00
6c64886f2a super minor 2025-08-24 14:03:55 -04:00
14 changed files with 331 additions and 168 deletions

View File

@@ -26,9 +26,10 @@ const Profile = () => {
Name:{" "}
<input
value={profile()?.data?.name ?? ""}
onChange={(e) =>
api.setName.post({ name: e.target.value })
}
onChange={(e) => {
dialogRef.close();
void api.setName.post({ name: e.target.value });
}}
class="bg-emerald-200 border-1.5 rounded-full px-4"
/>
</div>

View File

@@ -2,11 +2,11 @@ import { Accessor, createContext, useContext } from "solid-js";
import {
SimpleAction,
SimplePlayerView,
vSimpleGameState,
} from "../../../server/src/games/simple";
import { me, profile } from "../profile";
import Hand from "./Hand";
import Pile from "./Pile";
import { TableContext } from "./Table";
import Hand from "./Hand";
export const GameContext = createContext<{
view: Accessor<SimplePlayerView>;
@@ -31,6 +31,15 @@ export default () => {
hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })}
/>
<div class="absolute tc text-align-center">
It's{" "}
<span class="font-bold">
{view().playerTurn == me()
? "your"
: profile(view().playerTurn)()?.name + "'s"}
</span>{" "}
turn
</div>
</GameContext.Provider>
);
};

View File

@@ -1,8 +1,6 @@
import { Component, For, useContext } from "solid-js";
import { Component, For } from "solid-js";
import type { Card as TCard, Hand as THand } from "../../../shared/cards";
import Card from "./Card";
import type { Hand as THand, Card as TCard } from "../../../shared/cards";
import { GameContext } from "./Game";
import { produce } from "solid-js/store";
import { Stylable } from "./toolbox";
export default ((props) => {

View File

@@ -1,12 +1,28 @@
import { createSignal, useContext } from "solid-js";
import { playerColor, profile } from "../profile";
import { TableContext } from "./Table";
import { Stylable } from "./toolbox";
import { createObservable, createObservableWithInit } from "../fn";
export default (props: { playerKey: string } & Stylable) => {
const table = useContext(TableContext);
const playerReady =
table?.wsEvents
.filter((evt) => evt.playersReady != null)
.map((evt) => evt.playersReady![props.playerKey])
.thru((Evt) => createObservableWithInit(Evt, false)) ??
createSignal(false)[0];
return (
<div
style={{
...props.style,
"background-color": playerColor(props.playerKey),
...(playerReady() && table?.view() == null
? {
border: "10px solid green",
}
: {}),
}}
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
>

View File

@@ -2,35 +2,42 @@ import {
Accessor,
createContext,
createEffect,
createResource,
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, WSEvent } from "../fn";
import { EdenWS } from "@elysiajs/eden/treaty";
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 Player from "./Player";
import { fromPromise, Stream } from "kefir";
export const TableContext = createContext<{
players: Accessor<string[]>;
view: Accessor<any>;
sendWs: (msg: TWsIn) => void;
wsEvents: Stream<TWsOut, any>;
}>();
export default (props: { tableKey: string }) => {
const ws = api.ws(props).subscribe();
const wsEvents = fromWebsocket<TWsOut>(ws);
onCleanup(() => ws.close());
const wsPromise = new Promise<
ReturnType<ReturnType<typeof api.ws>["subscribe"]>
>((res) => {
const ws = api.ws(props).subscribe();
ws.on("open", () => res(ws));
ws.on("error", () => res(ws));
});
const sendWs = (msg: TWsIn) => wsPromise.then((ws) => ws.send(msg));
const wsEvents = fromPromise(wsPromise).flatMap((ws) =>
fromWebsocket<TWsOut>(ws)
);
onCleanup(() => wsPromise.then((ws) => ws.close()));
const presenceEvents = wsEvents.filter((evt) => evt.players != null);
const gameEvents = wsEvents.filter((evt) => evt.view != null);
const players = createObservableWithInit<string[]>(
@@ -38,14 +45,16 @@ export default (props: { tableKey: string }) => {
[]
);
const [ready, setReady] = createSignal(false);
createEffect(() => sendWs({ ready: ready() }));
const view = createObservable(gameEvents.map((evt) => evt.view));
return (
<TableContext.Provider
value={{
sendWs: (evt) => ws.send(evt),
sendWs,
wsEvents,
view,
players,
}}
>
<div class="flex justify-around p-t-10">
@@ -72,18 +81,33 @@ export default (props: { tableKey: string }) => {
</div>
<div
id="table"
class="fixed bg-radial from-orange-950 to-stone-950 border-neutral-950 border-4 top-40 bottom-20 left-10 right-10 shadow-lg"
class={cx(
"fixed",
"bg-radial",
"from-orange-950",
"to-stone-950",
"border-4",
"border-neutral-950",
"shadow-lg",
"top-40",
"bottom-20",
"left-10",
"right-10"
)}
style={{
"border-radius": "50% 50%",
"border-radius": "50%",
}}
>
<Show when={view() == null}>
<div class="absolute center">
<button
onClick={() => ws.send({ startGame: true })}
onClick={() => setReady((prev) => !prev)}
class="button p-1 "
>
Start Game!
{ready() ? "Not Ready" : "Ready"}
</button>
</div>
</Show>

View File

@@ -35,3 +35,5 @@ export const createObservableWithInit = <T>(
return signal;
};
export const cx = (...classes: string[]) => classes.join(" ");

View File

@@ -1,16 +1,18 @@
import { A, useParams } from "@solidjs/router";
import Table from "../components/Table";
import { Show } from "solid-js";
import { me } from "../profile";
export default () => {
const { tableKey } = useParams();
return (
<>
<Show when={me() != null}>
<Table tableKey={tableKey} />
<A href={"/"} class="fixed tl m-4 px-2 py-1.5 button">
Back
</A>
</>
</Show>
);
};

View File

@@ -22,11 +22,13 @@
"elysia-rate-limit": "^4.4.0",
"kefir": "^3.8.8",
"kefir-bus": "^2.3.1",
"lodash": "^4.17.21",
"object-hash": "^3.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/kefir": "^3.8.11",
"@types/lodash": "^4.17.20",
"concurrently": "^9.2.0",
"prisma": "6.13.0",
"ts-xor": "^1.3.0"

View File

@@ -1,15 +1,17 @@
import { Elysia, t } from "elysia";
import {
getSimplePlayerView,
SimpleAction,
SimpleConfiguration,
SimpleGameState,
getKnowledge,
getView,
} from "./games/simple";
import { human } from "./human";
import dayjs from "dayjs";
import db from "./db";
import { liveTable, WsOut, WsIn } from "./table";
import { Human } from "@prisma/client";
import _ from "lodash";
import { combine } from "kefir";
const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => {
@@ -72,19 +74,35 @@ const api = new Elysia({ prefix: "/api" })
},
send,
}) {
const table = liveTable<SimpleGameState, SimpleAction>(tableKey);
const table = liveTable<
SimpleConfiguration,
SimpleGameState,
SimpleAction
>(tableKey);
table.inputs.connectionChanges.emit({
humanKey,
presence: "joined",
});
table.outputs.playersPresent.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 })
);
table.inputs.presenceChanges.emit({ humanKey, presence: "joined" });
combine(
[table.outputs.gameState],
[table.outputs.gameConfig],
(state, config) =>
state &&
config &&
getSimplePlayerView(config, state, humanKey)
)
.toProperty()
.onValue((view) => send({ view }));
},
response: WsOut,
@@ -100,15 +118,15 @@ const api = new Elysia({ prefix: "/api" })
body
) {
const {
inputs: { gameProposals, gameStarts, gameActions },
inputs: { readys, actions, quits },
} = liveTable(tableKey);
if ("proposeGame" in body) {
gameProposals.emit(body);
} else if ("startGame" in body) {
gameStarts.emit(body);
if ("ready" in body) {
readys.emit({ humanKey, ...body });
} else if ("action" in body) {
gameActions.emit({ humanKey, ...body.action });
actions.emit({ humanKey, ...body.action });
} else if ("quit" in body) {
quits.emit({ humanKey });
}
},
@@ -118,7 +136,7 @@ const api = new Elysia({ prefix: "/api" })
humanKey,
},
}) {
liveTable(tableKey).inputs.presenceChanges.emit({
liveTable(tableKey).inputs.connectionChanges.emit({
humanKey,
presence: "left",
});

View File

@@ -1,104 +1,103 @@
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
import { heq } from "@games/shared/utils";
// omniscient game state
export type SimpleGameState = {
prev?: {
action: SimpleAction;
};
deck: Pile;
players: { [humanId: string]: Hand };
export type SimpleConfiguration = {
game: "simple";
players: string[];
};
// a particular player's knowledge of the global game state
export type vSimpleGameState = {
humanId: string;
deck: Pile<vCard>;
players: { [humanId: string]: Hand<vCard> };
// omniscient game state
export type SimpleGameState = {
deck: Pile;
turnIdx: number;
playerHands: { [humanKey: string]: Hand };
};
// a particular player's point of view in the game
export type SimplePlayerView = {
humanId: string;
deckCount: number;
playerHandCounts: { [humanId: string]: number };
playerTurn: string;
playerHandCounts: { [humanKey: string]: number };
myHand: Hand<Card>;
};
export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
export const newGame = (players: string[]) => {
console.log("new game called with", JSON.stringify(players));
export const newSimpleGameState = (
config: SimpleConfiguration
): SimpleGameState => {
const { players } = config;
return {
deck: shuffle(newDeck()),
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
} as SimpleGameState;
turnIdx: 0,
playerHands: Object.fromEntries(
players.map((humanKey) => [humanKey, []])
),
};
};
export const getKnowledge = (
export const getSimplePlayerView = (
config: SimpleConfiguration,
state: SimpleGameState,
humanId: 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
humanKey: string
): SimplePlayerView => ({
humanId,
deckCount: state.deck.length,
myHand: state.players[humanId] as Hand,
playerTurn: config.players[state.turnIdx],
myHand: state.playerHands[humanKey] as Hand,
playerHandCounts: Object.fromEntries(
Object.entries(state.players)
.filter(([id]) => id != humanId)
Object.entries(state.playerHands)
.filter(([id]) => id != humanKey)
.map(([id, hand]) => [id, hand.length])
),
});
export const resolveAction = (
state: SimpleGameState,
humanId: string,
action: SimpleAction
): SimpleGameState => {
console.log("attempting to resolve action", JSON.stringify(action));
if (!(humanId in state.players)) {
throw Error(
`${humanId} is not a player in this game; they cannot perform actions`
export const resolveSimpleAction = ({
config,
state,
humanKey,
action,
}: {
config: SimpleConfiguration;
state: SimpleGameState;
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 (action.type == "draw") {
const [drawn, ...rest] = state.deck;
console.log("drew card", JSON.stringify(drawn));
return {
deck: rest,
players: {
...state.players,
[humanId]: [drawn, ...playerHand],
},
};
if (humanKey != config.players[state.turnIdx]) {
throw new Error(`It's not ${humanKey}'s turn!`);
}
// action.type == discard
const index = playerHand.findIndex(heq(action.card));
return {
deck: [action.card, ...state.deck],
players: {
...state.players,
[humanId]: playerHand
.slice(0, index)
.concat(playerHand.slice(index + 1)),
},
};
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
const cardIndex = playerHand.findIndex(heq(action.card));
return {
deck: [action.card, ...state.deck],
playerHands: {
...state.playerHands,
[humanKey]: playerHand
.slice(0, cardIndex)
.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 { staticPlugin } from "@elysiajs/static";
import { error } from "node:console";
import { Elysia, env } from "elysia";
import api from "./api";
const port = env.PORT || 5001;
@@ -12,9 +11,9 @@ const app = new Elysia()
origin: ["http://localhost:3000", "https://games.drm.dev"],
})
)
.onRequest(({ request }) => {
console.log(request.method, request.url);
})
// .onRequest(({ request }) => {
// console.log(request.method, request.url);
// })
.onError(({ error }) => {
console.error(error);
return error;

View File

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

View File

@@ -1,104 +1,186 @@
import { t } from "elysia";
import { combine, Property } from "kefir";
import { combine, pool, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
import {
newGame,
resolveAction,
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({ proposeGame: t.String() }),
t.Object({ startGame: t.Literal(true) }),
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<GameState, GameAction> = {
type TablePayload<GameConfig, GameState, GameAction> = {
inputs: {
presenceChanges: TBus<
connectionChanges: TBus<
Attributed & { presence: "joined" | "left" },
never
>;
gameProposals: TBus<{ proposeGame: string }, never>;
gameStarts: TBus<{ startGame: true }, never>;
gameActions: TBus<Attributed & GameAction, 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>;
[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)) {
const inputs: TablePayload<GameState, GameAction>["inputs"] = {
presenceChanges: Bus(),
gameProposals: Bus(),
gameStarts: Bus(),
gameActions: Bus(),
const inputs: TablePayload<
GameConfig,
GameState,
GameAction
>["inputs"] = {
connectionChanges: Bus(),
readys: Bus(),
actions: Bus(),
quits: Bus(),
};
const { presenceChanges, gameProposals, gameStarts, gameActions } =
inputs;
const { connectionChanges, readys, actions, quits } = 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 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))
.toProperty();
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,
]
)
.toProperty()
.log("playersReady");
const gameStarts = playersReady
.filter(
(pr) =>
Object.values(pr).length > 0 &&
Object.values(pr).every((ready) => ready)
)
.map((_) => null)
.log("gameStarts");
const gameConfigPool = pool<
{
game: string;
players: string[];
},
never
>();
const gameConfig = gameConfigPool.toProperty();
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,
combine([gameStarts], [gameConfigPool], (_, config) => config),
(prev, startConfig: SimpleConfiguration) =>
prev == null ? newSimpleGameState(startConfig) : prev,
],
[
gameActions,
(prev, evt: Attributed & SimpleAction) =>
combine([actions], [gameConfigPool], (action, config) => ({
action,
config,
})),
(
prev,
evt: {
action: Attributed & SimpleAction;
config: SimpleConfiguration;
}
) =>
prev != null
? resolveAction(prev, evt.humanKey, evt)
? resolveSimpleAction({
config: evt.config,
state: prev,
action: evt.action,
humanKey: evt.action.humanKey,
})
: prev,
]
).toProperty();
const gameIsActive = gameState
.map((gs) => gs != null)
.skipDuplicates()
.toProperty()
.log("gameIsActive");
gameConfigPool.plug(
playersPresent
.filterBy(gameIsActive.map((active) => !active))
.map((players) => ({
game: "simple",
players,
}))
);
tables[key] = {
inputs,
outputs: {
playersPresent,
playersReady,
gameConfig: gameConfig as Property<unknown, never>,
gameState: gameState as Property<unknown, never>,
},
};
// cleanup
tables[key].outputs.playersPresent
.slidingWindow(2)
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0)
.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<GameState, GameAction>;
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
};

11
pnpm-lock.yaml generated
View File

@@ -99,6 +99,9 @@ importers:
kefir-bus:
specifier: ^2.3.1
version: 2.3.1(kefir@3.8.8)
lodash:
specifier: ^4.17.21
version: 4.17.21
object-hash:
specifier: ^3.0.0
version: 3.0.0
@@ -109,6 +112,9 @@ importers:
'@types/kefir':
specifier: ^3.8.11
version: 3.8.11
'@types/lodash':
specifier: ^4.17.20
version: 4.17.20
concurrently:
specifier: ^9.2.0
version: 9.2.0
@@ -474,6 +480,9 @@ packages:
'@types/kefir@3.8.11':
resolution: {integrity: sha512-5TRdFXQYsVUvqIH6nYjslHzBgn4hnptcutXnqAhfbKdWD/799c44hFhQGF3887E2t/Q4jSp3RvNFCaQ+b9w6vQ==}
'@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
'@types/node@24.2.0':
resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==}
@@ -1640,6 +1649,8 @@ snapshots:
dependencies:
'@types/node': 24.2.0
'@types/lodash@4.17.20': {}
'@types/node@24.2.0':
dependencies:
undici-types: 7.10.0