Compare commits
44 Commits
601e3660d3
...
5e9b145066
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e9b145066 | |||
| 7d45a36f93 | |||
| fb3f567a5b | |||
| 41bd1fce38 | |||
| ed42c831de | |||
| ae6a79aadd | |||
| 46002403c8 | |||
| b854fec9e5 | |||
| bedafb0b7c | |||
| 9e3697ffef | |||
| b3e040f03f | |||
| 46e7b60ade | |||
| ce29ab72ae | |||
| d203fe4141 | |||
| 67fdf66cd4 | |||
| 9919b97931 | |||
| fd342e7d47 | |||
| b433a26fc6 | |||
| 0ea16ead64 | |||
| 11f21221ee | |||
| 01a12ec58a | |||
| 782dd738cc | |||
| 5e33e33cce | |||
| 90be478e9a | |||
| f38a5a69df | |||
| 0d6d3d6d32 | |||
| d69336027a | |||
| 686529507e | |||
| aeb7d9174b | |||
| e5f432dc98 | |||
| 0f015841ff | |||
| 6c45e7b114 | |||
| a117f6703f | |||
| 4bcf071668 | |||
| 6c64886f2a | |||
| 9b918b1c6a | |||
| 3347452ec4 | |||
| a2e8887a0b | |||
| cc53470ddf | |||
| 7d8ac0db76 | |||
| 35a5af154f | |||
| 265aad4522 | |||
| 287c19fc0d | |||
| 3f1635880a |
@@ -1,10 +0,0 @@
|
|||||||
node_modules
|
|
||||||
Dockerfile
|
|
||||||
Makefile
|
|
||||||
README.md
|
|
||||||
.output
|
|
||||||
.vinxi
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.dockerignore
|
|
||||||
*.db
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
|||||||
.vinxi
|
.vinxi
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
.DS_STORE
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
11
Dockerfile
11
Dockerfile
@@ -1,11 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
COPY package.json ./
|
|
||||||
RUN --mount=type=cache,target=/root/.npm npm install
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN --mount=type=cache,target=/app/.vinxi npm run build
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
5
Makefile
5
Makefile
@@ -8,3 +8,8 @@ build:
|
|||||||
|
|
||||||
start:
|
start:
|
||||||
PORT=$(PORT) pnpm start
|
PORT=$(PORT) pnpm start
|
||||||
|
|
||||||
|
note:
|
||||||
|
./notes/newfile
|
||||||
|
# touch ./notes/$$file.md
|
||||||
|
# code -r ./notes/$$file.md
|
||||||
7
deploy
Executable file
7
deploy
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
branch=$(git branch --show-current)
|
||||||
|
|
||||||
|
git switch prod
|
||||||
|
git merge $branch
|
||||||
|
git push
|
||||||
|
git switch $branch
|
||||||
7
notes/newfile
Executable file
7
notes/newfile
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
ts=$(date +"%Y-%m-%d-%H%M%S")
|
||||||
|
file=./notes/$ts.md
|
||||||
|
touch $file
|
||||||
|
echo -e "# $ts\n" > $file
|
||||||
|
echo "$file:end"
|
||||||
|
code --goto "$file:2"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "games",
|
"name": "games",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.2",
|
"version": "0.0.10",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel dev",
|
"dev": "pnpm --parallel dev",
|
||||||
"build": "pnpm run -F client build",
|
"build": "pnpm run -F client build",
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"object-hash": "^3.0.0"
|
"object-hash": "^3.0.0"
|
||||||
}
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/object-hash": "^3.0.6"
|
"@types/object-hash": "^3.0.6"
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { type Api } from "../../server/src/api";
|
|
||||||
import { treaty } from "@elysiajs/eden";
|
|
||||||
|
|
||||||
const { api } = treaty<Api>(
|
|
||||||
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
|
|
||||||
{
|
|
||||||
fetch: { credentials: "include" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
export default api;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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 Cookies from "js-cookie";
|
|
||||||
|
|
||||||
const Profile = () => {
|
|
||||||
let dialogRef!: HTMLDialogElement;
|
|
||||||
const [profile] = createResource(async () => 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) =>
|
|
||||||
api.setName.post({ name: e.target.value })
|
|
||||||
}
|
|
||||||
class="bg-emerald-200 border-1.5 rounded-full px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => (
|
|
||||||
<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="/:game"
|
|
||||||
component={lazy(() => import("./routes/[game]/index"))}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/:game/:instance"
|
|
||||||
component={lazy(() => import("./routes/[game]/[instance]"))}
|
|
||||||
/>
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
|
|
||||||
// todo: fix this
|
|
||||||
(Cookies.get("token") == null ? api.whoami.post() : Promise.resolve()).then(
|
|
||||||
() => render(App, document.getElementById("app")!)
|
|
||||||
);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Accessor, createContext, createResource, Show } from "solid-js";
|
|
||||||
import {
|
|
||||||
GameState,
|
|
||||||
Action,
|
|
||||||
vGameState,
|
|
||||||
PlayerView,
|
|
||||||
} from "../../../server/src/games/simple";
|
|
||||||
import api from "../api";
|
|
||||||
import Hand from "./Hand";
|
|
||||||
import Pile from "./Pile";
|
|
||||||
|
|
||||||
export const GameContext = createContext<{
|
|
||||||
view: Accessor<PlayerView | undefined>;
|
|
||||||
submitAction: (action: Action) => Promise<any>;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
export default (props: { instanceId: string }) => {
|
|
||||||
const [view, { mutate }] = createResource(() =>
|
|
||||||
api
|
|
||||||
.simple(props)
|
|
||||||
.get()
|
|
||||||
.then((res) => res.data as PlayerView)
|
|
||||||
);
|
|
||||||
const submitAction = (action: Action) =>
|
|
||||||
api
|
|
||||||
.simple(props)
|
|
||||||
.post({ action })
|
|
||||||
.then((res) => res.status == 200 && mutate(res.data as PlayerView));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GameContext.Provider value={{ view, submitAction }}>
|
|
||||||
<Show when={view.latest != undefined}>
|
|
||||||
<Pile
|
|
||||||
count={view.latest!.deckCount}
|
|
||||||
class="cursor-pointer fixed center"
|
|
||||||
onClick={() => submitAction({ type: "draw" })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Hand class="fixed bc" hand={view.latest!.myHand} />
|
|
||||||
</Show>
|
|
||||||
</GameContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Component, For, useContext } from "solid-js";
|
|
||||||
import Card from "./Card";
|
|
||||||
import { Hand } from "../../../shared/cards";
|
|
||||||
import { GameContext } from "./Game";
|
|
||||||
import { produce } from "solid-js/store";
|
|
||||||
import { Stylable } from "./toolbox";
|
|
||||||
|
|
||||||
export default ((props) => {
|
|
||||||
const { submitAction, view } = useContext(GameContext)!;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={"hand " + props.class} style={props.style}>
|
|
||||||
<For each={props.hand}>
|
|
||||||
{(card) => (
|
|
||||||
<Card
|
|
||||||
card={card}
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => submitAction({ type: "discard", card })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}) satisfies Component<{ hand: Hand } & Stylable>;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
>;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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));
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { A, useParams } from "@solidjs/router";
|
|
||||||
|
|
||||||
import Game from "../../components/Game";
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
const params = useParams<{ game: string; instance: string }>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Game instanceId={params.instance} />
|
|
||||||
<A href={`/${params.game}`} class="fixed tl m-4 px-2 py-1.5 button">
|
|
||||||
Back
|
|
||||||
</A>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { A, useParams } from "@solidjs/router";
|
|
||||||
|
|
||||||
import { createEffect, createResource, For, Suspense } from "solid-js";
|
|
||||||
import api from "../../api";
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
const param = useParams<{ game: string }>();
|
|
||||||
|
|
||||||
const [instances, { refetch }] = createResource(
|
|
||||||
() => param.game,
|
|
||||||
async () => api.instances.get({ query: param }).then((res) => res.data)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<div style={{ padding: "20px" }}>
|
|
||||||
<p class="text-[40px]">{param.game}</p>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1.5 m-4 button rounded"
|
|
||||||
onClick={() => api.simple.newGame.post().then(refetch)}
|
|
||||||
>
|
|
||||||
New Game
|
|
||||||
</button>
|
|
||||||
<ul>
|
|
||||||
<For each={instances() ?? []}>
|
|
||||||
{(instance) => (
|
|
||||||
<li>
|
|
||||||
<A href={`/${param.game}/${instance.id}`}>
|
|
||||||
{instance.id}
|
|
||||||
</A>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,62 +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;
|
|
||||||
border-radius: 10%;
|
|
||||||
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,19 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "sqlite"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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(2))
|
|
||||||
name String @default("")
|
|
||||||
Instance Instance[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Instance {
|
|
||||||
id String @id @default(cuid(2))
|
|
||||||
createdByKey String
|
|
||||||
createdBy Human @relation(fields: [createdByKey], references: [key])
|
|
||||||
gameKey String
|
|
||||||
game Game @relation(fields: [gameKey], references: [key])
|
|
||||||
gameState Json
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"elysia": "^1.3.8",
|
|
||||||
"object-hash": "^3.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest",
|
|
||||||
"concurrently": "^9.2.0",
|
|
||||||
"prisma": "6.13.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { defineConfig } from "prisma/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: path.join("db", "schema.prisma"),
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { prisma } from "./db/db";
|
|
||||||
import { Elysia, t } from "elysia";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { simpleApi } from "./games/simple";
|
|
||||||
import { human } from "./human";
|
|
||||||
|
|
||||||
const api = new Elysia({ prefix: "/api" })
|
|
||||||
.post("/whoami", async ({ cookie: { token } }) => {
|
|
||||||
if (token.value == null) {
|
|
||||||
const newHuman = await prisma.human.create({
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
token.value = newHuman.key;
|
|
||||||
}
|
|
||||||
return token.value;
|
|
||||||
})
|
|
||||||
.use(human)
|
|
||||||
.post(
|
|
||||||
"/setName",
|
|
||||||
({ body: { name }, humanKey }) =>
|
|
||||||
prisma.human.update({
|
|
||||||
where: {
|
|
||||||
key: humanKey,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
name: t.String(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.get("/profile", ({ humanKey }) =>
|
|
||||||
prisma.human.findFirst({ where: { key: humanKey } })
|
|
||||||
)
|
|
||||||
.get("/games", () => prisma.game.findMany())
|
|
||||||
|
|
||||||
.get("/instances", ({ query: { game } }) =>
|
|
||||||
prisma.instance.findMany({
|
|
||||||
where: {
|
|
||||||
game: {
|
|
||||||
name: game,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
.use(simpleApi);
|
|
||||||
|
|
||||||
export default api;
|
|
||||||
export type Api = typeof api;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"use server";
|
|
||||||
import { prisma } from "./db";
|
|
||||||
|
|
||||||
export const queryAll = async () => await prisma.game.findMany();
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { GameState, newDeck, shuffle } from "../types/cards";
|
|
||||||
import { prisma } from "./db";
|
|
||||||
|
|
||||||
export const queryInstances = async (gameName: string) =>
|
|
||||||
prisma.instance.findMany({ where: { game: { name: gameName } } });
|
|
||||||
|
|
||||||
export const createInstance = (gameName: string) =>
|
|
||||||
prisma.instance.create({
|
|
||||||
data: {
|
|
||||||
gameState: { deck: shuffle(newDeck()), hand: [] } as GameState,
|
|
||||||
game: { connect: { name: gameName } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getGameState = (instanceId: number) =>
|
|
||||||
prisma.instance
|
|
||||||
.findUnique({ where: { id: instanceId } })
|
|
||||||
.then((i) => i?.gameState as GameState | undefined);
|
|
||||||
|
|
||||||
export const updateGameState = async (
|
|
||||||
instanceId: number,
|
|
||||||
gameState: GameState
|
|
||||||
) => prisma.instance.update({ where: { id: instanceId }, data: { gameState } });
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
export const prisma = new PrismaClient();
|
|
||||||
@@ -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,176 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
Hand,
|
|
||||||
newDeck,
|
|
||||||
Pile,
|
|
||||||
shuffle,
|
|
||||||
vCard,
|
|
||||||
} from "../../../shared/cards";
|
|
||||||
import { heq } from "@games/shared/utils";
|
|
||||||
import { Elysia, t } from "elysia";
|
|
||||||
import { prisma } from "../db/db";
|
|
||||||
import { human } from "../human";
|
|
||||||
|
|
||||||
// omniscient game state
|
|
||||||
export type GameState = {
|
|
||||||
prev?: {
|
|
||||||
action: Action;
|
|
||||||
};
|
|
||||||
|
|
||||||
deck: Pile;
|
|
||||||
players: { [humanId: string]: Hand };
|
|
||||||
};
|
|
||||||
|
|
||||||
// a particular player's knowledge of the global game state
|
|
||||||
export type vGameState = {
|
|
||||||
humanId: string;
|
|
||||||
|
|
||||||
deck: Pile<vCard>;
|
|
||||||
players: { [humanId: string]: Hand<vCard> };
|
|
||||||
};
|
|
||||||
|
|
||||||
// a particular player's point of view in the game
|
|
||||||
export type PlayerView = {
|
|
||||||
humanId: string;
|
|
||||||
|
|
||||||
deckCount: number;
|
|
||||||
playerHandCounts: { [humanId: string]: number };
|
|
||||||
myHand: Hand<Card>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Action = { type: "draw" } | { type: "discard"; card: Card };
|
|
||||||
|
|
||||||
export const newGame = (players: string[]) => {
|
|
||||||
console.log("new game called with", JSON.stringify(players));
|
|
||||||
return {
|
|
||||||
deck: shuffle(newDeck()),
|
|
||||||
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
|
|
||||||
} as GameState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getKnowledge = (
|
|
||||||
state: GameState,
|
|
||||||
humanId: string
|
|
||||||
): vGameState => ({
|
|
||||||
humanId,
|
|
||||||
deck: state.deck.map((_) => null),
|
|
||||||
players: Object.fromEntries(
|
|
||||||
Object.entries(state.players).map(([id, hand]) => [
|
|
||||||
id,
|
|
||||||
hand.map(id === humanId ? (card) => card : (_) => null),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getView = (state: vGameState, humanId: string): PlayerView => ({
|
|
||||||
humanId,
|
|
||||||
deckCount: state.deck.length,
|
|
||||||
myHand: state.players[humanId] as Hand,
|
|
||||||
playerHandCounts: Object.fromEntries(
|
|
||||||
Object.entries(state.players)
|
|
||||||
.filter(([id]) => id != humanId)
|
|
||||||
.map(([id, hand]) => [id, hand.length])
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resolveAction = (
|
|
||||||
state: GameState,
|
|
||||||
humanId: string,
|
|
||||||
action: Action
|
|
||||||
): GameState => {
|
|
||||||
// if (action.prevHash != hash(state)) {
|
|
||||||
// throw new Error(
|
|
||||||
// `action thinks it's applying to ${
|
|
||||||
// action.prevHash
|
|
||||||
// }, but we're checking it against ${hash(state)}`
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
const playerHand = state.players[humanId];
|
|
||||||
if (action.type == "draw") {
|
|
||||||
const [drawn, ...rest] = state.deck;
|
|
||||||
return {
|
|
||||||
deck: rest,
|
|
||||||
players: {
|
|
||||||
...state.players,
|
|
||||||
[humanId]: [drawn, ...playerHand],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// action.type == discard
|
|
||||||
const index = playerHand.findIndex(heq(action.card));
|
|
||||||
return {
|
|
||||||
deck: [action.card, ...state.deck],
|
|
||||||
players: {
|
|
||||||
...state.players,
|
|
||||||
[humanId]: playerHand
|
|
||||||
.slice(0, index)
|
|
||||||
.concat(playerHand.slice(index + 1)),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const simpleApi = new Elysia({ prefix: "/simple" })
|
|
||||||
.use(human)
|
|
||||||
.post("/newGame", ({ humanKey }) => {
|
|
||||||
return prisma.instance.create({
|
|
||||||
data: {
|
|
||||||
gameState: newGame([humanKey]),
|
|
||||||
gameKey: "simple",
|
|
||||||
createdByKey: humanKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.group("/:instanceId", (app) =>
|
|
||||||
app
|
|
||||||
.get("/", ({ params: { instanceId }, humanKey }) =>
|
|
||||||
prisma.instance
|
|
||||||
.findUnique({
|
|
||||||
where: {
|
|
||||||
id: instanceId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((game) =>
|
|
||||||
getView(
|
|
||||||
getKnowledge(
|
|
||||||
game!.gameState as GameState,
|
|
||||||
humanKey
|
|
||||||
),
|
|
||||||
humanKey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/",
|
|
||||||
({ params: { instanceId }, body: { action }, humanKey }) =>
|
|
||||||
prisma.instance
|
|
||||||
.findUniqueOrThrow({
|
|
||||||
where: {
|
|
||||||
id: instanceId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(async (game) => {
|
|
||||||
const newState = resolveAction(
|
|
||||||
game.gameState as GameState,
|
|
||||||
humanKey,
|
|
||||||
action
|
|
||||||
);
|
|
||||||
await prisma.instance.update({
|
|
||||||
data: { gameState: newState },
|
|
||||||
where: {
|
|
||||||
id: instanceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return getView(
|
|
||||||
getKnowledge(newState, humanKey),
|
|
||||||
humanKey
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
action: t.Any(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import Elysia from "elysia";
|
|
||||||
|
|
||||||
export const human = new Elysia({ name: "human" })
|
|
||||||
.derive(async ({ cookie: { token }, status }) => {
|
|
||||||
const humanKey = token.value;
|
|
||||||
return humanKey != null ? { humanKey } : status(401);
|
|
||||||
})
|
|
||||||
.as("scoped");
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import api from "./api";
|
|
||||||
import { Elysia, env } from "elysia";
|
|
||||||
import { cors } from "@elysiajs/cors";
|
|
||||||
import { staticPlugin } from "@elysiajs/static";
|
|
||||||
import { error } from "node:console";
|
|
||||||
|
|
||||||
const port = env.PORT || 5001;
|
|
||||||
|
|
||||||
const app = new Elysia()
|
|
||||||
.use(
|
|
||||||
cors({
|
|
||||||
origin: ["localhost:3000", "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,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>
|
||||||
@@ -1,22 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "@games/client",
|
"name": "@games/client",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3000",
|
"dev": "vite --port 3000",
|
||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/eden": "^1.3.2",
|
"@elysiajs/eden": "^1.3.2",
|
||||||
|
"@solid-primitives/memo": "^1.4.3",
|
||||||
"@solid-primitives/scheduled": "^1.5.2",
|
"@solid-primitives/scheduled": "^1.5.2",
|
||||||
|
"@solid-primitives/storage": "^4.3.3",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"color2k": "^2.0.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"kefir": "^3.8.8",
|
||||||
|
"kefir-bus": "^2.3.1",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
"solid-js": "^1.9.5"
|
"solid-js": "^1.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/solar": "^1.2.4",
|
"@iconify-json/solar": "^1.2.4",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/kefir": "^3.8.11",
|
||||||
"@unocss/preset-icons": "^66.4.2",
|
"@unocss/preset-icons": "^66.4.2",
|
||||||
"@unocss/preset-wind4": "^66.4.2",
|
"@unocss/preset-wind4": "^66.4.2",
|
||||||
"unocss": "^66.4.2",
|
"unocss": "^66.4.2",
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
14
pkg/client/src/api.ts
Normal file
14
pkg/client/src/api.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
59
pkg/client/src/app.tsx
Normal file
59
pkg/client/src/app.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { makePersisted } from "@solid-primitives/storage";
|
||||||
|
import { Route, Router } from "@solidjs/router";
|
||||||
|
import pkg from "^/package.json";
|
||||||
|
import { createSignal, lazy, Suspense } from "solid-js";
|
||||||
|
import { render } from "solid-js/web";
|
||||||
|
import "virtual:uno.css";
|
||||||
|
import "./style.css";
|
||||||
|
import { name, setName } from "./profile";
|
||||||
|
|
||||||
|
const Profile = () => {
|
||||||
|
let dialogRef!: HTMLDialogElement;
|
||||||
|
|
||||||
|
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={name()}
|
||||||
|
onChange={(e) => {
|
||||||
|
dialogRef.close();
|
||||||
|
setName(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 />
|
||||||
|
<span class="fixed br m-2 font-mono text-xs">
|
||||||
|
{"v" + pkg.version}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Route path="/" component={lazy(() => import("./routes/index"))} />
|
||||||
|
<Route
|
||||||
|
path="/t/:tableKey"
|
||||||
|
component={lazy(() => import("./routes/[table]"))}
|
||||||
|
/>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(App, document.getElementsByTagName("body")[0]);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, createResource, JSX, Suspense } from "solid-js";
|
import { Component, Suspense } from "solid-js";
|
||||||
|
|
||||||
import { Clickable, Stylable } from "./toolbox";
|
import type { Card } from "@games/shared/cards";
|
||||||
import { Card } from "../../../shared/cards";
|
import { Clickable, Scalable, Stylable } from "./toolbox";
|
||||||
|
|
||||||
const cardToSvgFilename = (card: Card) => {
|
const cardToSvgFilename = (card: Card) => {
|
||||||
if (card.kind == "joker") {
|
if (card.kind == "joker") {
|
||||||
@@ -17,7 +17,12 @@ const cardToSvgFilename = (card: Card) => {
|
|||||||
}`;
|
}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CARD_RATIO = 1.456730769;
|
||||||
|
export const BASE_CARD_WIDTH = 100;
|
||||||
|
|
||||||
export default ((props) => {
|
export default ((props) => {
|
||||||
|
const width = () => BASE_CARD_WIDTH * (props.scale ?? 1);
|
||||||
|
const height = () => width() * CARD_RATIO;
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<img
|
<img
|
||||||
@@ -25,7 +30,8 @@ export default ((props) => {
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
style={props.style}
|
style={props.style}
|
||||||
width="100px"
|
width={`${width()}px`}
|
||||||
|
height={`${height()}px`}
|
||||||
src={
|
src={
|
||||||
props.face == "down"
|
props.face == "down"
|
||||||
? "/views/back.svg"
|
? "/views/back.svg"
|
||||||
@@ -43,5 +49,6 @@ export default ((props) => {
|
|||||||
| { card?: Card; face: "down" }
|
| { card?: Card; face: "down" }
|
||||||
) &
|
) &
|
||||||
Stylable &
|
Stylable &
|
||||||
Clickable
|
Clickable &
|
||||||
|
Scalable
|
||||||
>;
|
>;
|
||||||
30
pkg/client/src/components/FannedHand.tsx
Normal file
30
pkg/client/src/components/FannedHand.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Hand } from "@games/shared/cards";
|
||||||
|
import { For } from "solid-js";
|
||||||
|
import Card from "./Card";
|
||||||
|
import { Stylable } from "./toolbox";
|
||||||
|
|
||||||
|
export default (props: { handCount: number } & Stylable) => {
|
||||||
|
return (
|
||||||
|
<For each={Array(props.handCount)}>
|
||||||
|
{(_, i) => {
|
||||||
|
const midOffset = i() + 0.5 - props.handCount / 2;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
face="down"
|
||||||
|
scale={0.4}
|
||||||
|
style={{
|
||||||
|
"margin-left": "-12px",
|
||||||
|
"margin-right": "-12px",
|
||||||
|
transform: `translate(0px, ${Math.pow(
|
||||||
|
Math.abs(midOffset),
|
||||||
|
2
|
||||||
|
)}px) rotate(${midOffset * 0.12}rad)`,
|
||||||
|
"min-width": "40px",
|
||||||
|
"box-shadow": "-4px 4px 6px rgba(0, 0, 0, 0.6)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
|
};
|
||||||
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;
|
||||||
|
}
|
||||||
26
pkg/client/src/components/Hand.tsx
Normal file
26
pkg/client/src/components/Hand.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
import "./Hand.css";
|
||||||
|
|
||||||
|
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
|
||||||
|
>;
|
||||||
63
pkg/client/src/components/Pile.tsx
Normal file
63
pkg/client/src/components/Pile.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Component, createMemo, For, JSX, Show } from "solid-js";
|
||||||
|
import Card, { BASE_CARD_WIDTH, CARD_RATIO } from "./Card";
|
||||||
|
import { desaturate } from "color2k";
|
||||||
|
|
||||||
|
import { Clickable, hashColor, Scalable, Stylable } from "./toolbox";
|
||||||
|
|
||||||
|
const cardOffset = 0.35; // Small offset for the stack effect
|
||||||
|
|
||||||
|
export default ((props) => {
|
||||||
|
const cards = createMemo(() => {
|
||||||
|
const numCards = Math.max(0, props.count - 1); // Subtract 1 for the top card
|
||||||
|
return Array.from({ length: numCards }, (_, i) => i).toReversed();
|
||||||
|
});
|
||||||
|
const width = () => BASE_CARD_WIDTH * (props.scale ?? 1);
|
||||||
|
const height = () => width() * CARD_RATIO;
|
||||||
|
const offset = () => cardOffset * (props.scale ?? 1);
|
||||||
|
return (
|
||||||
|
<Show when={props.count > 0}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
class={props.class}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="absolute z-[-1]"
|
||||||
|
width={width() + cards().length * offset()}
|
||||||
|
height={height() + cards().length * offset()}
|
||||||
|
viewBox={`0 0 ${width() + cards().length * offset()} ${
|
||||||
|
height() + cards().length * offset()
|
||||||
|
}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<For each={cards()}>
|
||||||
|
{(i) => {
|
||||||
|
const xOffset = (i * offset()) / 2;
|
||||||
|
const yOffset = i * offset();
|
||||||
|
const color = desaturate(hashColor(i), 0.9);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
x={xOffset}
|
||||||
|
y={yOffset}
|
||||||
|
width={width()}
|
||||||
|
height={height()}
|
||||||
|
rx="5" // Rounded corners
|
||||||
|
ry="5"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</svg>
|
||||||
|
<Card onClick={props.onClick} face="down" scale={props.scale} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}) satisfies Component<
|
||||||
|
{
|
||||||
|
count: number;
|
||||||
|
} & Stylable &
|
||||||
|
Clickable &
|
||||||
|
Scalable
|
||||||
|
>;
|
||||||
28
pkg/client/src/components/Player.tsx
Normal file
28
pkg/client/src/components/Player.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { onMount, useContext } from "solid-js";
|
||||||
|
import { playerColor } from "~/profile";
|
||||||
|
import { TableContext } from "./Table";
|
||||||
|
import { Stylable } from "./toolbox";
|
||||||
|
|
||||||
|
export default (props: { playerKey: string } & Stylable) => {
|
||||||
|
const table = useContext(TableContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(e) => table?.setPlayers(props.playerKey, { ref: e })}
|
||||||
|
style={{
|
||||||
|
...props.style,
|
||||||
|
"background-color": playerColor(props.playerKey),
|
||||||
|
...(table?.view() == null && table?.players[props.playerKey].ready
|
||||||
|
? {
|
||||||
|
border: "10px solid green",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
|
||||||
|
>
|
||||||
|
<p class="font-[1em] text-align-center">
|
||||||
|
{table?.players[props.playerKey].name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
199
pkg/client/src/components/Table.tsx
Normal file
199
pkg/client/src/components/Table.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import type { TWsIn, TWsOut } from "@games/server/src/table";
|
||||||
|
import games from "@games/shared/games/index";
|
||||||
|
import { pool, Property, Stream } from "kefir";
|
||||||
|
import {
|
||||||
|
Accessor,
|
||||||
|
createContext,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Setter,
|
||||||
|
Show,
|
||||||
|
} from "solid-js";
|
||||||
|
import { createStore, SetStoreFunction, Store } from "solid-js/store";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import api, { fromWebsocket } from "~/api";
|
||||||
|
import { createObservable, createSynced, cx, extractProperty } from "~/fn";
|
||||||
|
import { me, name } from "~/profile";
|
||||||
|
import GAMES from "./games";
|
||||||
|
import Player from "./Player";
|
||||||
|
|
||||||
|
type PlayerStore = Store<{
|
||||||
|
[key: string]: {
|
||||||
|
name: string;
|
||||||
|
ready: boolean;
|
||||||
|
ref?: HTMLDivElement;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
export const TableContext = createContext<{
|
||||||
|
wsEvents: Stream<TWsOut, any>;
|
||||||
|
sendWs: (msg: TWsIn) => void;
|
||||||
|
|
||||||
|
tableRef: HTMLDivElement;
|
||||||
|
|
||||||
|
gameConfig: Accessor<any>;
|
||||||
|
setGameConfig: Setter<any>;
|
||||||
|
players: PlayerStore;
|
||||||
|
setPlayers: SetStoreFunction<PlayerStore>;
|
||||||
|
|
||||||
|
view: Accessor<any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
export default (props: { tableKey: string }) => {
|
||||||
|
// #region Websocket declaration
|
||||||
|
let ws: ReturnType<ReturnType<typeof api.ws>["subscribe"]> | undefined =
|
||||||
|
undefined;
|
||||||
|
const wsEvents = pool<TWsOut, any>();
|
||||||
|
const sendWs = (msg: TWsIn) => ws?.send(msg);
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region inbound table properties
|
||||||
|
const [players, setPlayers] = createStore<PlayerStore>({});
|
||||||
|
|
||||||
|
wsEvents
|
||||||
|
.thru(extractProperty("playersPresent"))
|
||||||
|
.onValue((P) =>
|
||||||
|
setPlayers(
|
||||||
|
Object.fromEntries(
|
||||||
|
P.map((p) => [
|
||||||
|
p,
|
||||||
|
p in players ? players[p] : { name: "", ready: false },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
wsEvents.thru(extractProperty("playerNames")).onValue((P) =>
|
||||||
|
Object.entries(P)
|
||||||
|
.filter(([player]) => player in players)
|
||||||
|
.map(([player, name]) => setPlayers(player, "name", name))
|
||||||
|
);
|
||||||
|
|
||||||
|
wsEvents.thru(extractProperty("playersReady")).onValue((P) =>
|
||||||
|
Object.entries(P)
|
||||||
|
.filter(([player]) => player in players)
|
||||||
|
.map(([player, ready]) => setPlayers(player, "ready", ready))
|
||||||
|
);
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region inbound game properties
|
||||||
|
const [gameConfig, setGameConfig] = createSynced({
|
||||||
|
ws: wsEvents.thru(extractProperty("gameConfig")) as Property<
|
||||||
|
{ game: string; players: string[] },
|
||||||
|
any
|
||||||
|
>,
|
||||||
|
sendWs: (gameConfig) => sendWs({ gameConfig }),
|
||||||
|
});
|
||||||
|
const view = wsEvents.thru(extractProperty("view")).thru(createObservable);
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
const [ready, setReady] = createSignal(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ws = api.ws(props).subscribe();
|
||||||
|
ws.on("open", () => {
|
||||||
|
wsEvents.plug(fromWebsocket<TWsOut>(ws));
|
||||||
|
|
||||||
|
// TODO: these need to be in a tracking scope to be disposed
|
||||||
|
createEffect(() => sendWs({ ready: ready() }));
|
||||||
|
createEffect(() => sendWs({ name: name() }));
|
||||||
|
});
|
||||||
|
onCleanup(() => ws?.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
const GamePicker = () => {
|
||||||
|
return (
|
||||||
|
<div class="absolute tc mt-8 flex gap-4">
|
||||||
|
<select value={gameConfig()?.game}>
|
||||||
|
<For each={Object.entries(games)}>
|
||||||
|
{([gameId]) => <option value={gameId}>{gameId}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => setReady((prev) => !prev)} class="button p-1 ">
|
||||||
|
{ready() ? "Not Ready" : "Ready"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let tableRef!: HTMLDivElement;
|
||||||
|
return (
|
||||||
|
<TableContext.Provider
|
||||||
|
value={{
|
||||||
|
wsEvents,
|
||||||
|
sendWs,
|
||||||
|
|
||||||
|
tableRef,
|
||||||
|
|
||||||
|
players,
|
||||||
|
setPlayers,
|
||||||
|
gameConfig,
|
||||||
|
setGameConfig,
|
||||||
|
view,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Player avatars around the table */}
|
||||||
|
<div class="flex justify-around p-t-14">
|
||||||
|
<For each={gameConfig()?.players.filter((p) => p != me())}>
|
||||||
|
{(player, i) => {
|
||||||
|
const verticalOffset = () => {
|
||||||
|
const N = gameConfig()!.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>
|
||||||
|
|
||||||
|
{/* The table body itself */}
|
||||||
|
<div
|
||||||
|
ref={tableRef}
|
||||||
|
class={cx(
|
||||||
|
"fixed",
|
||||||
|
|
||||||
|
"bg-radial",
|
||||||
|
"from-orange-950",
|
||||||
|
"to-stone-950",
|
||||||
|
|
||||||
|
"border-4",
|
||||||
|
"border-neutral-950",
|
||||||
|
"shadow-lg",
|
||||||
|
|
||||||
|
"top-40",
|
||||||
|
"bottom-20",
|
||||||
|
"left-[2%]",
|
||||||
|
"right-[2%]"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
"border-radius": "50%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={view() == null}>
|
||||||
|
<GamePicker />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The game being played */}
|
||||||
|
<Dynamic
|
||||||
|
component={
|
||||||
|
gameConfig()?.game ?? "" in GAMES
|
||||||
|
? GAMES[gameConfig()!.game as keyof typeof GAMES]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
pkg/client/src/components/games/index.ts
Normal file
5
pkg/client/src/components/games/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import simple from "./simple";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
simple,
|
||||||
|
};
|
||||||
149
pkg/client/src/components/games/simple.tsx
Normal file
149
pkg/client/src/components/games/simple.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type {
|
||||||
|
SimpleAction,
|
||||||
|
SimplePlayerView,
|
||||||
|
} from "@games/shared/games/simple";
|
||||||
|
import { Accessor, createEffect, For, Show, useContext } from "solid-js";
|
||||||
|
import { Portal } from "solid-js/web";
|
||||||
|
import { me } from "~/profile";
|
||||||
|
import { createObservable, extractProperty } from "../../fn";
|
||||||
|
import FannedHand from "../FannedHand";
|
||||||
|
import Hand from "../Hand";
|
||||||
|
import Pile from "../Pile";
|
||||||
|
import { TableContext } from "../Table";
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const table = useContext(TableContext)!;
|
||||||
|
const view = table.view as Accessor<SimplePlayerView>;
|
||||||
|
|
||||||
|
const Configuration = () => (
|
||||||
|
<Show when={view() == null}>
|
||||||
|
<Portal mount={table.tableRef}>
|
||||||
|
<div class="absolute center grid grid-cols-2 gap-col-2 text-xl">
|
||||||
|
<label for="allow discards" style={{ "text-align": "right" }}>
|
||||||
|
Allow discards
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="allow discards"
|
||||||
|
style={{ width: "50px" }}
|
||||||
|
checked={table.gameConfig()?.["can discard"] ?? false}
|
||||||
|
onChange={(evt) =>
|
||||||
|
table.setGameConfig({
|
||||||
|
...table.gameConfig(),
|
||||||
|
"can discard": evt.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label for="to win" style={{ "text-align": "right" }}>
|
||||||
|
Cards to win
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="to win"
|
||||||
|
style={{
|
||||||
|
"text-align": "center",
|
||||||
|
width: "50px",
|
||||||
|
color: "var(--yellow)",
|
||||||
|
}}
|
||||||
|
value={table.gameConfig()["cards to win"]}
|
||||||
|
onChange={(evt) =>
|
||||||
|
table.setGameConfig({
|
||||||
|
...table.gameConfig(),
|
||||||
|
"cards to win": Number.parseInt(evt.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitAction = (action: SimpleAction) => table.sendWs({ action });
|
||||||
|
const ActiveGame = () => (
|
||||||
|
<Show when={view() != null}>
|
||||||
|
{/* Main pile in the middle of the table */}
|
||||||
|
<Pile
|
||||||
|
count={view().deckCount}
|
||||||
|
scale={0.8}
|
||||||
|
class="cursor-pointer fixed center"
|
||||||
|
onClick={() => submitAction({ type: "draw" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Your own hand */}
|
||||||
|
<Hand
|
||||||
|
class="fixed bc"
|
||||||
|
hand={view().myHand}
|
||||||
|
onClickCard={(card) => submitAction({ type: "discard", card })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Other players' hands */}
|
||||||
|
<For
|
||||||
|
each={Object.entries(view().playerHandCounts).filter(
|
||||||
|
([key, _]) => key in table.players
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{([playerKey, handCount], i) => (
|
||||||
|
<Portal
|
||||||
|
mount={table.players[playerKey].ref}
|
||||||
|
ref={(ref) => {
|
||||||
|
const midOffset =
|
||||||
|
i() + 0.5 - Object.values(view().playerHandCounts).length / 2;
|
||||||
|
|
||||||
|
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%;`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FannedHand handCount={handCount} />
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
{/* Turn indicator */}
|
||||||
|
<div
|
||||||
|
class="absolute tc text-align-center"
|
||||||
|
style={{
|
||||||
|
"background-color":
|
||||||
|
view().playerTurn == me() ? "var(--yellow)" : "transparent",
|
||||||
|
color: view().playerTurn == me() ? "var(--dark)" : "var(--light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
It's{" "}
|
||||||
|
<span class="font-bold">
|
||||||
|
{view().playerTurn == me()
|
||||||
|
? "your"
|
||||||
|
: table.players[view().playerTurn].name + "'s"}
|
||||||
|
</span>{" "}
|
||||||
|
turn
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quit button */}
|
||||||
|
<button
|
||||||
|
class="button fixed tl m-4 p-1"
|
||||||
|
onClick={() => {
|
||||||
|
table.sendWs({ quit: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quit
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = table.wsEvents
|
||||||
|
.thru(extractProperty("results"))
|
||||||
|
.thru(createObservable);
|
||||||
|
const Results = () => (
|
||||||
|
<Show when={results() != null}>
|
||||||
|
<span class="bg-[var(--light)] text-[var(--dark)] rounded-[24px] border-2 border-[var(--dark)] absolute center p-4 shadow-lg text-[4em] text-center">
|
||||||
|
{table.players[results()!].name} won!
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Configuration />
|
||||||
|
<ActiveGame />
|
||||||
|
<Results />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hash, { NotUndefined } from "object-hash";
|
||||||
import { JSX } from "solid-js";
|
import { JSX } from "solid-js";
|
||||||
|
|
||||||
export type Stylable = {
|
export type Stylable = {
|
||||||
@@ -14,3 +15,9 @@ export type Clickable = {
|
|||||||
>
|
>
|
||||||
| undefined;
|
| undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Scalable = {
|
||||||
|
scale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hashColor = (obj: NotUndefined) => `#${hash(obj).substring(0, 6)}`;
|
||||||
70
pkg/client/src/fn.ts
Normal file
70
pkg/client/src/fn.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { createLatest } from "@solid-primitives/memo";
|
||||||
|
import { Observable, Property, Stream } from "kefir";
|
||||||
|
import { Accessor, createEffect, createSignal } from "solid-js";
|
||||||
|
import { createStore } from "solid-js/store";
|
||||||
|
import type { ExtractPropertyType, UnionKeys } from "@games/shared/types";
|
||||||
|
|
||||||
|
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(" ");
|
||||||
|
|
||||||
|
export const createObservableStore =
|
||||||
|
<T extends object = {}>(init: T) =>
|
||||||
|
(obs: Observable<T, any>) => {
|
||||||
|
const [store, setStore] = createStore<T>(init);
|
||||||
|
obs.onValue((val) => setStore(val));
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractProperty =
|
||||||
|
<T extends object, P extends UnionKeys<T>>(property: P) =>
|
||||||
|
(obs: Observable<T, any>): Property<ExtractPropertyType<T, P>, any> =>
|
||||||
|
obs
|
||||||
|
.filter((o) => property in o)
|
||||||
|
.map(
|
||||||
|
(o) => (o as { [K in P]: any })[property] as ExtractPropertyType<T, P>
|
||||||
|
)
|
||||||
|
.toProperty();
|
||||||
|
|
||||||
|
export const createSynced = <T>(p: {
|
||||||
|
ws: Stream<T, any>;
|
||||||
|
sendWs: (o: T) => void;
|
||||||
|
}) => {
|
||||||
|
const [local, setLocal] = createSignal<T>();
|
||||||
|
const remote = createObservable(p.ws.toProperty());
|
||||||
|
createEffect(() => local() !== undefined && p.sendWs(local()!));
|
||||||
|
return [createLatest([local, remote]), setLocal] as const;
|
||||||
|
};
|
||||||
14
pkg/client/src/profile.ts
Normal file
14
pkg/client/src/profile.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { makePersisted } from "@solid-primitives/storage";
|
||||||
|
import hash from "object-hash";
|
||||||
|
import { createResource, createSignal } from "solid-js";
|
||||||
|
import api from "./api";
|
||||||
|
|
||||||
|
export const mePromise = api.whoami.post().then((r) => r.data);
|
||||||
|
export const [me] = createResource(() => mePromise);
|
||||||
|
|
||||||
|
export const playerColor = (humanKey: string) =>
|
||||||
|
"#" + hash(humanKey).substring(0, 6);
|
||||||
|
|
||||||
|
export const [name, setName] = makePersisted(createSignal("__name__"), {
|
||||||
|
name: "name",
|
||||||
|
});
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
pkg/client/src/routes/index.tsx
Normal file
46
pkg/client/src/routes/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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>
|
||||||
|
<a href="https://brainmade.org" target="_blank">
|
||||||
|
<img
|
||||||
|
src="https://brainmade.org/white-logo.svg"
|
||||||
|
class="fixed bl m-2"
|
||||||
|
width="80"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
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: 2em;
|
||||||
|
font-family: Garamond;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1em;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
@@ -12,9 +13,10 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"],
|
"^/*": ["./*"],
|
||||||
"$/*": ["./"],
|
"@games/*": ["./pkg/*"],
|
||||||
"@/*": ["./public/*"]
|
"$/*": ["./pkg/client/*"],
|
||||||
|
"~/*": ["./pkg/client/src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,10 +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",
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import solidPlugin from "vite-plugin-solid";
|
import solidPlugin from "vite-plugin-solid";
|
||||||
import UnoCSS from "unocss/vite";
|
import UnoCSS from "unocss/vite";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [solidPlugin(), UnoCSS()],
|
plugins: [solidPlugin(), UnoCSS()],
|
||||||
@@ -8,4 +9,12 @@ export default defineConfig({
|
|||||||
outDir: "../server/public",
|
outDir: "../server/public",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"^": path.resolve(__dirname, "../../"),
|
||||||
|
"@games": path.resolve(__dirname, "../"),
|
||||||
|
$: path.resolve(__dirname, "./"),
|
||||||
|
"~": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
29
pkg/server/package.json
Normal file
29
pkg/server/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@games/server",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
|
||||||
|
"start": "NODE_ENV=production bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
pkg/server/src/api.ts
Normal file
83
pkg/server/src/api.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { generateTokenAndKey, resolveToken } from "./human";
|
||||||
|
import { err } from "./logging";
|
||||||
|
import { liveTable, WsIn, WsOut } from "./table";
|
||||||
|
import type { ExtractPropertyType, UnionKeys } from "@games/shared/types";
|
||||||
|
|
||||||
|
const api = new Elysia({ prefix: "/api" })
|
||||||
|
.post("/whoami", async ({ cookie: { token } }) => {
|
||||||
|
let key: string | undefined;
|
||||||
|
if (token.value == null || (key = resolveToken(token.value)) == null) {
|
||||||
|
const [newToken, newKey] = generateTokenAndKey();
|
||||||
|
token.set({
|
||||||
|
value: newToken,
|
||||||
|
expires: dayjs().add(1, "year").toDate(),
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
return newKey;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
})
|
||||||
|
.derive(async ({ cookie: { token }, status }) => {
|
||||||
|
const humanKey = token.value && resolveToken(token.value);
|
||||||
|
return humanKey != null ? { humanKey } : status(401);
|
||||||
|
})
|
||||||
|
.ws("/ws/:tableKey", {
|
||||||
|
body: WsIn,
|
||||||
|
response: WsOut,
|
||||||
|
|
||||||
|
open({
|
||||||
|
data: {
|
||||||
|
params: { tableKey },
|
||||||
|
humanKey,
|
||||||
|
},
|
||||||
|
send,
|
||||||
|
}) {
|
||||||
|
const table = liveTable(tableKey);
|
||||||
|
|
||||||
|
table.inputs.connectionChanges.emit({
|
||||||
|
humanKey,
|
||||||
|
presence: "joined",
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries({
|
||||||
|
...table.outputs.global,
|
||||||
|
...(table.outputs.player[humanKey] ?? {}),
|
||||||
|
}).forEach(([type, stream]) =>
|
||||||
|
// @ts-ignore
|
||||||
|
stream.onValue((v) => send({ [type]: v }))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
message(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
humanKey,
|
||||||
|
params: { tableKey },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body
|
||||||
|
) {
|
||||||
|
liveTable(tableKey).inputs.messages.emit({ ...body, humanKey });
|
||||||
|
},
|
||||||
|
|
||||||
|
close({
|
||||||
|
data: {
|
||||||
|
params: { tableKey },
|
||||||
|
humanKey,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
liveTable(tableKey).inputs.connectionChanges.emit({
|
||||||
|
humanKey,
|
||||||
|
presence: "left",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
error(error) {
|
||||||
|
err(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
export type Api = typeof api;
|
||||||
17
pkg/server/src/human.ts
Normal file
17
pkg/server/src/human.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const tokenToHumanKey: { [token: string]: string } = {};
|
||||||
|
const playerKeys: Set<string> = new Set();
|
||||||
|
|
||||||
|
export const generateTokenAndKey = () => {
|
||||||
|
let token: string, key: string;
|
||||||
|
do {
|
||||||
|
[token, key] = [crypto.randomUUID(), crypto.randomUUID()];
|
||||||
|
tokenToHumanKey[token] = key;
|
||||||
|
playerKeys.add(key);
|
||||||
|
} while (!(token in tokenToHumanKey || playerKeys.has(key)));
|
||||||
|
return [token, key];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveToken = (token: string) =>
|
||||||
|
tokenToHumanKey[token] as string | undefined;
|
||||||
|
export const tokenExists = (token: string) => token in tokenToHumanKey;
|
||||||
|
export const keyExists = (key: string) => playerKeys.has(key);
|
||||||
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
318
pkg/server/src/table.ts
Normal file
318
pkg/server/src/table.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import GAMES, { Game, GameKey } from "@games/shared/games";
|
||||||
|
import {
|
||||||
|
invert,
|
||||||
|
isEmpty,
|
||||||
|
multiScan,
|
||||||
|
partition,
|
||||||
|
set,
|
||||||
|
setDiff,
|
||||||
|
ValueWithin,
|
||||||
|
} from "@games/shared/kefirs";
|
||||||
|
import { t } from "elysia";
|
||||||
|
import { combine, constant, merge, Observable, pool, Property } from "kefir";
|
||||||
|
import Bus, { type Bus as TBus } from "kefir-bus";
|
||||||
|
import { log } from "./logging";
|
||||||
|
import simple from "@games/shared/games/simple";
|
||||||
|
|
||||||
|
const DEFAULT_GAME_CONFIG = simple.defaultConfig;
|
||||||
|
|
||||||
|
export const WsOut = t.Union([
|
||||||
|
t.Object({ playersPresent: t.Array(t.String()) }),
|
||||||
|
t.Object({ playerNames: t.Record(t.String(), t.String()) }),
|
||||||
|
t.Object({ playersReady: t.Record(t.String(), t.Boolean()) }),
|
||||||
|
t.Object({
|
||||||
|
gameConfig: t.Object({ game: t.String(), players: t.Array(t.String()) }),
|
||||||
|
}),
|
||||||
|
t.Object({ view: t.Any() }),
|
||||||
|
t.Object({ results: t.Any() }),
|
||||||
|
]);
|
||||||
|
export type TWsOut = typeof WsOut.static;
|
||||||
|
export const WsIn = t.Union([
|
||||||
|
t.Object({ name: t.String() }),
|
||||||
|
t.Object({
|
||||||
|
gameConfig: t.Object({ game: t.String(), players: t.Array(t.String()) }),
|
||||||
|
}),
|
||||||
|
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,
|
||||||
|
GameResult = 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>;
|
||||||
|
results: Property<GameResult, any>;
|
||||||
|
playerNames: Property<{ [key: string]: string }, 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,
|
||||||
|
GameResult
|
||||||
|
>(
|
||||||
|
key: string
|
||||||
|
) => {
|
||||||
|
if (!(key in tables)) {
|
||||||
|
const inputs: TablePayload<
|
||||||
|
GameConfig,
|
||||||
|
GameState,
|
||||||
|
GameAction,
|
||||||
|
GameResult
|
||||||
|
>["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: withGame(gameState)
|
||||||
|
.map(
|
||||||
|
([state, game]) =>
|
||||||
|
state && (game.getView({ state, humanKey: p }) as GameView)
|
||||||
|
)
|
||||||
|
.toProperty(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
removed.forEach((p) => {
|
||||||
|
delete playerStreams[p];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
ready,
|
||||||
|
action,
|
||||||
|
quit,
|
||||||
|
gameConfig: clientGameConfigs,
|
||||||
|
} = partition(
|
||||||
|
["name", "ready", "action", "quit", "gameConfig"],
|
||||||
|
messages
|
||||||
|
) as unknown as {
|
||||||
|
// yuck
|
||||||
|
name: Observable<Attributed & { name: string }, any>;
|
||||||
|
ready: Observable<Attributed & { ready: boolean }, any>;
|
||||||
|
action: Observable<Attributed & { action: GameAction }, any>;
|
||||||
|
quit: Observable<Attributed, any>;
|
||||||
|
gameConfig: Observable<Attributed & { gameConfig: GameConfig }, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gameEnds = quit.map((_) => null);
|
||||||
|
|
||||||
|
const gameIsActivePool = pool<boolean, any>();
|
||||||
|
const gameIsActive = merge([
|
||||||
|
constant(false),
|
||||||
|
gameIsActivePool,
|
||||||
|
]).toProperty();
|
||||||
|
|
||||||
|
const playersReady = multiScan(
|
||||||
|
null as {
|
||||||
|
[key: string]: boolean;
|
||||||
|
} | null,
|
||||||
|
[
|
||||||
|
playersPresent.filterBy(invert(gameIsActive)),
|
||||||
|
(prev, players: ValueWithin<typeof playersPresent>) =>
|
||||||
|
Object.fromEntries(players.map((p) => [p, prev?.[p] ?? false])),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
ready.filterBy(invert(gameIsActive)),
|
||||||
|
(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 playerNames = name
|
||||||
|
.scan(
|
||||||
|
(prev, n) => ({ ...prev, [n.humanKey]: n.name }),
|
||||||
|
{} as { [key: string]: string }
|
||||||
|
)
|
||||||
|
.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].impl(config))
|
||||||
|
.toProperty();
|
||||||
|
|
||||||
|
const withGame = <T>(obs: Observable<T, any>) =>
|
||||||
|
combine([obs], [gameImpl], (o, game) => [o, game] as const);
|
||||||
|
|
||||||
|
const resultsPool = pool<GameResult | null, any>();
|
||||||
|
const results = merge([constant(null), resultsPool]).toProperty();
|
||||||
|
|
||||||
|
const gameState = multiScan(
|
||||||
|
null as GameState | null,
|
||||||
|
[
|
||||||
|
// initialize game state when started
|
||||||
|
gameImpl.sampledBy(gameStarts).filterBy(invert(gameIsActive)),
|
||||||
|
(prev, game: ValueWithin<typeof gameImpl>) =>
|
||||||
|
prev || (game.init() as GameState),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// handle actions from players
|
||||||
|
action.filterBy(gameIsActive).thru(withGame),
|
||||||
|
(
|
||||||
|
prev,
|
||||||
|
[{ action, humanKey }, game]: [
|
||||||
|
Attributed & { action: GameAction },
|
||||||
|
ReturnType<Game["impl"]>
|
||||||
|
]
|
||||||
|
) =>
|
||||||
|
prev &&
|
||||||
|
(game.resolveAction({
|
||||||
|
state: prev,
|
||||||
|
action,
|
||||||
|
humanKey,
|
||||||
|
}) as GameState),
|
||||||
|
],
|
||||||
|
[results.filterBy(gameIsActive), () => null], // handle game ending criteria
|
||||||
|
[quit, () => null] // handle players leaving the room
|
||||||
|
).toProperty();
|
||||||
|
|
||||||
|
resultsPool.plug(
|
||||||
|
gameState
|
||||||
|
.filter((state) => state != null)
|
||||||
|
.thru(withGame)
|
||||||
|
.map(([state, game]) => game.getResult(state) as unknown as GameResult)
|
||||||
|
.filter((result) => result != null)
|
||||||
|
.merge(quit.map(() => null))
|
||||||
|
);
|
||||||
|
|
||||||
|
gameIsActivePool.plug(
|
||||||
|
combine([gameState, results])
|
||||||
|
.map(([state, result]) => state != null && result == null)
|
||||||
|
.skipDuplicates()
|
||||||
|
.toProperty()
|
||||||
|
);
|
||||||
|
|
||||||
|
gameConfigPool.plug(
|
||||||
|
multiScan(
|
||||||
|
DEFAULT_GAME_CONFIG,
|
||||||
|
[
|
||||||
|
playersPresent.filterBy(gameIsActive.thru(invert)),
|
||||||
|
(prev, players) => ({
|
||||||
|
...prev,
|
||||||
|
players,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
clientGameConfigs.filterBy(gameIsActive.thru(invert)),
|
||||||
|
// @ts-ignore
|
||||||
|
(prev, { gameConfig }) => ({ ...gameConfig, players: prev.players }),
|
||||||
|
]
|
||||||
|
) as unknown as Observable<GameConfig, any>
|
||||||
|
);
|
||||||
|
|
||||||
|
tables[key] = {
|
||||||
|
inputs,
|
||||||
|
outputs: {
|
||||||
|
global: {
|
||||||
|
playersPresent,
|
||||||
|
playersReady,
|
||||||
|
gameConfig,
|
||||||
|
results,
|
||||||
|
playerNames,
|
||||||
|
},
|
||||||
|
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>;
|
||||||
|
};
|
||||||
34
pkg/shared/games/index.ts
Normal file
34
pkg/shared/games/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import simple from "./simple";
|
||||||
|
|
||||||
|
export type Game<
|
||||||
|
S = unknown, // state
|
||||||
|
A = unknown, // action
|
||||||
|
E = unknown, // error
|
||||||
|
V = unknown, // view
|
||||||
|
R = unknown, // results
|
||||||
|
C extends { game: string; players: string[] } = {
|
||||||
|
game: string;
|
||||||
|
players: string[];
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
impl: (config: C) => {
|
||||||
|
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;
|
||||||
|
getResult: (state: S) => R | undefined;
|
||||||
|
};
|
||||||
|
defaultConfig: C;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GAMES: {
|
||||||
|
[key: string]: Game;
|
||||||
|
} = {
|
||||||
|
// renaissance,
|
||||||
|
simple,
|
||||||
|
};
|
||||||
|
export default GAMES;
|
||||||
|
|
||||||
|
export type GameKey = string;
|
||||||
158
pkg/shared/games/simple.ts
Normal file
158
pkg/shared/games/simple.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
|
||||||
|
import { heq } from "@games/shared/utils";
|
||||||
|
import type { Game } from ".";
|
||||||
|
import { XOR } from "ts-xor";
|
||||||
|
|
||||||
|
export type SimpleConfiguration = {
|
||||||
|
game: "simple";
|
||||||
|
players: string[];
|
||||||
|
"can discard": boolean;
|
||||||
|
"cards to win": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// type SimpleError = XOR<
|
||||||
|
// { "go away": string },
|
||||||
|
// { chill: string },
|
||||||
|
// { "ah ah": string }
|
||||||
|
// >;
|
||||||
|
type SimpleError = {
|
||||||
|
class: "go away" | "chill" | "ah ah";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSimpleAction = ({
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
action,
|
||||||
|
humanKey,
|
||||||
|
}: {
|
||||||
|
config: SimpleConfiguration;
|
||||||
|
state: SimpleGameState;
|
||||||
|
action: SimpleAction;
|
||||||
|
humanKey: string;
|
||||||
|
}): SimpleGameState => {
|
||||||
|
const playerHand = state.playerHands[humanKey];
|
||||||
|
|
||||||
|
if (playerHand == null) {
|
||||||
|
throw {
|
||||||
|
message: "You are not a part of this game!",
|
||||||
|
class: "go away",
|
||||||
|
} satisfies SimpleError;
|
||||||
|
}
|
||||||
|
if (humanKey != config.players[state.turnIdx]) {
|
||||||
|
throw {
|
||||||
|
message: "It's not your turn!",
|
||||||
|
class: "chill",
|
||||||
|
} satisfies SimpleError;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (config["can discard"] == false) {
|
||||||
|
throw {
|
||||||
|
message: "You're not allowed to discard!",
|
||||||
|
class: "ah ah",
|
||||||
|
} satisfies SimpleError;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SimpleResult = string;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
defaultConfig: {
|
||||||
|
game: "simple",
|
||||||
|
players: [],
|
||||||
|
"can discard": true,
|
||||||
|
"cards to win": 5,
|
||||||
|
},
|
||||||
|
impl: (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,
|
||||||
|
getResult: (state) =>
|
||||||
|
Object.entries(state.playerHands).find(
|
||||||
|
([_, hand]) => hand.length === config["cards to win"]
|
||||||
|
)?.[0],
|
||||||
|
}),
|
||||||
|
} satisfies Game<
|
||||||
|
SimpleGameState,
|
||||||
|
SimpleAction,
|
||||||
|
SimpleError,
|
||||||
|
SimplePlayerView,
|
||||||
|
SimpleResult,
|
||||||
|
SimpleConfiguration
|
||||||
|
>;
|
||||||
54
pkg/shared/kefirs.ts
Normal file
54
pkg/shared/kefirs.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
export const invert = <E>(obs: Observable<boolean, E>) => obs.map((o) => !o);
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
pkg/shared/types.ts
Normal file
9
pkg/shared/types.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type UnionKeys<T> = T extends any ? keyof T : never;
|
||||||
|
export type ExtractPropertyType<
|
||||||
|
T,
|
||||||
|
P extends string | number | symbol
|
||||||
|
> = T extends {
|
||||||
|
[K in P]: any;
|
||||||
|
}
|
||||||
|
? T[P]
|
||||||
|
: never;
|
||||||
2601
pnpm-lock.yaml
generated
2601
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