Compare commits

..

7 Commits

Author SHA1 Message Date
11f21221ee we did it 2025-08-30 22:30:31 -04:00
01a12ec58a [wip] so close; check the ws messages 2025-08-30 18:24:08 -04:00
782dd738cc [wip] im tired boss 2025-08-30 15:49:55 -04:00
5e33e33cce [wip] kefir cleanup 2025-08-29 23:50:23 -04:00
90be478e9a css cleanup 2025-08-29 21:57:03 -04:00
f38a5a69df package -> pkg 2025-08-29 20:54:18 -04:00
0d6d3d6d32 starting to abstract the game 2025-08-28 22:20:24 -04:00
68 changed files with 2804 additions and 672 deletions

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -1,7 +1,7 @@
{ {
"name": "games", "name": "games",
"type": "module", "type": "module",
"version": "0.0.5", "version": "0.0.6",
"scripts": { "scripts": {
"dev": "pnpm --parallel dev", "dev": "pnpm --parallel dev",
"build": "pnpm run -F client build", "build": "pnpm run -F client build",

View File

@@ -1,17 +0,0 @@
import { A } from "@solidjs/router";
import { createEffect, createResource, For } from "solid-js";
import api from "~/api";
export default () => {
const [games] = createResource(async () =>
api.games.get().then((res) => res.data)
);
return (
<div style={{ padding: "20px" }}>
<For each={games()}>
{(game) => <A href={`/${game.key}`}>{game.name}</A>}
</For>
</div>
);
};

View File

@@ -1,61 +0,0 @@
html {
height: 100vh;
}
body {
margin: 0;
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande",
"Lucida Sans", Arial, sans-serif;
color: white;
height: 100%;
}
body::before {
z-index: -1;
content: "";
position: absolute;
width: 100%;
height: 100%;
}
/* a {
color: rgb(18, 229, 113);
}
a:visited {
color: rgb(23, 138, 125);
} */
.button {
cursor: pointer;
background-color: white;
color: black;
box-shadow: 0px 5px 10px black;
transition: background-color 0.15s, color 0.15s, transform 0.15s;
}
.button:hover {
background-color: rgb(23, 138, 125);
color: white;
transform: scale(1.1);
}
#app {
height: 100%;
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
}
.hand {
height: 160px;
background: radial-gradient(rgb(24, 70, 82), rgb(1, 42, 41));
min-width: 100px;
width: fit-content;
max-width: 90%;
border: 2px dashed white;
border-radius: 12px;
margin-bottom: 50px;
padding: 10px;
overflow: scroll;
scrollbar-width: none;
display: flex;
gap: 5px;
}

View File

@@ -1,10 +0,0 @@
import * as renaissance from "./renaissance";
import * as simple from "./simple";
const games = {
renaissance,
simple,
};
export default games;
export type Game = keyof typeof games;

View File

@@ -1,13 +0,0 @@
import Elysia from "elysia";
import db from "./db";
export const human = new Elysia({ name: "human" })
.derive(async ({ cookie: { token }, status }) => {
const humanKey = await db.human
.findUnique({
where: { token: token.value },
})
.then((human) => human?.key);
return humanKey != null ? { humanKey } : status(401);
})
.as("scoped");

View File

@@ -1,33 +0,0 @@
import { cors } from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
import { Elysia, env } from "elysia";
import api from "./api";
const port = env.PORT || 5001;
const app = new Elysia()
.use(
cors({
origin: ["http://localhost:3000", "https://games.drm.dev"],
})
)
// .onRequest(({ request }) => {
// console.log(request.method, request.url);
// })
.onError(({ error }) => {
console.error(error);
return error;
})
.get("/ping", () => "pong")
.use(api)
.get("/*", () => Bun.file("./public/index.html"))
.use(
staticPlugin({
assets: "public",
prefix: "/",
alwaysStatic: true,
})
)
.listen(port);
console.log("server started on", port);

View File

@@ -1,29 +0,0 @@
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, unknown> =>
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,202 +0,0 @@
import { t } from "elysia";
import { combine, pool, 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.Nullable(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 }, any>;
actions: TBus<Attributed & GameAction, any>;
quits: TBus<Attributed, any>;
};
outputs: {
playersPresent: Property<string[], any>;
playersReady: Property<{ [key: string]: boolean } | null, any>;
gameConfig: Property<GameConfig | null, any>;
gameState: Property<GameState | null, any>;
};
};
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;
quits.log("quits");
// =======
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 gameEnds = quits.map((_) => null);
const gameStarts = pool<null, any>();
const playersReady = transform(
null as { [key: string]: boolean } | null,
[
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,
],
[gameStarts, () => null],
[
combine([gameEnds], [playersPresent], (_, players) => players),
(_, players: string[]) =>
Object.fromEntries(players.map((p) => [p, false])),
]
)
.toProperty()
.log("playersReady");
gameStarts.plug(
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], [gameConfigPool], (_, config) => config),
(prev, startConfig: SimpleConfiguration) =>
prev == null ? newSimpleGameState(startConfig) : prev,
],
[
combine([actions], [gameConfigPool], (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,
],
[quits, () => null]
).toProperty();
gameState
.map((state) => JSON.stringify(state).substring(0, 10))
.log("gameState");
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: playersReady.toProperty(),
gameConfig: gameConfig as Property<unknown, any>,
gameState: gameState as Property<unknown, any>,
},
};
// 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>;
};

View File

@@ -1,7 +0,0 @@
{
"name": "@games/shared",
"version": "1.0.0",
"dependencies": {
"object-hash": "^3.0.0"
}
}

View File

@@ -1,6 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"esModuleInterop": true
}
}

View File

@@ -5,7 +5,5 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<script type="module" src="/src/app.tsx"></script> <script type="module" src="/src/app.tsx"></script>
</head> </head>
<body> <body />
<div id="app" />
</body>
</html> </html>

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -44,8 +44,6 @@ const App = () => {
<> <>
<Suspense>{props.children}</Suspense> <Suspense>{props.children}</Suspense>
<Profile /> <Profile />
{/* Version */}
<span class="fixed br m-2 font-mono text-xs"> <span class="fixed br m-2 font-mono text-xs">
{"v" + pkg.version} {"v" + pkg.version}
</span> </span>
@@ -54,11 +52,11 @@ const App = () => {
> >
<Route path="/" component={lazy(() => import("./routes/index"))} /> <Route path="/" component={lazy(() => import("./routes/index"))} />
<Route <Route
path="/:tableKey" path="/t/:tableKey"
component={lazy(() => import("./routes/[table]"))} component={lazy(() => import("./routes/[table]"))}
/> />
</Router> </Router>
); );
}; };
render(App, document.getElementById("app")!); render(App, document.getElementsByTagName("body")[0]);

View File

@@ -2,7 +2,7 @@ import { Accessor, createContext, For, useContext } from "solid-js";
import type { import type {
SimpleAction, SimpleAction,
SimplePlayerView, SimplePlayerView,
} from "@games/server/src/games/simple"; } from "@games/shared/games/simple";
import { me, profile } from "~/profile"; import { me, profile } from "~/profile";
import Hand from "./Hand"; import Hand from "./Hand";
import Pile from "./Pile"; import Pile from "./Pile";
@@ -44,7 +44,9 @@ export default () => {
</div> </div>
<button <button
class="button fixed tl m-4 p-1" class="button fixed tl m-4 p-1"
onClick={() => table.sendWs({ quit: true })} onClick={() => {
table.sendWs({ quit: true });
}}
> >
Quit Quit
</button> </button>
@@ -54,10 +56,7 @@ export default () => {
mount={document.getElementById(`player-${playerKey}`)!} mount={document.getElementById(`player-${playerKey}`)!}
ref={(ref) => { ref={(ref) => {
const midOffset = const midOffset =
i() + i() + 0.5 - Object.values(view().playerHandCounts).length / 2;
0.5 -
Object.values(view().playerHandCounts).length /
2;
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%; transform: translate(${Math.abs( ref.style = `position: absolute; display: flex; justify-content: center; top: 65%; transform: translate(${Math.abs(
midOffset * 0 midOffset * 0

View File

@@ -0,0 +1,17 @@
.hand {
height: 160px;
background: radial-gradient(var(--sweet-green), var(--dark-green));
min-width: 100px;
width: fit-content;
max-width: 90%;
border: 2px dashed var(--light);
border-radius: 12px;
margin-bottom: 50px;
padding: 10px;
overflow: scroll;
scrollbar-width: none;
display: flex;
gap: 5px;
}

View File

@@ -3,6 +3,8 @@ import type { Card as TCard, Hand as THand } from "@games/shared/cards";
import Card from "./Card"; import Card from "./Card";
import { Stylable } from "./toolbox"; import { Stylable } from "./toolbox";
import "./Hand.css";
export default ((props) => { export default ((props) => {
return ( return (
<div class={"hand " + props.class} style={props.style}> <div class={"hand " + props.class} style={props.style}>

View File

@@ -14,6 +14,7 @@ import { createObservable, createObservableWithInit, cx } from "~/fn";
import { me, mePromise } from "~/profile"; import { me, mePromise } from "~/profile";
import Game from "./Game"; import Game from "./Game";
import Player from "./Player"; import Player from "./Player";
import games from "@games/shared/games/index";
export const TableContext = createContext<{ export const TableContext = createContext<{
view: Accessor<any>; view: Accessor<any>;
@@ -36,11 +37,11 @@ export default (props: { tableKey: string }) => {
); );
onCleanup(() => wsPromise.then((ws) => ws.close())); onCleanup(() => wsPromise.then((ws) => ws.close()));
const presenceEvents = wsEvents.filter((evt) => evt.players != null); const presenceEvents = wsEvents.filter((evt) => evt.playersPresent != null);
const gameEvents = wsEvents.filter((evt) => evt.view !== undefined); const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
const players = createObservableWithInit<string[]>( const players = createObservableWithInit<string[]>(
presenceEvents.map((evt) => evt.players!), presenceEvents.map((evt) => evt.playersPresent!),
[] []
); );
@@ -78,9 +79,7 @@ export default (props: { tableKey: string }) => {
<Player <Player
playerKey={player} playerKey={player}
style={{ style={{
transform: `translate(0, ${ transform: `translate(0, ${verticalOffset() * 150}vh)`,
verticalOffset() * 150
}vh)`,
}} }}
/> />
); );
@@ -110,7 +109,12 @@ export default (props: { tableKey: string }) => {
}} }}
> >
<Show when={view() == null}> <Show when={view() == null}>
<div class="absolute center"> <div class="absolute tc mt-8 flex gap-4">
<select>
<For each={Object.entries(games)}>
{([gameId, game]) => <option value={gameId}>{gameId}</option>}
</For>
</select>
<button <button
onClick={() => setReady((prev) => !prev)} onClick={() => setReady((prev) => !prev)}
class="button p-1 " class="button p-1 "

View File

@@ -0,0 +1,40 @@
import { A } from "@solidjs/router";
export default () => {
const randomTablePath = `/t/abcd`;
return (
<div class="flex flex-col absolute center">
<h1>Welcome to games.drm.dev!</h1>
<p>
This website is a real-time multiplayer platform for playing
card games online.
</p>
<br />
<p>
Games happen at <strong>tables</strong>. A table is any url of
the form{" "}
<span class="font-mono text-[var(--light-purple)]">
games.drm.dev/t/
<span class="text-[var(--yellow)]">*</span>
</span>
</p>
<br />
<p>
Go to the same one as your friend and you will find them there!
</p>
<br />
<p>
If you have a table key in mind (the part after /t/), then plug
it in to your URL bar! Or, here's a couple links to random
tables:
</p>
<br />
<p>
With no one in it:{" "}
<A href={randomTablePath}>
https://www.games.drm.dev{randomTablePath}
</A>
</p>
</div>
);
};

47
pkg/client/src/style.css Normal file
View File

@@ -0,0 +1,47 @@
body {
--purple: rgb(138, 156, 255);
--light-purple: rgb(200, 150, 223);
--light: seashell;
--dark: #0a180e;
--green: rgb(24, 82, 65);
--dark-green: rgb(1, 42, 16);
--sweet-green: rgb(23, 138, 125);
--yellow: rgb(252, 220, 103);
height: 100vh;
color: var(--light);
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
font-family: "Trebuchet MS";
}
h1 {
font-size: 48px;
font-family: Garamond;
}
p {
font-size: 24px;
font-family: Garamond;
}
a {
font-family: monospace;
color: var(--purple);
}
a:visited {
color: var(--light-purple);
}
.button {
cursor: pointer;
background-color: var(--light);
color: var(--dark);
box-shadow: 0px 5px 10px var(--dark);
transition: background-color 0.15s, color 0.15s, transform 0.15s;
}
.button:hover {
background-color: var(--sweet-green);
color: var(--light);
transform: scale(1.1);
}
strong {
color: var(--yellow);
}

View File

@@ -14,9 +14,9 @@
"isolatedModules": true, "isolatedModules": true,
"paths": { "paths": {
"^/*": ["./*"], "^/*": ["./*"],
"@games/*": ["./packages/*"], "@games/*": ["./pkg/*"],
"$/*": ["./packages/client/*"], "$/*": ["./pkg/client/*"],
"~/*": ["./packages/client/src/*"] "~/*": ["./pkg/client/src/*"]
} }
} }
} }

View File

@@ -25,20 +25,16 @@ export default defineConfig({
"bc", "bc",
{ {
bottom: 0, bottom: 0,
left: 0, left: "50%",
right: 0, transform: "translateX(-50%)",
"margin-left": "auto",
"margin-right": "auto",
}, },
], ],
[ [
"tc", "tc",
{ {
top: 0, top: 0,
left: 0, left: "50%",
right: 0, transform: "translateX(-50%)",
"margin-left": "auto",
"margin-right": "auto",
}, },
], ],

View File

@@ -1,16 +1,21 @@
import { Elysia, t } from "elysia"; import { Game } from "@games/shared/games";
import {
getSimplePlayerView,
SimpleAction,
SimpleConfiguration,
SimpleGameState,
} 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 { Human } from "@prisma/client";
import dayjs from "dayjs";
import { Elysia, t } from "elysia";
import { combine } from "kefir"; import { combine } from "kefir";
import Bus from "kefir-bus";
import db from "./db";
import { liveTable, WsIn, WsOut } from "./table";
import { err } from "./logging";
export const WS = Bus<
{
type: "open" | "message" | "error" | "close";
humanKey: string;
tableKey: string;
},
unknown
>();
const api = new Elysia({ prefix: "/api" }) const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => { .post("/whoami", async ({ cookie: { token } }) => {
@@ -35,7 +40,14 @@ const api = new Elysia({ prefix: "/api" })
return human.key; return human.key;
}) })
.use(human) .derive(async ({ cookie: { token }, status }) => {
const humanKey = await db.human
.findUnique({
where: { token: token.value },
})
.then((human) => human?.key);
return humanKey != null ? { humanKey } : status(401);
})
.post( .post(
"/setName", "/setName",
({ body: { name }, humanKey }) => ({ body: { name }, humanKey }) =>
@@ -64,49 +76,32 @@ const api = new Elysia({ prefix: "/api" })
return safeProfile; return safeProfile;
}) })
) )
.get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/ws/:tableKey", { .ws("/ws/:tableKey", {
async open({ body: WsIn,
response: WsOut,
open({
data: { data: {
params: { tableKey }, params: { tableKey },
humanKey, humanKey,
}, },
send, send,
}) { }) {
const table = liveTable< const table = liveTable(tableKey);
SimpleConfiguration,
SimpleGameState,
SimpleAction
>(tableKey);
table.inputs.connectionChanges.emit({ table.inputs.connectionChanges.emit({
humanKey, humanKey,
presence: "joined", presence: "joined",
}); });
table.outputs.playersPresent.onValue((players) => Object.entries({
send({ players }) ...table.outputs.global,
...(table.outputs.player[humanKey] ?? {}),
}).forEach(([type, stream]) =>
stream.onValue((v) => send({ [type]: v }))
); );
table.outputs.playersReady
.skipDuplicates()
.onValue((readys) => send({ playersReady: readys }));
combine(
[table.outputs.gameState],
[table.outputs.gameConfig],
(state, config) =>
state &&
config &&
getSimplePlayerView(config, state, humanKey)
)
.toProperty()
.onValue((view) => send({ view }));
}, },
response: WsOut,
body: WsIn,
message( message(
{ {
data: { data: {
@@ -116,20 +111,10 @@ const api = new Elysia({ prefix: "/api" })
}, },
body body
) { ) {
const { liveTable(tableKey).inputs.messages.emit({ ...body, humanKey });
inputs: { readys, actions, quits },
} = liveTable(tableKey);
if ("ready" in body) {
readys.emit({ humanKey, ...body });
} else if ("action" in body) {
actions.emit({ humanKey, ...body.action });
} else if ("quit" in body) {
quits.emit({ humanKey });
}
}, },
async close({ close({
data: { data: {
params: { tableKey }, params: { tableKey },
humanKey, humanKey,
@@ -140,6 +125,10 @@ const api = new Elysia({ prefix: "/api" })
presence: "left", presence: "left",
}); });
}, },
error(error) {
err(error);
},
}); });
export default api; export default api;

27
pkg/server/src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { cors } from "@elysiajs/cors";
import { Elysia, env } from "elysia";
import api from "./api";
import staticFiles from "./static";
import * as log from "./logging";
const port = env.PORT || 5001;
new Elysia()
.use(
cors({
origin: [
"http://localhost:3000", // dev
"https://games.drm.dev", // prod
],
})
)
.onError(({ error }) => console.error(error))
.get("/ping", () => "pong")
.use(api)
.use(staticFiles)
.listen(port);
console.log(`server started on ${port}`);

16
pkg/server/src/logging.ts Normal file
View File

@@ -0,0 +1,16 @@
import { combine, pool } from "kefir";
import Bus from "kefir-bus";
export const LogPool = pool();
const LogBus = Bus();
const LogStream = combine([LogPool, LogBus]);
export const log = (value: unknown) => LogBus.emit(value);
export const err = (value: unknown) =>
LogBus.emitEvent({ type: "error", value });
LogStream.log();
LogStream.onError((err) => {
console.error(err);
});

12
pkg/server/src/static.ts Normal file
View File

@@ -0,0 +1,12 @@
import staticPlugin from "@elysiajs/static";
import Elysia from "elysia";
export default new Elysia()
.get("/*", () => Bun.file("./public/index.html"))
.use(
staticPlugin({
assets: "public",
prefix: "/",
alwaysStatic: true,
})
);

254
pkg/server/src/table.ts Normal file
View File

@@ -0,0 +1,254 @@
import GAMES, { Game, GameKey } from "@games/shared/games";
import {
isEmpty,
multiScan,
partition,
set,
setDiff,
ValueWithin,
} from "@games/shared/kefirs";
import { t } from "elysia";
import { combine, Observable, pool, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
import { log } from "./logging";
export const WsOut = t.Object({
playersPresent: t.Optional(t.Array(t.String())),
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))),
gameConfig: t.Optional(t.Any()),
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 = unknown,
GameView = unknown,
GameAction = unknown
> = {
inputs: {
connectionChanges: TBus<
Attributed & {
presence: "joined" | "left";
},
never
>;
messages: TBus<Attributed & TWsIn, any>;
};
outputs: {
global: {
playersPresent: Property<string[], any>;
playersReady: Property<
{
[key: string]: boolean;
} | null,
any
>;
gameConfig: Property<GameConfig | null, any>;
};
player: {
[key: string]: {
view: Property<GameView | null, any>;
};
};
};
};
const tables: {
[key: string]: TablePayload;
} = {};
export const liveTable = <
GameConfig extends {
game: GameKey;
players: string[];
},
GameState,
GameAction extends Attributed,
GameView
>(
key: string
) => {
if (!(key in tables)) {
const inputs: TablePayload<GameConfig, GameState, GameAction>["inputs"] = {
connectionChanges: Bus(),
messages: Bus(),
};
const { connectionChanges, messages } = inputs;
// =======
// players who have at least one connection to the room
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 playerStreams: {
[key: string]: { view: Property<GameView | null, any> };
} = {};
playersPresent
.map(set)
.slidingWindow(2, 2)
.map(([prev, cur]) => setDiff([prev, cur]))
.onValue(({ added, removed }) => {
added.forEach((p) => {
playerStreams[p] = {
view: combine([gameState], [gameImpl], (a, b) => [a, b] as const)
.map(
([state, game]) =>
state && (game.getView({ state, humanKey: p }) as GameView)
)
.toProperty(),
};
});
removed.forEach((p) => {
delete playerStreams[p];
});
});
const { ready, action, quit } = partition(
["ready", "action", "quit"],
messages
) as unknown as {
// yuck
ready: Observable<Attributed & { ready: boolean }, any>;
action: Observable<Attributed & { action: GameAction }, any>;
quit: Observable<Attributed, any>;
};
const gameEnds = quit.map((_) => null);
const playersReady = multiScan(
null as {
[key: string]: boolean;
} | null,
[
playersPresent, // TODO: filter to only outside active games
(prev, players: ValueWithin<typeof playersPresent>) =>
Object.fromEntries(players.map((p) => [p, prev?.[p] ?? false])),
],
[
ready, // TODO: filter to only outside active games
(prev, evt: ValueWithin<typeof ready>) =>
prev?.[evt.humanKey] != null
? {
...prev,
[evt.humanKey]: evt.ready,
}
: prev,
],
[gameEnds, () => null],
[
playersPresent.sampledBy(gameEnds),
(_, players: ValueWithin<typeof playersPresent>) =>
Object.fromEntries(players.map((p) => [p, false])),
]
).toProperty();
const gameStarts = playersReady
.filter(
(pr) =>
Object.values(pr ?? {}).length > 0 &&
Object.values(pr!).every((ready) => ready)
)
.map((_) => null);
const gameConfigPool = pool<GameConfig, any>();
const gameConfig = gameConfigPool.toProperty();
const gameImpl = gameConfig
.filter((cfg) => cfg.game in GAMES)
.map((config) => GAMES[config.game as GameKey](config))
.toProperty();
const gameState = multiScan(
null as GameState | null,
[
// initialize game state when started
gameImpl.sampledBy(gameStarts),
(prev, game: ValueWithin<typeof gameImpl>) =>
prev || (game.init() as GameState),
],
[
combine([action], [gameImpl], (act, game) => [act, game] as const),
(
prev,
[{ action, humanKey }, game]: [
Attributed & { action: GameAction },
Game
]
) =>
prev &&
(game.resolveAction({
state: prev,
action,
humanKey,
}) as GameState),
],
[quit, () => null]
).toProperty();
const gameIsActive = gameState
.map((gs) => gs != null)
.skipDuplicates()
.toProperty();
gameConfigPool.plug(
multiScan(
{
game: "simple",
players: [] as string[],
},
[
playersPresent.filterBy(gameIsActive.map((active) => !active)),
(prev, players) => ({
...prev,
players,
}),
]
// TODO: Add player defined config changes
) as unknown as Observable<GameConfig, any>
);
tables[key] = {
inputs,
outputs: {
global: {
playersPresent,
playersReady,
gameConfig,
},
player: playerStreams,
},
};
// cleanup: delete the room if no one is in it for 30 seconds
tables[key].outputs.global.playersPresent
.skip(1) // don't consider the empty room upon creation
.debounce(30000)
.filter(isEmpty)
.onValue(() => {
log("DELETING LIVE TABLE");
delete tables[key];
});
}
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
};

25
pkg/shared/games/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import simple from "./simple";
export type Game<
S = unknown,
A = unknown,
E extends { error: any } = { error: any },
V = unknown
> = {
title: string;
rules: string;
init: () => S;
resolveAction: (p: { state: S; action: A; humanKey: string }) => S | E;
getView: (p: { state: S; humanKey: string }) => V;
resolveQuit: (p: { state: S; humanKey: string }) => S;
};
export const GAMES: {
[key: string]: (config: { game: string; players: string[] }) => Game;
} = {
// renaissance,
simple,
};
export default GAMES;
export type GameKey = string;

View File

@@ -1,5 +1,6 @@
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 type { Game } from ".";
export type SimpleConfiguration = { export type SimpleConfiguration = {
game: "simple"; game: "simple";
@@ -30,9 +31,7 @@ export const newSimpleGameState = (
return { return {
deck: shuffle(newDeck()), deck: shuffle(newDeck()),
turnIdx: 0, turnIdx: 0,
playerHands: Object.fromEntries( playerHands: Object.fromEntries(players.map((humanKey) => [humanKey, []])),
players.map((humanKey) => [humanKey, []])
),
}; };
}; };
@@ -54,13 +53,13 @@ export const getSimplePlayerView = (
export const resolveSimpleAction = ({ export const resolveSimpleAction = ({
config, config,
state, state,
humanKey,
action, action,
humanKey,
}: { }: {
config: SimpleConfiguration; config: SimpleConfiguration;
state: SimpleGameState; state: SimpleGameState;
humanKey: string;
action: SimpleAction; action: SimpleAction;
humanKey: string;
}): SimpleGameState => { }): SimpleGameState => {
const playerHand = state.playerHands[humanKey]; const playerHand = state.playerHands[humanKey];
if (playerHand == null) { if (playerHand == null) {
@@ -101,3 +100,21 @@ export const resolveSimpleAction = ({
}; };
} }
}; };
type SimpleError = { error: "whoops!" };
export default (config: SimpleConfiguration) =>
({
title: "Simple",
rules: "You can draw, or you can discard. Then your turn is up.",
init: () => newSimpleGameState(config),
resolveAction: (p) => resolveSimpleAction({ ...p, config }),
getView: ({ state, humanKey }) =>
getSimplePlayerView(config, state, humanKey),
resolveQuit: () => null,
} satisfies Game<
SimpleGameState,
SimpleAction,
SimpleError,
SimplePlayerView
>);

52
pkg/shared/kefirs.ts Normal file
View File

@@ -0,0 +1,52 @@
import { merge, Observable } from "kefir";
import Bus from "kefir-bus";
export type ValueWithin<O extends Observable<any, any>> = Parameters<
Parameters<O["map"]>[0]
>[0];
type Mutation<A, O extends Observable<any, any>> = [
O,
(prev: A, value: ValueWithin<O>) => A
];
export const multiScan = <A, M extends Mutation<A, any>[]>(
initValue: A,
...mutations: M
): Observable<A, 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 extends { [key: string]: any },
E
>(
classes: C,
obs: Observable<T, E>
) => {
const classBuses = Object.fromEntries(classes.map((c) => [c, Bus()]));
obs.onValue((v) => {
for (const _class of classes) {
if (_class in v) {
classBuses[_class].emit(v);
return;
}
}
});
return classBuses;
};
export const isEmpty = (container: { length: number }) => container.length == 0;
export const setDiff = <T>(
sets: [Set<T>, s2: Set<T>]
): { added: T[]; removed: T[] } => ({
added: [...sets[1].difference(sets[0])],
removed: [...sets[0].difference(sets[1])],
});
export const set = <T>(arr: T[]) => new Set<T>(arr);

13
pkg/shared/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@games/shared",
"version": "1.0.0",
"dependencies": {
"kefir": "^3.8.8",
"kefir-bus": "^2.3.1",
"object-hash": "^3.0.0"
},
"devDependencies": {
"@types/kefir": "^3.8.11",
"ts-xor": "^1.3.0"
}
}

9
pkg/shared/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"esModuleInterop": true,
"target": "esnext",
"moduleResolution": "nodenext",
"module": "nodenext"
}
}

2404
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,4 +2,4 @@ onlyBuiltDependencies:
- "@parcel/watcher" - "@parcel/watcher"
- esbuild - esbuild
packages: packages:
- "packages/*" - "pkg/*"

View File

@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@games/*": ["packages/*"] "@games/*": ["pkg/*"]
} }
} }
} }