Compare commits

...

30 Commits

Author SHA1 Message Date
5e9b145066 version bump 2025-09-08 22:54:43 -04:00
7d45a36f93 fix div bug 2025-09-08 22:54:10 -04:00
fb3f567a5b bugs bashed 2025-09-08 22:45:42 -04:00
41bd1fce38 add brainmade logo 2025-09-08 21:29:19 -04:00
ed42c831de [wip] gameplay works but config not respected 2025-09-07 23:31:19 -04:00
ae6a79aadd [wip] configs are synced but gameplay is broken 2025-09-07 22:56:56 -04:00
46002403c8 really solid 2025-09-06 23:22:58 -04:00
b854fec9e5 [wip] extractProperty proper typing with union types 2025-09-06 17:30:47 -04:00
bedafb0b7c portal to decorate players by ref instead of ID 2025-09-06 16:54:03 -04:00
9e3697ffef better plumbing for games frontend 2025-09-04 23:48:49 -04:00
b3e040f03f errors 2025-09-03 22:50:32 -04:00
46e7b60ade deploy 2025-09-03 21:16:19 -04:00
ce29ab72ae scaling 2025-09-03 21:14:54 -04:00
d203fe4141 better fanned hands 2025-09-02 23:21:48 -04:00
67fdf66cd4 depth for card piles 2025-09-02 22:38:04 -04:00
9919b97931 hotfix: version bump 2025-09-01 22:55:01 -04:00
fd342e7d47 no db 2025-09-01 22:53:57 -04:00
b433a26fc6 result states 2025-08-31 22:23:30 -04:00
0ea16ead64 hotfix: font size in em 2025-08-31 10:06:46 -04:00
11f21221ee we did it 2025-08-30 22:30:31 -04:00
01a12ec58a [wip] so close; check the ws messages 2025-08-30 18:24:08 -04:00
782dd738cc [wip] im tired boss 2025-08-30 15:49:55 -04:00
5e33e33cce [wip] kefir cleanup 2025-08-29 23:50:23 -04:00
90be478e9a css cleanup 2025-08-29 21:57:03 -04:00
f38a5a69df package -> pkg 2025-08-29 20:54:18 -04:00
0d6d3d6d32 starting to abstract the game 2025-08-28 22:20:24 -04:00
d69336027a holding cards, kinda 2025-08-27 23:04:49 -04:00
686529507e basic hand viewing 2025-08-26 21:42:44 -04:00
aeb7d9174b quitting working but suspiciously 2025-08-26 19:13:09 -04:00
e5f432dc98 housekeeping 2025-08-26 18:37:27 -04:00
83 changed files with 3658 additions and 1344 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@
.vinxi .vinxi
*.db *.db
.DS_STORE
# ---> Node # ---> Node
# Logs # Logs
logs logs

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

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

View File

@@ -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
View 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
View 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"

View File

@@ -1,7 +1,7 @@
{ {
"name": "games", "name": "games",
"type": "module", "type": "module",
"version": "0.0.4", "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",
@@ -12,11 +12,7 @@
"object-hash": "^3.0.0" "object-hash": "^3.0.0"
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "esbuild"
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma"
] ]
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,45 +0,0 @@
import { Accessor, createContext, useContext } from "solid-js";
import {
SimpleAction,
SimplePlayerView,
} from "../../../server/src/games/simple";
import { me, profile } from "../profile";
import Hand from "./Hand";
import Pile from "./Pile";
import { TableContext } from "./Table";
export const GameContext = createContext<{
view: Accessor<SimplePlayerView>;
submitAction: (action: SimpleAction) => any;
}>();
export default () => {
const table = useContext(TableContext)!;
const view = table.view as Accessor<SimplePlayerView>;
const submitAction = (action: SimpleAction) => table.sendWs({ action });
return (
<GameContext.Provider value={{ view, submitAction }}>
<Pile
count={view().deckCount}
class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })}
/>
<Hand
class="fixed bc"
hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })}
/>
<div class="absolute tc text-align-center">
It's{" "}
<span class="font-bold">
{view().playerTurn == me()
? "your"
: profile(view().playerTurn)()?.name + "'s"}
</span>{" "}
turn
</div>
</GameContext.Provider>
);
};

View File

@@ -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
>;

View File

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

View File

@@ -1,120 +0,0 @@
import {
Accessor,
createContext,
createEffect,
createResource,
createSignal,
For,
onCleanup,
Show,
} from "solid-js";
import { TWsIn, TWsOut } from "../../../server/src/table";
import api, { fromWebsocket } from "../api";
import { createObservable, createObservableWithInit, cx } from "../fn";
import { me } from "../profile";
import Game from "./Game";
import Player from "./Player";
import { fromPromise, Stream } from "kefir";
export const TableContext = createContext<{
view: Accessor<any>;
sendWs: (msg: TWsIn) => void;
wsEvents: Stream<TWsOut, any>;
}>();
export default (props: { tableKey: string }) => {
const wsPromise = new Promise<
ReturnType<ReturnType<typeof api.ws>["subscribe"]>
>((res) => {
const ws = api.ws(props).subscribe();
ws.on("open", () => res(ws));
ws.on("error", () => res(ws));
});
const sendWs = (msg: TWsIn) => wsPromise.then((ws) => ws.send(msg));
const wsEvents = fromPromise(wsPromise).flatMap((ws) =>
fromWebsocket<TWsOut>(ws)
);
onCleanup(() => wsPromise.then((ws) => ws.close()));
const presenceEvents = wsEvents.filter((evt) => evt.players != null);
const gameEvents = wsEvents.filter((evt) => evt.view != null);
const players = createObservableWithInit<string[]>(
presenceEvents.map((evt) => evt.players!),
[]
);
const [ready, setReady] = createSignal(false);
createEffect(() => sendWs({ ready: ready() }));
const view = createObservable(gameEvents.map((evt) => evt.view));
return (
<TableContext.Provider
value={{
sendWs,
wsEvents,
view,
}}
>
<div class="flex justify-around p-t-10">
<For each={players().filter((p) => p != me())}>
{(player, i) => {
const verticalOffset = () => {
const N = players().length - 1;
const x = Math.abs((2 * i() + 1) / (N * 2) - 0.5);
const y = Math.sqrt(1 - x * x);
return 1 - y;
};
return (
<Player
playerKey={player}
style={{
transform: `translate(0, ${
verticalOffset() * 150
}vh)`,
}}
/>
);
}}
</For>
</div>
<div
id="table"
class={cx(
"fixed",
"bg-radial",
"from-orange-950",
"to-stone-950",
"border-4",
"border-neutral-950",
"shadow-lg",
"top-40",
"bottom-20",
"left-10",
"right-10"
)}
style={{
"border-radius": "50%",
}}
>
<Show when={view() == null}>
<div class="absolute center">
<button
onClick={() => setReady((prev) => !prev)}
class="button p-1 "
>
{ready() ? "Not Ready" : "Ready"}
</button>
</div>
</Show>
</div>
<Show when={view() != null}>
<Game />
</Show>
</TableContext.Provider>
);
};

View File

@@ -1,39 +0,0 @@
import { Observable } from "kefir";
import { Accessor, createSignal } from "solid-js";
declare global {
interface Array<T> {
thru<S>(fn: (arr: T[]) => S): S;
}
}
Array.prototype.thru = function <T, S>(this: T[], fn: (arr: T[]) => S) {
return fn(this);
};
export const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o));
export type ApiType<T extends () => Promise<{ data: any }>> = Awaited<
ReturnType<T>
>["data"];
export type WSEvent<
T extends { subscribe: (handler: (...args: any[]) => any) => any }
> = Parameters<Parameters<T["subscribe"]>[0]>[0];
export const createObservable = <T>(obs: Observable<T, any>) => {
const [signal, setSignal] = createSignal<T>();
obs.onValue((val) => setSignal(() => val));
return signal;
};
export const createObservableWithInit = <T>(
obs: Observable<T, any>,
init: T
) => {
const [signal, setSignal] = createSignal<T>(init);
obs.onValue((val) => setSignal(() => val));
return signal;
};
export const cx = (...classes: string[]) => classes.join(" ");

View File

@@ -1,26 +0,0 @@
import { createResource, Resource } from "solid-js";
import { ApiType } from "./fn";
import api from "./api";
import hash from "object-hash";
export const mePromise = api.whoami.post().then((r) => r.data);
export const [me] = createResource(() => mePromise);
const playerProfiles: {
[humanKey: string]: Resource<ApiType<typeof api.profile.get>>;
} = {};
export const profile = (humanKey: string) => {
if (!(humanKey in playerProfiles)) {
playerProfiles[humanKey] = createResource(() =>
api.profile
.get({ query: { otherHumanKey: humanKey } })
.then((r) => r.data)
)[0];
}
return playerProfiles[humanKey];
};
export const playerColor = (humanKey: string) =>
"#" + hash(humanKey).substring(0, 6);

View File

@@ -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>
</>
);
};

View File

@@ -1,33 +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">New Game</button>
<ul>
<For each={instances() ?? []}>
{(instance) => (
<li>
<A href={`/${param.game}/${instance.id}`}>
{instance.id}
</A>
</li>
)}
</For>
</ul>
</div>
</Suspense>
);
};

View File

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

View File

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

View File

@@ -1,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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,45 +0,0 @@
/*
Warnings:
- The required column `token` was added to the `Human` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- CreateTable
CREATE TABLE "_HumanToInstance" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_HumanToInstance_A_fkey" FOREIGN KEY ("A") REFERENCES "Human" ("key") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_HumanToInstance_B_fkey" FOREIGN KEY ("B") REFERENCES "Instance" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Human" (
"key" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '__name__',
"lastActive" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
DROP TABLE "Human";
ALTER TABLE "new_Human" RENAME TO "Human";
CREATE UNIQUE INDEX "Human_token_key" ON "Human"("token");
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdByKey" TEXT NOT NULL,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("createdByKey", "gameKey", "gameState", "id") SELECT "createdByKey", "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "_HumanToInstance_AB_unique" ON "_HumanToInstance"("A", "B");
-- CreateIndex
CREATE INDEX "_HumanToInstance_B_index" ON "_HumanToInstance"("B");

View File

@@ -1,15 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdByKey" TEXT NOT NULL,
"gameKey" TEXT NOT NULL,
"gameState" JSONB,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("createdByKey", "gameKey", "gameState", "id") SELECT "createdByKey", "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -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"

View File

@@ -1,37 +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())
token String @unique @default(cuid())
name String @default("__name__")
lastActive DateTime @default(now())
playingInstances Instance[]
}
model Instance {
id String @id @default(cuid())
createdByKey String
gameKey String
players Human[]
game Game @relation(fields: [gameKey], references: [key])
gameState Json?
}

View File

@@ -1,6 +0,0 @@
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: path.join("db", "schema.prisma"),
});

View File

@@ -1,147 +0,0 @@
import { Elysia, t } from "elysia";
import {
getSimplePlayerView,
SimpleAction,
SimpleConfiguration,
SimpleGameState,
} from "./games/simple";
import { human } from "./human";
import dayjs from "dayjs";
import db from "./db";
import { liveTable, WsOut, WsIn } from "./table";
import { Human } from "@prisma/client";
import _ from "lodash";
import { combine } from "kefir";
const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => {
let human: Human | null;
if (
token.value == null ||
(human = await db.human.findUnique({
where: {
token: token.value,
},
})) == null
) {
human = await db.human.create({
data: {},
});
token.set({
value: human.token,
expires: dayjs().add(1, "year").toDate(),
httpOnly: true,
});
}
return human.key;
})
.use(human)
.post(
"/setName",
({ body: { name }, humanKey }) =>
db.human.update({
where: {
key: humanKey,
},
data: {
name,
},
}),
{
body: t.Object({
name: t.String(),
}),
}
)
.get("/profile", ({ humanKey, query: { otherHumanKey } }) =>
db.human
.findFirst({ where: { key: otherHumanKey ?? humanKey } })
.then((human) => {
if (human == null) {
return null;
}
const { token, ...safeProfile } = human;
return safeProfile;
})
)
.get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/ws/:tableKey", {
async open({
data: {
params: { tableKey },
humanKey,
},
send,
}) {
const table = liveTable<
SimpleConfiguration,
SimpleGameState,
SimpleAction
>(tableKey);
table.inputs.connectionChanges.emit({
humanKey,
presence: "joined",
});
table.outputs.playersPresent.onValue((players) =>
send({ players })
);
table.outputs.playersReady.onValue((readys) =>
send({ playersReady: readys })
);
combine(
[table.outputs.gameState],
[table.outputs.gameConfig],
(state, config) =>
state &&
config &&
getSimplePlayerView(config, state, humanKey)
)
.toProperty()
.onValue((view) => send({ view }));
},
response: WsOut,
body: WsIn,
message(
{
data: {
humanKey,
params: { tableKey },
},
},
body
) {
const {
inputs: { readys, actions, quits },
} = liveTable(tableKey);
if ("ready" in body) {
readys.emit({ humanKey, ...body });
} else if ("action" in body) {
actions.emit({ humanKey, ...body.action });
} else if ("quit" in body) {
quits.emit({ humanKey });
}
},
async close({
data: {
params: { tableKey },
humanKey,
},
}) {
liveTable(tableKey).inputs.connectionChanges.emit({
humanKey,
presence: "left",
});
},
});
export default api;
export type Api = typeof api;

View File

@@ -1,5 +0,0 @@
"use server";
import { PrismaClient } from "@prisma/client";
export default new PrismaClient();

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
import { merge, Observable } from "kefir";
export const transform = <
T,
Mutations extends [Observable<any, any>, (prev: T, evt: any) => T][]
>(
initValue: T,
...mutations: Mutations
): Observable<T, unknown> =>
merge(
mutations.map(([source, mutation]) =>
source.map((event) => ({ event, mutation }))
)
).scan((prev, { event, mutation }) => mutation(prev, event), initValue);
export const partition =
<C extends readonly [...string[]], T, E>(
classes: C,
partitionFn: (v: T) => C[number]
) =>
(obs: Observable<T, E>) => {
const assigned = obs.map((obj) => ({ obj, cls: partitionFn(obj) }));
return Object.fromEntries(
classes.map((C) => [
C,
assigned.filter(({ cls }) => cls == C).map(({ obj }) => obj),
])
);
};

View File

@@ -1,186 +0,0 @@
import { t } from "elysia";
import { combine, pool, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus";
import {
newSimpleGameState,
resolveSimpleAction,
SimpleAction,
SimpleConfiguration,
SimpleGameState,
} from "./games/simple";
import { transform } from "./kefir-extension";
export const WsOut = t.Object({
players: t.Optional(t.Array(t.String())),
playersReady: t.Optional(t.Record(t.String(), t.Boolean())),
view: t.Optional(t.Any()),
});
export type TWsOut = typeof WsOut.static;
export const WsIn = t.Union([
t.Object({ ready: t.Boolean() }),
t.Object({ action: t.Any() }),
t.Object({ quit: t.Literal(true) }),
]);
export type TWsIn = typeof WsIn.static;
type Attributed = { humanKey: string };
type TablePayload<GameConfig, GameState, GameAction> = {
inputs: {
connectionChanges: TBus<
Attributed & { presence: "joined" | "left" },
never
>;
readys: TBus<Attributed & { ready: boolean }, never>;
actions: TBus<Attributed & GameAction, never>;
quits: TBus<Attributed, never>;
};
outputs: {
playersPresent: Property<string[], never>;
playersReady: Property<{ [key: string]: boolean }, unknown>;
gameConfig: Property<GameConfig | null, never>;
gameState: Property<GameState | null, never>;
};
};
const tables: {
[key: string]: TablePayload<unknown, unknown, unknown>;
} = {};
export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
if (!(key in tables)) {
const inputs: TablePayload<
GameConfig,
GameState,
GameAction
>["inputs"] = {
connectionChanges: Bus(),
readys: Bus(),
actions: Bus(),
quits: Bus(),
};
const { connectionChanges, readys, actions, quits } = inputs;
// =======
const playersPresent = connectionChanges
.scan((prev, evt) => {
if (evt.presence == "left" && prev[evt.humanKey] == 1) {
const { [evt.humanKey]: _, ...rest } = prev;
return rest;
}
return {
...prev,
[evt.humanKey]:
(prev[evt.humanKey] ?? 0) +
(evt.presence == "joined" ? 1 : -1),
};
}, {} as { [key: string]: number })
.map((counts) => Object.keys(counts))
.toProperty();
const playersReady = transform(
{} as { [key: string]: boolean },
[
playersPresent,
(prev, players: string[]) =>
Object.fromEntries(
players.map((p) => [p, prev[p] ?? false])
),
],
[
readys,
(prev, evt: { humanKey: string; ready: boolean }) =>
prev[evt.humanKey] != null
? { ...prev, [evt.humanKey]: evt.ready }
: prev,
]
)
.toProperty()
.log("playersReady");
const gameStarts = playersReady
.filter(
(pr) =>
Object.values(pr).length > 0 &&
Object.values(pr).every((ready) => ready)
)
.map((_) => null)
.log("gameStarts");
const gameConfigPool = pool<
{
game: string;
players: string[];
},
never
>();
const gameConfig = gameConfigPool.toProperty();
const gameState = transform(
null as SimpleGameState | null,
[
combine([gameStarts], [gameConfigPool], (_, config) => config),
(prev, startConfig: SimpleConfiguration) =>
prev == null ? newSimpleGameState(startConfig) : prev,
],
[
combine([actions], [gameConfigPool], (action, config) => ({
action,
config,
})),
(
prev,
evt: {
action: Attributed & SimpleAction;
config: SimpleConfiguration;
}
) =>
prev != null
? resolveSimpleAction({
config: evt.config,
state: prev,
action: evt.action,
humanKey: evt.action.humanKey,
})
: prev,
]
).toProperty();
const gameIsActive = gameState
.map((gs) => gs != null)
.skipDuplicates()
.toProperty()
.log("gameIsActive");
gameConfigPool.plug(
playersPresent
.filterBy(gameIsActive.map((active) => !active))
.map((players) => ({
game: "simple",
players,
}))
);
tables[key] = {
inputs,
outputs: {
playersPresent,
playersReady,
gameConfig: gameConfig as Property<unknown, never>,
gameState: gameState as Property<unknown, never>,
},
};
// cleanup
tables[key].outputs.playersPresent
.debounce(30000, { immediate: false })
.filter((players) => players.length === 0)
.skip(1)
.onValue((_) => {
console.log("DELETING LIVE TABLE");
delete tables[key];
});
}
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
};

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,11 @@
}, },
"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": "^3.8.8",
"kefir-bus": "^2.3.1", "kefir-bus": "^2.3.1",

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,8 +1,6 @@
import { createResource } from "solid-js";
import { type Api } from "../../server/src/api";
import { treaty } from "@elysiajs/eden"; import { treaty } from "@elysiajs/eden";
import { EdenWS } from "@elysiajs/eden/treaty";
import { fromEvents } from "kefir"; import { fromEvents } from "kefir";
import { type Api } from "@games/server/src/api";
const { api } = treaty<Api>( const { api } = treaty<Api>(
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin, import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
@@ -13,6 +11,4 @@ const { api } = treaty<Api>(
export default api; export default api;
export const fromWebsocket = <T>(ws: any) => export const fromWebsocket = <T>(ws: any) =>
fromEvents(ws, "message").map( fromEvents(ws, "message").map((evt) => (evt as unknown as { data: T }).data);
(evt) => (evt as unknown as { data: T }).data
);

View File

@@ -1,18 +1,14 @@
import { makePersisted } from "@solid-primitives/storage";
import { Route, Router } from "@solidjs/router"; import { Route, Router } from "@solidjs/router";
import { createResource, lazy, Suspense } from "solid-js"; import pkg from "^/package.json";
import { createSignal, lazy, Suspense } from "solid-js";
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import "virtual:uno.css"; import "virtual:uno.css";
import pkg from "../package.json";
import "./style.css"; import "./style.css";
import api from "./api"; import { name, setName } from "./profile";
import Cookies from "js-cookie";
import { mePromise } from "./profile";
const Profile = () => { const Profile = () => {
let dialogRef!: HTMLDialogElement; let dialogRef!: HTMLDialogElement;
const [profile] = createResource(() =>
mePromise.then(() => api.profile.get())
);
return ( return (
<> <>
@@ -25,10 +21,10 @@ const Profile = () => {
<div class="fixed tr bg-emerald-100 m-2 p-4 rounded-xl border-2 shadow-md shadow-black"> <div class="fixed tr bg-emerald-100 m-2 p-4 rounded-xl border-2 shadow-md shadow-black">
Name:{" "} Name:{" "}
<input <input
value={profile()?.data?.name ?? ""} value={name()}
onChange={(e) => { onChange={(e) => {
dialogRef.close(); dialogRef.close();
void api.setName.post({ name: e.target.value }); setName(e.target.value);
}} }}
class="bg-emerald-200 border-1.5 rounded-full px-4" class="bg-emerald-200 border-1.5 rounded-full px-4"
/> />
@@ -45,8 +41,6 @@ const App = () => {
<> <>
<Suspense>{props.children}</Suspense> <Suspense>{props.children}</Suspense>
<Profile /> <Profile />
{/* Version */}
<span class="fixed br m-2 font-mono text-xs"> <span class="fixed br m-2 font-mono text-xs">
{"v" + pkg.version} {"v" + pkg.version}
</span> </span>
@@ -55,11 +49,11 @@ const App = () => {
> >
<Route path="/" component={lazy(() => import("./routes/index"))} /> <Route path="/" component={lazy(() => import("./routes/index"))} />
<Route <Route
path="/:tableKey" path="/t/:tableKey"
component={lazy(() => import("./routes/[table]"))} component={lazy(() => import("./routes/[table]"))}
/> />
</Router> </Router>
); );
}; };
render(App, document.getElementById("app")!); render(App, document.getElementsByTagName("body")[0]);

View File

@@ -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
>; >;

View 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>
);
};

View File

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

View File

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

View File

@@ -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
>;

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,5 @@
import simple from "./simple";
export default {
simple,
};

View 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 />
</>
);
};

View File

@@ -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
View 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
View 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",
});

View File

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

View 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
View File

@@ -0,0 +1,47 @@
body {
--purple: rgb(138, 156, 255);
--light-purple: rgb(200, 150, 223);
--light: seashell;
--dark: #0a180e;
--green: rgb(24, 82, 65);
--dark-green: rgb(1, 42, 16);
--sweet-green: rgb(23, 138, 125);
--yellow: rgb(252, 220, 103);
height: 100vh;
color: var(--light);
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
font-family: "Trebuchet MS";
}
h1 {
font-size: 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);
}

View File

@@ -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/*"]
} }
} }
} }

View File

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

View File

@@ -1,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"),
},
},
}); });

View File

@@ -1,15 +1,8 @@
{ {
"name": "@games/server", "name": "@games/server",
"scripts": { "scripts": {
"dev": "concurrently 'pnpm run devserver' 'pnpm run dbstudio'", "dev": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
"devserver": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts", "start": "NODE_ENV=production bun run 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": { "dependencies": {
"@elysiajs/cors": "^1.3.3", "@elysiajs/cors": "^1.3.3",

83
pkg/server/src/api.ts Normal file
View 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
View 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
View File

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

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

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

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

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

318
pkg/server/src/table.ts Normal file
View 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
View 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;

View File

@@ -1,9 +1,13 @@
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards"; import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
import { heq } from "@games/shared/utils"; import { heq } from "@games/shared/utils";
import type { Game } from ".";
import { XOR } from "ts-xor";
export type SimpleConfiguration = { export type SimpleConfiguration = {
game: "simple"; game: "simple";
players: string[]; players: string[];
"can discard": boolean;
"cards to win": number;
}; };
// omniscient game state // omniscient game state
@@ -30,9 +34,7 @@ export const newSimpleGameState = (
return { return {
deck: shuffle(newDeck()), deck: shuffle(newDeck()),
turnIdx: 0, turnIdx: 0,
playerHands: Object.fromEntries( playerHands: Object.fromEntries(players.map((humanKey) => [humanKey, []])),
players.map((humanKey) => [humanKey, []])
),
}; };
}; };
@@ -51,25 +53,40 @@ export const getSimplePlayerView = (
), ),
}); });
// type SimpleError = XOR<
// { "go away": string },
// { chill: string },
// { "ah ah": string }
// >;
type SimpleError = {
class: "go away" | "chill" | "ah ah";
message: string;
};
export const resolveSimpleAction = ({ export const resolveSimpleAction = ({
config, config,
state, state,
humanKey,
action, action,
humanKey,
}: { }: {
config: SimpleConfiguration; config: SimpleConfiguration;
state: SimpleGameState; state: SimpleGameState;
humanKey: string;
action: SimpleAction; action: SimpleAction;
humanKey: string;
}): SimpleGameState => { }): SimpleGameState => {
const playerHand = state.playerHands[humanKey]; const playerHand = state.playerHands[humanKey];
if (playerHand == null) { if (playerHand == null) {
throw new Error( throw {
`${humanKey} is not a player in this game; they cannot perform actions` message: "You are not a part of this game!",
); class: "go away",
} satisfies SimpleError;
} }
if (humanKey != config.players[state.turnIdx]) { if (humanKey != config.players[state.turnIdx]) {
throw new Error(`It's not ${humanKey}'s turn!`); throw {
message: "It's not your turn!",
class: "chill",
} satisfies SimpleError;
} }
const numPlayers = Object.keys(state.playerHands).length; const numPlayers = Object.keys(state.playerHands).length;
@@ -88,6 +105,13 @@ export const resolveSimpleAction = ({
}; };
} else { } else {
// action.type == discard // 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)); const cardIndex = playerHand.findIndex(heq(action.card));
return { return {
deck: [action.card, ...state.deck], deck: [action.card, ...state.deck],
@@ -101,3 +125,34 @@ export const resolveSimpleAction = ({
}; };
} }
}; };
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
View 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
View File

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

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

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

9
pkg/shared/types.ts Normal file
View 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;

2455
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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