Compare commits
7 Commits
d69336027a
...
11f21221ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 11f21221ee | |||
| 01a12ec58a | |||
| 782dd738cc | |||
| 5e33e33cce | |||
| 90be478e9a | |||
| f38a5a69df | |||
| 0d6d3d6d32 |
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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");
|
|
||||||
@@ -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);
|
|
||||||
@@ -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),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@games/shared",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"object-hash": "^3.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"esModuleInterop": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -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]);
|
||||||
@@ -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
|
||||||
17
pkg/client/src/components/Hand.css
Normal file
17
pkg/client/src/components/Hand.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
@@ -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 "
|
||||||
40
pkg/client/src/routes/index.tsx
Normal file
40
pkg/client/src/routes/index.tsx
Normal 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
47
pkg/client/src/style.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"^/*": ["./*"],
|
"^/*": ["./*"],
|
||||||
"@games/*": ["./packages/*"],
|
"@games/*": ["./pkg/*"],
|
||||||
"$/*": ["./packages/client/*"],
|
"$/*": ["./pkg/client/*"],
|
||||||
"~/*": ["./packages/client/src/*"]
|
"~/*": ["./pkg/client/src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -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
27
pkg/server/src/index.ts
Normal 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
16
pkg/server/src/logging.ts
Normal 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
12
pkg/server/src/static.ts
Normal 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
254
pkg/server/src/table.ts
Normal 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
25
pkg/shared/games/index.ts
Normal 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;
|
||||||
@@ -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
52
pkg/shared/kefirs.ts
Normal 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
13
pkg/shared/package.json
Normal 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
9
pkg/shared/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"module": "nodenext"
|
||||||
|
}
|
||||||
|
}
|
||||||
2404
pnpm-lock.yaml
generated
2404
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,4 @@ onlyBuiltDependencies:
|
|||||||
- "@parcel/watcher"
|
- "@parcel/watcher"
|
||||||
- esbuild
|
- esbuild
|
||||||
packages:
|
packages:
|
||||||
- "packages/*"
|
- "pkg/*"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@games/*": ["packages/*"]
|
"@games/*": ["pkg/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user