package -> pkg
This commit is contained in:
11
pkg/client/index.html
Normal file
11
pkg/client/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" />
|
||||
</body>
|
||||
</html>
|
||||
29
pkg/client/package.json
Normal file
29
pkg/client/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@games/client",
|
||||
"type": "module",
|
||||
"version": "0.0.4",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.3.2",
|
||||
"@solid-primitives/scheduled": "^1.5.2",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"kefir": "^3.8.8",
|
||||
"kefir-bus": "^2.3.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/solar": "^1.2.4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/kefir": "^3.8.11",
|
||||
"@unocss/preset-icons": "^66.4.2",
|
||||
"@unocss/preset-wind4": "^66.4.2",
|
||||
"unocss": "^66.4.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-solid": "^2.11.8"
|
||||
}
|
||||
}
|
||||
BIN
pkg/client/public/favicon.ico
Normal file
BIN
pkg/client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
1
pkg/client/public/views/back.svg
Symbolic link
1
pkg/client/public/views/back.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../assets/sources/Card_back_01.svg
|
||||
1
pkg/client/public/views/cards
Symbolic link
1
pkg/client/public/views/cards
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../assets/sources/Vector-Cards-Version-3.2/FACES (BORDERED)/STANDARD BORDERED/Single Cards (One Per FIle)
|
||||
16
pkg/client/src/api.ts
Normal file
16
pkg/client/src/api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import { fromEvents } from "kefir";
|
||||
import { type Api } from "@games/server/src/api";
|
||||
|
||||
const { api } = treaty<Api>(
|
||||
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
|
||||
{
|
||||
fetch: { credentials: "include" },
|
||||
}
|
||||
);
|
||||
export default api;
|
||||
|
||||
export const fromWebsocket = <T>(ws: any) =>
|
||||
fromEvents(ws, "message").map(
|
||||
(evt) => (evt as unknown as { data: T }).data
|
||||
);
|
||||
64
pkg/client/src/app.tsx
Normal file
64
pkg/client/src/app.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { createResource, lazy, Suspense } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
import "virtual:uno.css";
|
||||
import pkg from "^/package.json";
|
||||
import "./style.css";
|
||||
import api from "./api";
|
||||
import { mePromise } from "./profile";
|
||||
|
||||
const Profile = () => {
|
||||
let dialogRef!: HTMLDialogElement;
|
||||
const [profile] = createResource(() =>
|
||||
mePromise.then(() => api.profile.get())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => dialogRef.showModal()}
|
||||
class="i-solar-user-circle-bold button s-10 m-2 cursor-pointer fixed tr"
|
||||
/>
|
||||
|
||||
<dialog ref={dialogRef} closedby="any">
|
||||
<div class="fixed tr bg-emerald-100 m-2 p-4 rounded-xl border-2 shadow-md shadow-black">
|
||||
Name:{" "}
|
||||
<input
|
||||
value={profile()?.data?.name ?? ""}
|
||||
onChange={(e) => {
|
||||
dialogRef.close();
|
||||
void api.setName.post({ name: e.target.value });
|
||||
}}
|
||||
class="bg-emerald-200 border-1.5 rounded-full px-4"
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<Profile />
|
||||
|
||||
{/* Version */}
|
||||
<span class="fixed br m-2 font-mono text-xs">
|
||||
{"v" + pkg.version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={lazy(() => import("./routes/index"))} />
|
||||
<Route
|
||||
path="/:tableKey"
|
||||
component={lazy(() => import("./routes/[table]"))}
|
||||
/>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
render(App, document.getElementById("app")!);
|
||||
49
pkg/client/src/components/Card.tsx
Normal file
49
pkg/client/src/components/Card.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, Suspense } from "solid-js";
|
||||
|
||||
import type { Card } from "@games/shared/cards";
|
||||
import { Clickable, Sizable, Stylable } from "./toolbox";
|
||||
|
||||
const cardToSvgFilename = (card: Card) => {
|
||||
if (card.kind == "joker") {
|
||||
return `JOKER-${card.color == "black" ? "2" : "3"}`;
|
||||
}
|
||||
|
||||
const value =
|
||||
{ ace: 1, jack: 11, queen: 12, king: 13 }[
|
||||
card.rank as "ace" | "jack" | "queen" | "king" // fuck you typescript
|
||||
] ?? (card.rank as number);
|
||||
return `${card.suit.toUpperCase()}-${value}${
|
||||
value >= 11 ? "-" + (card.rank as string).toUpperCase() : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<Suspense>
|
||||
<img
|
||||
onClick={props.onClick}
|
||||
draggable={false}
|
||||
class={props.class}
|
||||
style={props.style}
|
||||
width={props.width ?? "100px"}
|
||||
height={props.height}
|
||||
src={
|
||||
props.face == "down"
|
||||
? "/views/back.svg"
|
||||
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}) satisfies Component<
|
||||
(
|
||||
| {
|
||||
card: Card;
|
||||
face?: "up";
|
||||
}
|
||||
| { card?: Card; face: "down" }
|
||||
) &
|
||||
Stylable &
|
||||
Clickable &
|
||||
Sizable
|
||||
>;
|
||||
29
pkg/client/src/components/FannedHand.tsx
Normal file
29
pkg/client/src/components/FannedHand.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Hand } from "@games/shared/cards";
|
||||
import { For } from "solid-js";
|
||||
import Card from "./Card";
|
||||
|
||||
export default (props: { handCount: number }) => {
|
||||
return (
|
||||
<For each={Array(props.handCount)}>
|
||||
{(_, i) => {
|
||||
const midOffset = i() + 0.5 - props.handCount / 2;
|
||||
return (
|
||||
<Card
|
||||
face="down"
|
||||
width="40px"
|
||||
style={{
|
||||
"margin-left": "-10px",
|
||||
"margin-right": "-10px",
|
||||
transform: `rotate(${
|
||||
midOffset * 0.2
|
||||
}rad) translate(0px, ${
|
||||
2 ** Math.abs(midOffset) * 2
|
||||
}px)`,
|
||||
"box-shadow": "-4px 4px 4px rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
73
pkg/client/src/components/Game.tsx
Normal file
73
pkg/client/src/components/Game.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Accessor, createContext, For, useContext } from "solid-js";
|
||||
import type {
|
||||
SimpleAction,
|
||||
SimplePlayerView,
|
||||
} from "@games/server/src/games/simple";
|
||||
import { me, profile } from "~/profile";
|
||||
import Hand from "./Hand";
|
||||
import Pile from "./Pile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Portal } from "solid-js/web";
|
||||
import FannedHand from "./FannedHand";
|
||||
|
||||
export const GameContext = createContext<{
|
||||
view: Accessor<SimplePlayerView>;
|
||||
submitAction: (action: SimpleAction) => any;
|
||||
}>();
|
||||
|
||||
export default () => {
|
||||
const table = useContext(TableContext)!;
|
||||
const view = table.view as Accessor<SimplePlayerView>;
|
||||
const submitAction = (action: SimpleAction) => table.sendWs({ action });
|
||||
|
||||
return (
|
||||
<GameContext.Provider value={{ view, submitAction }}>
|
||||
<Pile
|
||||
count={view().deckCount}
|
||||
class="cursor-pointer fixed center"
|
||||
onClick={() => submitAction({ type: "draw" })}
|
||||
/>
|
||||
|
||||
<Hand
|
||||
class="fixed bc"
|
||||
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>
|
||||
<button
|
||||
class="button fixed tl m-4 p-1"
|
||||
onClick={() => table.sendWs({ quit: true })}
|
||||
>
|
||||
Quit
|
||||
</button>
|
||||
<For each={Object.entries(view().playerHandCounts)}>
|
||||
{([playerKey, handCount], i) => (
|
||||
<Portal
|
||||
mount={document.getElementById(`player-${playerKey}`)!}
|
||||
ref={(ref) => {
|
||||
const midOffset =
|
||||
i() +
|
||||
0.5 -
|
||||
Object.values(view().playerHandCounts).length /
|
||||
2;
|
||||
|
||||
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%; transform: translate(${Math.abs(
|
||||
midOffset * 0
|
||||
)}px, 0px) rotate(${midOffset * 1}rad)`;
|
||||
}}
|
||||
>
|
||||
<FannedHand handCount={handCount} />
|
||||
</Portal>
|
||||
)}
|
||||
</For>
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
24
pkg/client/src/components/Hand.tsx
Normal file
24
pkg/client/src/components/Hand.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Component, For } from "solid-js";
|
||||
import type { Card as TCard, Hand as THand } from "@games/shared/cards";
|
||||
import Card from "./Card";
|
||||
import { Stylable } from "./toolbox";
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<div class={"hand " + props.class} style={props.style}>
|
||||
<For each={props.hand}>
|
||||
{(card, i) => (
|
||||
<Card
|
||||
card={card}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => props.onClickCard?.(card, i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}) satisfies Component<
|
||||
{ hand: THand; onClickCard?: (card: TCard, i: number) => any } & Stylable
|
||||
>;
|
||||
22
pkg/client/src/components/Pile.tsx
Normal file
22
pkg/client/src/components/Pile.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Component, For, JSX, Show } from "solid-js";
|
||||
import Card from "./Card";
|
||||
|
||||
import { Clickable, Stylable } from "./toolbox";
|
||||
|
||||
export default ((props) => {
|
||||
return (
|
||||
<Show when={props.count > 0}>
|
||||
<Card
|
||||
onClick={props.onClick}
|
||||
style={props.style}
|
||||
class={props.class + " shadow-lg shadow-black"}
|
||||
face="down"
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
}) satisfies Component<
|
||||
{
|
||||
count: number;
|
||||
} & Stylable &
|
||||
Clickable
|
||||
>;
|
||||
38
pkg/client/src/components/Player.tsx
Normal file
38
pkg/client/src/components/Player.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSignal, useContext } from "solid-js";
|
||||
import { playerColor, profile } from "~/profile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Stylable } from "./toolbox";
|
||||
import { createObservable, createObservableWithInit } from "~/fn";
|
||||
import { GameContext } from "./Game";
|
||||
|
||||
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];
|
||||
|
||||
const game = useContext(GameContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`player-${props.playerKey}`}
|
||||
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`}
|
||||
>
|
||||
<p style={{ "font-size": "1em" }}>
|
||||
{profile(props.playerKey)()?.name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
136
pkg/client/src/components/Table.tsx
Normal file
136
pkg/client/src/components/Table.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { TWsIn, TWsOut } from "@games/server/src/table";
|
||||
import { fromPromise, Stream } from "kefir";
|
||||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import api, { fromWebsocket } from "~/api";
|
||||
import { createObservable, createObservableWithInit, cx } from "~/fn";
|
||||
import { me, mePromise } from "~/profile";
|
||||
import Game from "./Game";
|
||||
import Player from "./Player";
|
||||
import games from "@games/shared/games/index";
|
||||
|
||||
export const TableContext = createContext<{
|
||||
view: Accessor<any>;
|
||||
sendWs: (msg: TWsIn) => void;
|
||||
wsEvents: Stream<TWsOut, any>;
|
||||
}>();
|
||||
|
||||
export default (props: { tableKey: string }) => {
|
||||
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 !== undefined);
|
||||
|
||||
const players = createObservableWithInit<string[]>(
|
||||
presenceEvents.map((evt) => evt.players!),
|
||||
[]
|
||||
);
|
||||
|
||||
const [ready, setReady] = createSignal(false);
|
||||
mePromise.then(
|
||||
(me) =>
|
||||
me &&
|
||||
wsEvents
|
||||
.filter((evt) => evt.playersReady !== undefined)
|
||||
.map((evt) => evt.playersReady?.[me] ?? false)
|
||||
.onValue(setReady)
|
||||
);
|
||||
|
||||
createEffect(() => sendWs({ ready: ready() }));
|
||||
const view = createObservable(gameEvents.map((evt) => evt.view));
|
||||
|
||||
return (
|
||||
<TableContext.Provider
|
||||
value={{
|
||||
sendWs,
|
||||
wsEvents,
|
||||
view,
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-around p-t-14">
|
||||
<For each={players().filter((p) => p != me())}>
|
||||
{(player, i) => {
|
||||
const verticalOffset = () => {
|
||||
const N = players().length - 1;
|
||||
const x = Math.abs((2 * i() + 1) / (N * 2) - 0.5);
|
||||
const y = Math.sqrt(1 - x * x);
|
||||
return 1 - y;
|
||||
};
|
||||
return (
|
||||
<Player
|
||||
playerKey={player}
|
||||
style={{
|
||||
transform: `translate(0, ${
|
||||
verticalOffset() * 150
|
||||
}vh)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
id="table"
|
||||
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%",
|
||||
}}
|
||||
>
|
||||
<Show when={view() == null}>
|
||||
<div class="absolute tc mt-8 flex gap-4">
|
||||
<select>
|
||||
<For each={Object.entries(games)}>
|
||||
{([gameId, game]) => (
|
||||
<option value={gameId}>{game.title}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setReady((prev) => !prev)}
|
||||
class="button p-1 "
|
||||
>
|
||||
{ready() ? "Not Ready" : "Ready"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={view() != null}>
|
||||
<Game />
|
||||
</Show>
|
||||
</TableContext.Provider>
|
||||
);
|
||||
};
|
||||
21
pkg/client/src/components/toolbox.tsx
Normal file
21
pkg/client/src/components/toolbox.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
export type Stylable = {
|
||||
class?: string;
|
||||
style?: JSX.CSSProperties;
|
||||
};
|
||||
|
||||
export type Clickable = {
|
||||
onClick?:
|
||||
| JSX.EventHandlerUnion<
|
||||
HTMLDivElement,
|
||||
MouseEvent,
|
||||
JSX.EventHandler<HTMLDivElement, MouseEvent>
|
||||
>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type Sizable = {
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
39
pkg/client/src/fn.ts
Normal file
39
pkg/client/src/fn.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Observable } from "kefir";
|
||||
import { Accessor, createSignal } from "solid-js";
|
||||
|
||||
declare global {
|
||||
interface Array<T> {
|
||||
thru<S>(fn: (arr: T[]) => S): S;
|
||||
}
|
||||
}
|
||||
Array.prototype.thru = function <T, S>(this: T[], fn: (arr: T[]) => S) {
|
||||
return fn(this);
|
||||
};
|
||||
export const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o));
|
||||
|
||||
export type ApiType<T extends () => Promise<{ data: any }>> = Awaited<
|
||||
ReturnType<T>
|
||||
>["data"];
|
||||
|
||||
export type WSEvent<
|
||||
T extends { subscribe: (handler: (...args: any[]) => any) => any }
|
||||
> = Parameters<Parameters<T["subscribe"]>[0]>[0];
|
||||
|
||||
export const createObservable = <T>(obs: Observable<T, any>) => {
|
||||
const [signal, setSignal] = createSignal<T>();
|
||||
obs.onValue((val) => setSignal(() => val));
|
||||
|
||||
return signal;
|
||||
};
|
||||
|
||||
export const createObservableWithInit = <T>(
|
||||
obs: Observable<T, any>,
|
||||
init: T
|
||||
) => {
|
||||
const [signal, setSignal] = createSignal<T>(init);
|
||||
obs.onValue((val) => setSignal(() => val));
|
||||
|
||||
return signal;
|
||||
};
|
||||
|
||||
export const cx = (...classes: string[]) => classes.join(" ");
|
||||
1
pkg/client/src/global.d.ts
vendored
Normal file
1
pkg/client/src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
26
pkg/client/src/profile.ts
Normal file
26
pkg/client/src/profile.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createResource, Resource } from "solid-js";
|
||||
import { ApiType } from "./fn";
|
||||
import api from "./api";
|
||||
import hash from "object-hash";
|
||||
|
||||
export const mePromise = api.whoami.post().then((r) => r.data);
|
||||
export const [me] = createResource(() => mePromise);
|
||||
|
||||
const playerProfiles: {
|
||||
[humanKey: string]: Resource<ApiType<typeof api.profile.get>>;
|
||||
} = {};
|
||||
|
||||
export const profile = (humanKey: string) => {
|
||||
if (!(humanKey in playerProfiles)) {
|
||||
playerProfiles[humanKey] = createResource(() =>
|
||||
api.profile
|
||||
.get({ query: { otherHumanKey: humanKey } })
|
||||
.then((r) => r.data)
|
||||
)[0];
|
||||
}
|
||||
|
||||
return playerProfiles[humanKey];
|
||||
};
|
||||
|
||||
export const playerColor = (humanKey: string) =>
|
||||
"#" + hash(humanKey).substring(0, 6);
|
||||
82
pkg/client/src/renaissance.ts
Normal file
82
pkg/client/src/renaissance.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Board, Card, Hand, Pile, Stack, Suit } from "./types/cards";
|
||||
import { clone } from "./fn";
|
||||
|
||||
const AGG: Suit = "spades";
|
||||
const CUL: Suit = "hearts";
|
||||
const TECH: Suit = "diamonds";
|
||||
const MIL: Suit = "clubs";
|
||||
|
||||
type GameState = {
|
||||
mainPile: Pile;
|
||||
discardStack: Stack;
|
||||
deadPile: Pile;
|
||||
players: {
|
||||
hand: Hand;
|
||||
board: Board;
|
||||
}[];
|
||||
playerTurn: number;
|
||||
lastMove: Move;
|
||||
prevState: GameState | null;
|
||||
};
|
||||
|
||||
type Move =
|
||||
| {
|
||||
type: "draw";
|
||||
fromPile: "main" | "discard";
|
||||
}
|
||||
| { type: "play"; card: Card }
|
||||
| { type: "remove"; card: Card }
|
||||
| { type: "attack"; army: Card[] }
|
||||
| { type: "pass" };
|
||||
|
||||
type Action = Move | { type: "discard"; cards: Card[] };
|
||||
|
||||
const techTier = (techValue: number) => Math.ceil(techValue / 14);
|
||||
const moveCost = (move: Move) => (move.type == "attack" ? move.army.length : 1);
|
||||
const movesSpent = (state: GameState): number =>
|
||||
state.playerTurn != state.prevState?.playerTurn
|
||||
? 0
|
||||
: moveCost(state.lastMove) + movesSpent(state.prevState);
|
||||
|
||||
const value = (suit: Suit) => (board: Board) =>
|
||||
board
|
||||
.filter((card) => card.suit === suit)
|
||||
.reduce((v, card) => v + card.value, 0);
|
||||
|
||||
export const getNextState = (p: {
|
||||
state: GameState;
|
||||
move: Move;
|
||||
}): GameState | { illegal: string } => {
|
||||
const { state, move } = p;
|
||||
|
||||
const currentPlayer = state.players[state.playerTurn];
|
||||
|
||||
const population = currentPlayer.board.thru(value(AGG));
|
||||
const culture = currentPlayer.board.thru(value(CUL));
|
||||
const tech = techTier(currentPlayer.board.thru(value(TECH)));
|
||||
|
||||
const movesRemaining = tech - movesSpent(state);
|
||||
|
||||
const newState = clone(state);
|
||||
if (move.type === "draw") {
|
||||
const pile =
|
||||
move.fromPile === "main"
|
||||
? newState.mainPile
|
||||
: newState.discardStack;
|
||||
|
||||
if (pile.length === 0) {
|
||||
return {
|
||||
illegal: `The ${move.fromPile} pile is empty; cannot draw`,
|
||||
};
|
||||
}
|
||||
|
||||
newState.players[newState.playerTurn].hand.push(pile.pop()!);
|
||||
if (movesRemaining == 1) {
|
||||
newState.playerTurn =
|
||||
(newState.playerTurn + 1) % newState.players.length;
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
return { illegal: "idk bruh" };
|
||||
};
|
||||
15
pkg/client/src/routes/[table].tsx
Normal file
15
pkg/client/src/routes/[table].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
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} />
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
17
pkg/client/src/routes/index.tsx
Normal file
17
pkg/client/src/routes/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
61
pkg/client/src/style.css
Normal file
61
pkg/client/src/style.css
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
}
|
||||
22
pkg/client/tsconfig.json
Normal file
22
pkg/client/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"^/*": ["./*"],
|
||||
"@games/*": ["./pkg/*"],
|
||||
"$/*": ["./pkg/client/*"],
|
||||
"~/*": ["./pkg/client/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
46
pkg/client/uno.config.ts
Normal file
46
pkg/client/uno.config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import presetWind4 from "@unocss/preset-wind4";
|
||||
import { defineConfig } from "unocss";
|
||||
import { presetIcons } from "unocss";
|
||||
import {} from "@iconify-json/solar";
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWind4(),
|
||||
presetIcons({
|
||||
collections: {
|
||||
solar: () =>
|
||||
import("@iconify-json/solar/icons.json").then(
|
||||
(i) => i.default as any
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
shortcuts: [[/^s-(\d+)$/, ([, d]) => `w-${d} h-${d}`]],
|
||||
rules: [
|
||||
["tl", { top: 0, left: 0 }],
|
||||
["tr", { top: 0, right: 0 }],
|
||||
["bl", { bottom: 0, left: 0 }],
|
||||
["br", { bottom: 0, right: 0 }],
|
||||
[
|
||||
"bc",
|
||||
{
|
||||
bottom: 0,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"tc",
|
||||
{
|
||||
top: 0,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
"center",
|
||||
{ top: "50%", left: "50%", transform: "translate(-50%, -50%)" },
|
||||
],
|
||||
],
|
||||
});
|
||||
20
pkg/client/vite.config.ts
Normal file
20
pkg/client/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import UnoCSS from "unocss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solidPlugin(), UnoCSS()],
|
||||
build: {
|
||||
outDir: "../server/public",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"^": path.resolve(__dirname, "../../"),
|
||||
"@games": path.resolve(__dirname, "../"),
|
||||
$: path.resolve(__dirname, "./"),
|
||||
"~": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
3
pkg/server/.gitignore
vendored
Normal file
3
pkg/server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# deps
|
||||
node_modules/
|
||||
public
|
||||
19
pkg/server/db/migrations/20250805231347_init/migration.sql
Normal file
19
pkg/server/db/migrations/20250805231347_init/migration.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Game" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"rules" TEXT
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Instance" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"gameId" INTEGER NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Game_name_key" ON "Game"("name");
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Game` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `id` on the `Game` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `gameId` on the `Instance` table. All the data in the column will be lost.
|
||||
- Added the required column `key` to the `Game` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `gameKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Game" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"rules" TEXT
|
||||
);
|
||||
INSERT INTO "new_Game" ("createdAt", "name", "rules", "updatedAt") SELECT "createdAt", "name", "rules", "updatedAt" FROM "Game";
|
||||
DROP TABLE "Game";
|
||||
ALTER TABLE "new_Game" RENAME TO "Game";
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("gameState", "id") SELECT "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Instance` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
28
pkg/server/db/migrations/20250809192322_humans/migration.sql
Normal file
28
pkg/server/db/migrations/20250809192322_humans/migration.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `createdByKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "Human" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdByKey" TEXT NOT NULL,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_createdByKey_fkey" FOREIGN KEY ("createdByKey") REFERENCES "Human" ("key") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Human" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
|
||||
DROP TABLE "Human";
|
||||
ALTER TABLE "new_Human" RENAME TO "Human";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
45
pkg/server/db/migrations/20250818213152_tokens/migration.sql
Normal file
45
pkg/server/db/migrations/20250818213152_tokens/migration.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The required column `token` was added to the `Human` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "_HumanToInstance" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
CONSTRAINT "_HumanToInstance_A_fkey" FOREIGN KEY ("A") REFERENCES "Human" ("key") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "_HumanToInstance_B_fkey" FOREIGN KEY ("B") REFERENCES "Instance" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Human" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"token" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT '__name__',
|
||||
"lastActive" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
|
||||
DROP TABLE "Human";
|
||||
ALTER TABLE "new_Human" RENAME TO "Human";
|
||||
CREATE UNIQUE INDEX "Human_token_key" ON "Human"("token");
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdByKey" TEXT NOT NULL,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB NOT NULL,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("createdByKey", "gameKey", "gameState", "id") SELECT "createdByKey", "gameKey", "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_HumanToInstance_AB_unique" ON "_HumanToInstance"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_HumanToInstance_B_index" ON "_HumanToInstance"("B");
|
||||
@@ -0,0 +1,15 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Instance" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdByKey" TEXT NOT NULL,
|
||||
"gameKey" TEXT NOT NULL,
|
||||
"gameState" JSONB,
|
||||
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Instance" ("createdByKey", "gameKey", "gameState", "id") SELECT "createdByKey", "gameKey", "gameState", "id" FROM "Instance";
|
||||
DROP TABLE "Instance";
|
||||
ALTER TABLE "new_Instance" RENAME TO "Instance";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
3
pkg/server/db/migrations/migration_lock.toml
Normal file
3
pkg/server/db/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
37
pkg/server/db/schema.prisma
Normal file
37
pkg/server/db/schema.prisma
Normal file
@@ -0,0 +1,37 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
}
|
||||
|
||||
model Game {
|
||||
key String @id
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
rules String?
|
||||
instances Instance[]
|
||||
}
|
||||
|
||||
model Human {
|
||||
key String @id @default(cuid())
|
||||
token String @unique @default(cuid())
|
||||
name String @default("__name__")
|
||||
lastActive DateTime @default(now())
|
||||
|
||||
playingInstances Instance[]
|
||||
}
|
||||
|
||||
model Instance {
|
||||
id String @id @default(cuid())
|
||||
createdByKey String
|
||||
gameKey String
|
||||
|
||||
players Human[]
|
||||
|
||||
game Game @relation(fields: [gameKey], references: [key])
|
||||
gameState Json?
|
||||
}
|
||||
36
pkg/server/package.json
Normal file
36
pkg/server/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@games/server",
|
||||
"scripts": {
|
||||
"dev": "concurrently 'pnpm run devserver' 'pnpm run dbstudio'",
|
||||
"devserver": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
|
||||
"dbstudio": "pnpm dlx prisma studio --browser none",
|
||||
"dbdeploy": "pnpm dlx prisma migrate deploy",
|
||||
"dbtypes": "pnpm dlx prisma generate",
|
||||
"dbsync": "pnpm dlx prisma migrate dev",
|
||||
"dbwipe": "pnpm dlx prisma migrate reset",
|
||||
"prod": "NODE_ENV=production bun run src/index.ts",
|
||||
"start": "concurrently 'pnpm run prod' 'pnpm run dbstudio'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.3.3",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@games/shared": "workspace:*",
|
||||
"@prisma/client": "6.13.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"elysia": "^1.3.8",
|
||||
"elysia-ip": "^1.0.10",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
pkg/server/prisma.config.ts
Normal file
6
pkg/server/prisma.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: path.join("db", "schema.prisma"),
|
||||
});
|
||||
146
pkg/server/src/api.ts
Normal file
146
pkg/server/src/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import {
|
||||
getSimplePlayerView,
|
||||
SimpleAction,
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
} from "@games/shared/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 { combine } from "kefir";
|
||||
|
||||
const api = new Elysia({ prefix: "/api" })
|
||||
.post("/whoami", async ({ cookie: { token } }) => {
|
||||
let human: Human | null;
|
||||
if (
|
||||
token.value == null ||
|
||||
(human = await db.human.findUnique({
|
||||
where: {
|
||||
token: token.value,
|
||||
},
|
||||
})) == null
|
||||
) {
|
||||
human = await db.human.create({
|
||||
data: {},
|
||||
});
|
||||
token.set({
|
||||
value: human.token,
|
||||
expires: dayjs().add(1, "year").toDate(),
|
||||
httpOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
return human.key;
|
||||
})
|
||||
.use(human)
|
||||
.post(
|
||||
"/setName",
|
||||
({ body: { name }, humanKey }) =>
|
||||
db.human.update({
|
||||
where: {
|
||||
key: humanKey,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
.get("/profile", ({ humanKey, query: { otherHumanKey } }) =>
|
||||
db.human
|
||||
.findFirst({ where: { key: otherHumanKey ?? humanKey } })
|
||||
.then((human) => {
|
||||
if (human == null) {
|
||||
return null;
|
||||
}
|
||||
const { token, ...safeProfile } = human;
|
||||
return safeProfile;
|
||||
})
|
||||
)
|
||||
.get("/games", () => [{ key: "simple", name: "simple" }])
|
||||
.ws("/ws/:tableKey", {
|
||||
async open({
|
||||
data: {
|
||||
params: { tableKey },
|
||||
humanKey,
|
||||
},
|
||||
send,
|
||||
}) {
|
||||
const table = liveTable<
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
SimpleAction
|
||||
>(tableKey);
|
||||
|
||||
table.inputs.connectionChanges.emit({
|
||||
humanKey,
|
||||
presence: "joined",
|
||||
});
|
||||
|
||||
table.outputs.playersPresent.onValue((players) =>
|
||||
send({ players })
|
||||
);
|
||||
|
||||
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(
|
||||
{
|
||||
data: {
|
||||
humanKey,
|
||||
params: { tableKey },
|
||||
},
|
||||
},
|
||||
body
|
||||
) {
|
||||
const {
|
||||
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({
|
||||
data: {
|
||||
params: { tableKey },
|
||||
humanKey,
|
||||
},
|
||||
}) {
|
||||
liveTable(tableKey).inputs.connectionChanges.emit({
|
||||
humanKey,
|
||||
presence: "left",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default api;
|
||||
export type Api = typeof api;
|
||||
5
pkg/server/src/db.ts
Normal file
5
pkg/server/src/db.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export default new PrismaClient();
|
||||
13
pkg/server/src/human.ts
Normal file
13
pkg/server/src/human.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
33
pkg/server/src/index.ts
Normal file
33
pkg/server/src/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
29
pkg/server/src/kefir-extension.ts
Normal file
29
pkg/server/src/kefir-extension.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { merge, Observable } from "kefir";
|
||||
|
||||
export const transform = <
|
||||
T,
|
||||
Mutations extends [Observable<any, any>, (prev: T, evt: any) => T][]
|
||||
>(
|
||||
initValue: T,
|
||||
...mutations: Mutations
|
||||
): Observable<T, 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),
|
||||
])
|
||||
);
|
||||
};
|
||||
0
pkg/server/src/logging.ts
Normal file
0
pkg/server/src/logging.ts
Normal file
202
pkg/server/src/table.ts
Normal file
202
pkg/server/src/table.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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/shared/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>;
|
||||
};
|
||||
9
pkg/server/tsconfig.json
Normal file
9
pkg/server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
58
pkg/shared/cards.ts
Normal file
58
pkg/shared/cards.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
const suits = ["heart", "diamond", "spade", "club"] as const;
|
||||
export type Suit = (typeof suits)[number];
|
||||
|
||||
const ranks = [
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
"jack",
|
||||
"queen",
|
||||
"king",
|
||||
"ace",
|
||||
] as const;
|
||||
export type Rank = (typeof ranks)[number];
|
||||
|
||||
export type Card =
|
||||
| {
|
||||
kind: "normal";
|
||||
suit: Suit;
|
||||
rank: Rank;
|
||||
}
|
||||
| { kind: "joker"; color: "red" | "black" };
|
||||
|
||||
export type vCard = Card | null | number;
|
||||
export type Pile<C extends vCard = Card> = C[];
|
||||
export type Stack<C extends vCard = Card> = C[];
|
||||
export type Hand<C extends vCard = Card> = C[];
|
||||
export type Board<C extends vCard = Card> = C[];
|
||||
|
||||
export const newDeck = (withJokers = false): Pile =>
|
||||
suits
|
||||
.map((suit) =>
|
||||
ranks.map((rank) => ({ kind: "normal", suit, rank } as Card))
|
||||
)
|
||||
.flat()
|
||||
.concat(
|
||||
withJokers
|
||||
? [
|
||||
{ kind: "joker", color: "red" },
|
||||
{ kind: "joker", color: "black" },
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
export const shuffle = (cards: Card[]) => {
|
||||
let i = cards.length;
|
||||
while (i > 0) {
|
||||
const j = Math.floor(Math.random() * i);
|
||||
i--;
|
||||
[cards[i], cards[j]] = [cards[j], cards[i]];
|
||||
}
|
||||
return cards;
|
||||
};
|
||||
25
pkg/shared/games/index.ts
Normal file
25
pkg/shared/games/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as renaissance from "./renaissance";
|
||||
import simple from "./simple";
|
||||
|
||||
export type Game<
|
||||
C extends { game: string },
|
||||
S,
|
||||
A,
|
||||
E extends { error: any },
|
||||
V
|
||||
> = {
|
||||
title: string;
|
||||
rules: string;
|
||||
init: (config: C) => S;
|
||||
resolveAction: (p: { config: C; state: S; action: A }) => S | E;
|
||||
getView: (p: { config: C; state: S; humanKey: string }) => V;
|
||||
resolveQuit: (p: { config: C; state: S; humanKey: string }) => S;
|
||||
};
|
||||
|
||||
const games = {
|
||||
// renaissance,
|
||||
simple,
|
||||
} satisfies { [key: string]: Game<any, any, any, any, any> };
|
||||
export default games;
|
||||
|
||||
export type GameId = keyof typeof games;
|
||||
1
pkg/shared/games/renaissance.ts
Normal file
1
pkg/shared/games/renaissance.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
122
pkg/shared/games/simple.ts
Normal file
122
pkg/shared/games/simple.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
|
||||
import { heq } from "@games/shared/utils";
|
||||
import type { Game } from ".";
|
||||
|
||||
export type SimpleConfiguration = {
|
||||
game: "simple";
|
||||
players: string[];
|
||||
};
|
||||
|
||||
// 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 = {
|
||||
deckCount: number;
|
||||
playerTurn: string;
|
||||
playerHandCounts: { [humanKey: string]: number };
|
||||
myHand: Hand<Card>;
|
||||
};
|
||||
|
||||
export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
|
||||
|
||||
export const newSimpleGameState = (
|
||||
config: SimpleConfiguration
|
||||
): SimpleGameState => {
|
||||
const { players } = config;
|
||||
return {
|
||||
deck: shuffle(newDeck()),
|
||||
turnIdx: 0,
|
||||
playerHands: Object.fromEntries(
|
||||
players.map((humanKey) => [humanKey, []])
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const getSimplePlayerView = (
|
||||
config: SimpleConfiguration,
|
||||
state: SimpleGameState,
|
||||
humanKey: string
|
||||
): SimplePlayerView => ({
|
||||
deckCount: state.deck.length,
|
||||
playerTurn: config.players[state.turnIdx],
|
||||
myHand: state.playerHands[humanKey] as Hand,
|
||||
playerHandCounts: Object.fromEntries(
|
||||
Object.entries(state.playerHands)
|
||||
.filter(([id]) => id != humanKey)
|
||||
.map(([id, hand]) => [id, hand.length])
|
||||
),
|
||||
});
|
||||
|
||||
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`
|
||||
);
|
||||
}
|
||||
if (humanKey != config.players[state.turnIdx]) {
|
||||
throw new Error(`It's not ${humanKey}'s turn!`);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type SimpleError = { error: "whoops!" };
|
||||
|
||||
export default {
|
||||
title: "Simple",
|
||||
rules: "You can draw, or you can discard. Then your turn is up.",
|
||||
init: newSimpleGameState,
|
||||
resolveAction: resolveSimpleAction,
|
||||
getView: ({ config, state, humanKey }) =>
|
||||
getSimplePlayerView(config, state, humanKey),
|
||||
resolveQuit: () => null,
|
||||
} satisfies Game<
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
SimpleAction,
|
||||
SimpleError,
|
||||
SimplePlayerView
|
||||
>;
|
||||
7
pkg/shared/package.json
Normal file
7
pkg/shared/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@games/shared",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"object-hash": "^3.0.0"
|
||||
}
|
||||
}
|
||||
6
pkg/shared/tsconfig.json
Normal file
6
pkg/shared/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
3
pkg/shared/utils.ts
Normal file
3
pkg/shared/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import hash, { NotUndefined } from "object-hash";
|
||||
|
||||
export const heq = (a: NotUndefined) => (b: NotUndefined) => hash(a) == hash(b);
|
||||
Reference in New Issue
Block a user