Compare commits
19 Commits
11f21221ee
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e9b145066 | |||
| 7d45a36f93 | |||
| fb3f567a5b | |||
| 41bd1fce38 | |||
| ed42c831de | |||
| ae6a79aadd | |||
| 46002403c8 | |||
| b854fec9e5 | |||
| bedafb0b7c | |||
| 9e3697ffef | |||
| b3e040f03f | |||
| 46e7b60ade | |||
| ce29ab72ae | |||
| d203fe4141 | |||
| 67fdf66cd4 | |||
| 9919b97931 | |||
| fd342e7d47 | |||
| b433a26fc6 | |||
| 0ea16ead64 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
.vinxi
|
||||
*.db
|
||||
|
||||
.DS_STORE
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
|
||||
5
Makefile
5
Makefile
@@ -8,3 +8,8 @@ build:
|
||||
|
||||
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",
|
||||
"type": "module",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.10",
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel dev",
|
||||
"build": "pnpm run -F client build",
|
||||
@@ -12,11 +12,7 @@
|
||||
"object-hash": "^3.0.0"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"@prisma/client",
|
||||
"@prisma/engines",
|
||||
"esbuild",
|
||||
"prisma"
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.3.2",
|
||||
"@solid-primitives/memo": "^1.4.3",
|
||||
"@solid-primitives/scheduled": "^1.5.2",
|
||||
"@solid-primitives/storage": "^4.3.3",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"color2k": "^2.0.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"kefir": "^3.8.8",
|
||||
"kefir-bus": "^2.3.1",
|
||||
|
||||
@@ -11,6 +11,4 @@ const { api } = treaty<Api>(
|
||||
export default api;
|
||||
|
||||
export const fromWebsocket = <T>(ws: any) =>
|
||||
fromEvents(ws, "message").map(
|
||||
(evt) => (evt as unknown as { data: T }).data
|
||||
);
|
||||
fromEvents(ws, "message").map((evt) => (evt as unknown as { data: T }).data);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
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 "virtual:uno.css";
|
||||
import pkg from "^/package.json";
|
||||
import "./style.css";
|
||||
import api from "./api";
|
||||
import { mePromise } from "./profile";
|
||||
import { name, setName } from "./profile";
|
||||
|
||||
const Profile = () => {
|
||||
let dialogRef!: HTMLDialogElement;
|
||||
const [profile] = createResource(() =>
|
||||
mePromise.then(() => api.profile.get())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -24,10 +21,10 @@ const Profile = () => {
|
||||
<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 ?? ""}
|
||||
value={name()}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Suspense } from "solid-js";
|
||||
|
||||
import type { Card } from "@games/shared/cards";
|
||||
import { Clickable, Sizable, Stylable } from "./toolbox";
|
||||
import { Clickable, Scalable, Stylable } from "./toolbox";
|
||||
|
||||
const cardToSvgFilename = (card: Card) => {
|
||||
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) => {
|
||||
const width = () => BASE_CARD_WIDTH * (props.scale ?? 1);
|
||||
const height = () => width() * CARD_RATIO;
|
||||
return (
|
||||
<Suspense>
|
||||
<img
|
||||
@@ -25,8 +30,8 @@ export default ((props) => {
|
||||
draggable={false}
|
||||
class={props.class}
|
||||
style={props.style}
|
||||
width={props.width ?? "100px"}
|
||||
height={props.height}
|
||||
width={`${width()}px`}
|
||||
height={`${height()}px`}
|
||||
src={
|
||||
props.face == "down"
|
||||
? "/views/back.svg"
|
||||
@@ -45,5 +50,5 @@ export default ((props) => {
|
||||
) &
|
||||
Stylable &
|
||||
Clickable &
|
||||
Sizable
|
||||
Scalable
|
||||
>;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 }) => {
|
||||
export default (props: { handCount: number } & Stylable) => {
|
||||
return (
|
||||
<For each={Array(props.handCount)}>
|
||||
{(_, i) => {
|
||||
@@ -10,16 +11,16 @@ export default (props: { handCount: number }) => {
|
||||
return (
|
||||
<Card
|
||||
face="down"
|
||||
width="40px"
|
||||
scale={0.4}
|
||||
style={{
|
||||
"margin-left": "-10px",
|
||||
"margin-right": "-10px",
|
||||
transform: `rotate(${
|
||||
midOffset * 0.2
|
||||
}rad) translate(0px, ${
|
||||
2 ** Math.abs(midOffset) * 2
|
||||
}px)`,
|
||||
"box-shadow": "-4px 4px 4px rgba(0, 0, 0, 0.7)",
|
||||
"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)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Accessor, createContext, For, useContext } from "solid-js";
|
||||
import type {
|
||||
SimpleAction,
|
||||
SimplePlayerView,
|
||||
} from "@games/shared/games/simple";
|
||||
import { me, profile } from "~/profile";
|
||||
import Hand from "./Hand";
|
||||
import Pile from "./Pile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Portal } from "solid-js/web";
|
||||
import FannedHand from "./FannedHand";
|
||||
|
||||
export const GameContext = createContext<{
|
||||
view: Accessor<SimplePlayerView>;
|
||||
submitAction: (action: SimpleAction) => any;
|
||||
}>();
|
||||
|
||||
export default () => {
|
||||
const table = useContext(TableContext)!;
|
||||
const view = table.view as Accessor<SimplePlayerView>;
|
||||
const submitAction = (action: SimpleAction) => table.sendWs({ action });
|
||||
|
||||
return (
|
||||
<GameContext.Provider value={{ view, submitAction }}>
|
||||
<Pile
|
||||
count={view().deckCount}
|
||||
class="cursor-pointer fixed center"
|
||||
onClick={() => submitAction({ type: "draw" })}
|
||||
/>
|
||||
|
||||
<Hand
|
||||
class="fixed bc"
|
||||
hand={view().myHand}
|
||||
onClickCard={(card) => submitAction({ type: "discard", card })}
|
||||
/>
|
||||
<div class="absolute tc text-align-center">
|
||||
It's{" "}
|
||||
<span class="font-bold">
|
||||
{view().playerTurn == me()
|
||||
? "your"
|
||||
: profile(view().playerTurn)()?.name + "'s"}
|
||||
</span>{" "}
|
||||
turn
|
||||
</div>
|
||||
<button
|
||||
class="button fixed tl m-4 p-1"
|
||||
onClick={() => {
|
||||
table.sendWs({ quit: true });
|
||||
}}
|
||||
>
|
||||
Quit
|
||||
</button>
|
||||
<For each={Object.entries(view().playerHandCounts)}>
|
||||
{([playerKey, handCount], i) => (
|
||||
<Portal
|
||||
mount={document.getElementById(`player-${playerKey}`)!}
|
||||
ref={(ref) => {
|
||||
const midOffset =
|
||||
i() + 0.5 - Object.values(view().playerHandCounts).length / 2;
|
||||
|
||||
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%; transform: translate(${Math.abs(
|
||||
midOffset * 0
|
||||
)}px, 0px) rotate(${midOffset * 1}rad)`;
|
||||
}}
|
||||
>
|
||||
<FannedHand handCount={handCount} />
|
||||
</Portal>
|
||||
)}
|
||||
</For>
|
||||
</GameContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,63 @@
|
||||
import { Component, For, JSX, Show } from "solid-js";
|
||||
import Card from "./Card";
|
||||
import { Component, createMemo, For, JSX, Show } from "solid-js";
|
||||
import Card, { BASE_CARD_WIDTH, CARD_RATIO } from "./Card";
|
||||
import { desaturate } from "color2k";
|
||||
|
||||
import { Clickable, Stylable } from "./toolbox";
|
||||
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}>
|
||||
<Card
|
||||
onClick={props.onClick}
|
||||
style={props.style}
|
||||
class={props.class + " shadow-lg shadow-black"}
|
||||
face="down"
|
||||
/>
|
||||
<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
|
||||
Clickable &
|
||||
Scalable
|
||||
>;
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { createSignal, useContext } from "solid-js";
|
||||
import { playerColor, profile } from "~/profile";
|
||||
import { onMount, useContext } from "solid-js";
|
||||
import { playerColor } from "~/profile";
|
||||
import { TableContext } from "./Table";
|
||||
import { Stylable } from "./toolbox";
|
||||
import { createObservable, createObservableWithInit } from "~/fn";
|
||||
import { GameContext } from "./Game";
|
||||
|
||||
export default (props: { playerKey: string } & Stylable) => {
|
||||
const table = useContext(TableContext);
|
||||
const playerReady =
|
||||
table?.wsEvents
|
||||
.filter((evt) => evt.playersReady != null)
|
||||
.map((evt) => evt.playersReady![props.playerKey])
|
||||
.thru((Evt) => createObservableWithInit(Evt, false)) ??
|
||||
createSignal(false)[0];
|
||||
|
||||
const game = useContext(GameContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`player-${props.playerKey}`}
|
||||
ref={(e) => table?.setPlayers(props.playerKey, { ref: e })}
|
||||
style={{
|
||||
...props.style,
|
||||
"background-color": playerColor(props.playerKey),
|
||||
...(playerReady() && table?.view() == null
|
||||
...(table?.view() == null && table?.players[props.playerKey].ready
|
||||
? {
|
||||
border: "10px solid green",
|
||||
}
|
||||
@@ -30,8 +20,8 @@ export default (props: { playerKey: string } & Stylable) => {
|
||||
}}
|
||||
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
|
||||
>
|
||||
<p style={{ "font-size": "1em" }}>
|
||||
{profile(props.playerKey)()?.name}
|
||||
<p class="font-[1em] text-align-center">
|
||||
{table?.players[props.playerKey].name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TWsIn, TWsOut } from "@games/server/src/table";
|
||||
import { fromPromise, Stream } from "kefir";
|
||||
import games from "@games/shared/games/index";
|
||||
import { pool, Property, Stream } from "kefir";
|
||||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
@@ -7,70 +8,140 @@ import {
|
||||
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, createObservableWithInit, cx } from "~/fn";
|
||||
import { me, mePromise } from "~/profile";
|
||||
import Game from "./Game";
|
||||
import { createObservable, createSynced, cx, extractProperty } from "~/fn";
|
||||
import { me, name } from "~/profile";
|
||||
import GAMES from "./games";
|
||||
import Player from "./Player";
|
||||
import games from "@games/shared/games/index";
|
||||
|
||||
type PlayerStore = Store<{
|
||||
[key: string]: {
|
||||
name: string;
|
||||
ready: boolean;
|
||||
ref?: HTMLDivElement;
|
||||
};
|
||||
}>;
|
||||
export const TableContext = createContext<{
|
||||
view: Accessor<any>;
|
||||
sendWs: (msg: TWsIn) => void;
|
||||
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 }) => {
|
||||
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));
|
||||
// #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 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.playersPresent != null);
|
||||
const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
|
||||
|
||||
const players = createObservableWithInit<string[]>(
|
||||
presenceEvents.map((evt) => evt.playersPresent!),
|
||||
[]
|
||||
);
|
||||
const view = wsEvents.thru(extractProperty("view")).thru(createObservable);
|
||||
// #endregion
|
||||
|
||||
const [ready, setReady] = createSignal(false);
|
||||
mePromise.then(
|
||||
(me) =>
|
||||
me &&
|
||||
wsEvents
|
||||
.filter((evt) => evt.playersReady !== undefined)
|
||||
.map((evt) => evt.playersReady?.[me] ?? false)
|
||||
.onValue(setReady)
|
||||
);
|
||||
|
||||
createEffect(() => sendWs({ ready: ready() }));
|
||||
const view = createObservable(gameEvents.map((evt) => evt.view));
|
||||
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={{
|
||||
sendWs,
|
||||
wsEvents,
|
||||
sendWs,
|
||||
|
||||
tableRef,
|
||||
|
||||
players,
|
||||
setPlayers,
|
||||
gameConfig,
|
||||
setGameConfig,
|
||||
view,
|
||||
}}
|
||||
>
|
||||
{/* Player avatars around the table */}
|
||||
<div class="flex justify-around p-t-14">
|
||||
<For each={players().filter((p) => p != me())}>
|
||||
<For each={gameConfig()?.players.filter((p) => p != me())}>
|
||||
{(player, i) => {
|
||||
const verticalOffset = () => {
|
||||
const N = players().length - 1;
|
||||
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;
|
||||
@@ -86,8 +157,10 @@ export default (props: { tableKey: string }) => {
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* The table body itself */}
|
||||
<div
|
||||
id="table"
|
||||
ref={tableRef}
|
||||
class={cx(
|
||||
"fixed",
|
||||
|
||||
@@ -101,32 +174,26 @@ export default (props: { tableKey: string }) => {
|
||||
|
||||
"top-40",
|
||||
"bottom-20",
|
||||
"left-10",
|
||||
"right-10"
|
||||
"left-[2%]",
|
||||
"right-[2%]"
|
||||
)}
|
||||
style={{
|
||||
"border-radius": "50%",
|
||||
}}
|
||||
>
|
||||
<Show when={view() == null}>
|
||||
<div class="absolute tc mt-8 flex gap-4">
|
||||
<select>
|
||||
<For each={Object.entries(games)}>
|
||||
{([gameId, game]) => <option value={gameId}>{gameId}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setReady((prev) => !prev)}
|
||||
class="button p-1 "
|
||||
>
|
||||
{ready() ? "Not Ready" : "Ready"}
|
||||
</button>
|
||||
</div>
|
||||
<GamePicker />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={view() != null}>
|
||||
<Game />
|
||||
</Show>
|
||||
|
||||
{/* 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";
|
||||
|
||||
export type Stylable = {
|
||||
@@ -15,7 +16,8 @@ export type Clickable = {
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type Sizable = {
|
||||
width?: string;
|
||||
height?: string;
|
||||
export type Scalable = {
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export const hashColor = (obj: NotUndefined) => `#${hash(obj).substring(0, 6)}`;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Observable } from "kefir";
|
||||
import { Accessor, createSignal } from "solid-js";
|
||||
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> {
|
||||
@@ -37,3 +40,31 @@ export const createObservableWithInit = <T>(
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { createResource, Resource } from "solid-js";
|
||||
import { ApiType } from "./fn";
|
||||
import api from "./api";
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
export const [name, setName] = makePersisted(createSignal("__name__"), {
|
||||
name: "name",
|
||||
});
|
||||
|
||||
@@ -3,38 +3,44 @@ 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>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,11 +15,11 @@ body {
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
font-size: 2em;
|
||||
font-family: Garamond;
|
||||
}
|
||||
p {
|
||||
font-size: 24px;
|
||||
font-size: 1em;
|
||||
font-family: Garamond;
|
||||
}
|
||||
a {
|
||||
|
||||
@@ -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,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");
|
||||
@@ -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;
|
||||
@@ -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,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?
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
{
|
||||
"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'"
|
||||
"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",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: path.join("db", "schema.prisma"),
|
||||
});
|
||||
@@ -1,81 +1,28 @@
|
||||
import { Game } from "@games/shared/games";
|
||||
import { Human } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { combine } from "kefir";
|
||||
import Bus from "kefir-bus";
|
||||
import db from "./db";
|
||||
import { liveTable, WsIn, WsOut } from "./table";
|
||||
import { Elysia } from "elysia";
|
||||
import { generateTokenAndKey, resolveToken } from "./human";
|
||||
import { err } from "./logging";
|
||||
|
||||
export const WS = Bus<
|
||||
{
|
||||
type: "open" | "message" | "error" | "close";
|
||||
humanKey: string;
|
||||
tableKey: string;
|
||||
},
|
||||
unknown
|
||||
>();
|
||||
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 human: Human | null;
|
||||
if (
|
||||
token.value == null ||
|
||||
(human = await db.human.findUnique({
|
||||
where: {
|
||||
token: token.value,
|
||||
},
|
||||
})) == null
|
||||
) {
|
||||
human = await db.human.create({
|
||||
data: {},
|
||||
});
|
||||
let key: string | undefined;
|
||||
if (token.value == null || (key = resolveToken(token.value)) == null) {
|
||||
const [newToken, newKey] = generateTokenAndKey();
|
||||
token.set({
|
||||
value: human.token,
|
||||
value: newToken,
|
||||
expires: dayjs().add(1, "year").toDate(),
|
||||
httpOnly: true,
|
||||
});
|
||||
return newKey;
|
||||
}
|
||||
|
||||
return human.key;
|
||||
return key;
|
||||
})
|
||||
.derive(async ({ cookie: { token }, status }) => {
|
||||
const humanKey = await db.human
|
||||
.findUnique({
|
||||
where: { token: token.value },
|
||||
})
|
||||
.then((human) => human?.key);
|
||||
const humanKey = token.value && resolveToken(token.value);
|
||||
return humanKey != null ? { humanKey } : status(401);
|
||||
})
|
||||
.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;
|
||||
})
|
||||
)
|
||||
.ws("/ws/:tableKey", {
|
||||
body: WsIn,
|
||||
response: WsOut,
|
||||
@@ -98,6 +45,7 @@ const api = new Elysia({ prefix: "/api" })
|
||||
...table.outputs.global,
|
||||
...(table.outputs.player[humanKey] ?? {}),
|
||||
}).forEach(([type, stream]) =>
|
||||
// @ts-ignore
|
||||
stream.onValue((v) => send({ [type]: v }))
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export default new PrismaClient();
|
||||
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);
|
||||
@@ -16,7 +16,7 @@ new Elysia()
|
||||
})
|
||||
)
|
||||
|
||||
.onError(({ error }) => console.error(error))
|
||||
// .onError(({ error }) => console.error(error))
|
||||
|
||||
.get("/ping", () => "pong")
|
||||
.use(api)
|
||||
|
||||
@@ -10,7 +10,7 @@ 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);
|
||||
});
|
||||
// LogStream.log();
|
||||
// LogStream.onError((err) => {
|
||||
// console.error(err);
|
||||
// });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import GAMES, { Game, GameKey } from "@games/shared/games";
|
||||
import {
|
||||
invert,
|
||||
isEmpty,
|
||||
multiScan,
|
||||
partition,
|
||||
@@ -8,18 +9,29 @@ import {
|
||||
ValueWithin,
|
||||
} from "@games/shared/kefirs";
|
||||
import { t } from "elysia";
|
||||
import { combine, Observable, pool, Property } from "kefir";
|
||||
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";
|
||||
|
||||
export const WsOut = t.Object({
|
||||
playersPresent: t.Optional(t.Array(t.String())),
|
||||
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))),
|
||||
gameConfig: t.Optional(t.Any()),
|
||||
view: t.Optional(t.Any()),
|
||||
});
|
||||
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) }),
|
||||
@@ -30,7 +42,8 @@ type Attributed = { humanKey: string };
|
||||
type TablePayload<
|
||||
GameConfig = unknown,
|
||||
GameView = unknown,
|
||||
GameAction = unknown
|
||||
GameAction = unknown,
|
||||
GameResult = unknown
|
||||
> = {
|
||||
inputs: {
|
||||
connectionChanges: TBus<
|
||||
@@ -51,6 +64,8 @@ type TablePayload<
|
||||
any
|
||||
>;
|
||||
gameConfig: Property<GameConfig | null, any>;
|
||||
results: Property<GameResult, any>;
|
||||
playerNames: Property<{ [key: string]: string }, any>;
|
||||
};
|
||||
player: {
|
||||
[key: string]: {
|
||||
@@ -71,12 +86,18 @@ export const liveTable = <
|
||||
},
|
||||
GameState,
|
||||
GameAction extends Attributed,
|
||||
GameView
|
||||
GameView,
|
||||
GameResult
|
||||
>(
|
||||
key: string
|
||||
) => {
|
||||
if (!(key in tables)) {
|
||||
const inputs: TablePayload<GameConfig, GameState, GameAction>["inputs"] = {
|
||||
const inputs: TablePayload<
|
||||
GameConfig,
|
||||
GameState,
|
||||
GameAction,
|
||||
GameResult
|
||||
>["inputs"] = {
|
||||
connectionChanges: Bus(),
|
||||
messages: Bus(),
|
||||
};
|
||||
@@ -110,7 +131,7 @@ export const liveTable = <
|
||||
.onValue(({ added, removed }) => {
|
||||
added.forEach((p) => {
|
||||
playerStreams[p] = {
|
||||
view: combine([gameState], [gameImpl], (a, b) => [a, b] as const)
|
||||
view: withGame(gameState)
|
||||
.map(
|
||||
([state, game]) =>
|
||||
state && (game.getView({ state, humanKey: p }) as GameView)
|
||||
@@ -123,29 +144,43 @@ export const liveTable = <
|
||||
});
|
||||
});
|
||||
|
||||
const { ready, action, quit } = partition(
|
||||
["ready", "action", "quit"],
|
||||
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, // TODO: filter to only outside active games
|
||||
playersPresent.filterBy(invert(gameIsActive)),
|
||||
(prev, players: ValueWithin<typeof playersPresent>) =>
|
||||
Object.fromEntries(players.map((p) => [p, prev?.[p] ?? false])),
|
||||
],
|
||||
[
|
||||
ready, // TODO: filter to only outside active games
|
||||
ready.filterBy(invert(gameIsActive)),
|
||||
(prev, evt: ValueWithin<typeof ready>) =>
|
||||
prev?.[evt.humanKey] != null
|
||||
? {
|
||||
@@ -162,6 +197,13 @@ export const liveTable = <
|
||||
]
|
||||
).toProperty();
|
||||
|
||||
const playerNames = name
|
||||
.scan(
|
||||
(prev, n) => ({ ...prev, [n.humanKey]: n.name }),
|
||||
{} as { [key: string]: string }
|
||||
)
|
||||
.toProperty();
|
||||
|
||||
const gameStarts = playersReady
|
||||
.filter(
|
||||
(pr) =>
|
||||
@@ -175,24 +217,31 @@ export const liveTable = <
|
||||
|
||||
const gameImpl = gameConfig
|
||||
.filter((cfg) => cfg.game in GAMES)
|
||||
.map((config) => GAMES[config.game as GameKey](config))
|
||||
.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),
|
||||
gameImpl.sampledBy(gameStarts).filterBy(invert(gameIsActive)),
|
||||
(prev, game: ValueWithin<typeof gameImpl>) =>
|
||||
prev || (game.init() as GameState),
|
||||
],
|
||||
[
|
||||
combine([action], [gameImpl], (act, game) => [act, game] as const),
|
||||
// handle actions from players
|
||||
action.filterBy(gameIsActive).thru(withGame),
|
||||
(
|
||||
prev,
|
||||
[{ action, humanKey }, game]: [
|
||||
Attributed & { action: GameAction },
|
||||
Game
|
||||
ReturnType<Game["impl"]>
|
||||
]
|
||||
) =>
|
||||
prev &&
|
||||
@@ -202,28 +251,41 @@ export const liveTable = <
|
||||
humanKey,
|
||||
}) as GameState),
|
||||
],
|
||||
[quit, () => null]
|
||||
[results.filterBy(gameIsActive), () => null], // handle game ending criteria
|
||||
[quit, () => null] // handle players leaving the room
|
||||
).toProperty();
|
||||
|
||||
const gameIsActive = gameState
|
||||
.map((gs) => gs != null)
|
||||
.skipDuplicates()
|
||||
.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(
|
||||
{
|
||||
game: "simple",
|
||||
players: [] as string[],
|
||||
},
|
||||
DEFAULT_GAME_CONFIG,
|
||||
[
|
||||
playersPresent.filterBy(gameIsActive.map((active) => !active)),
|
||||
playersPresent.filterBy(gameIsActive.thru(invert)),
|
||||
(prev, players) => ({
|
||||
...prev,
|
||||
players,
|
||||
}),
|
||||
],
|
||||
[
|
||||
clientGameConfigs.filterBy(gameIsActive.thru(invert)),
|
||||
// @ts-ignore
|
||||
(prev, { gameConfig }) => ({ ...gameConfig, players: prev.players }),
|
||||
]
|
||||
// TODO: Add player defined config changes
|
||||
) as unknown as Observable<GameConfig, any>
|
||||
);
|
||||
|
||||
@@ -234,6 +296,8 @@ export const liveTable = <
|
||||
playersPresent,
|
||||
playersReady,
|
||||
gameConfig,
|
||||
results,
|
||||
playerNames,
|
||||
},
|
||||
player: playerStreams,
|
||||
},
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import simple from "./simple";
|
||||
|
||||
export type Game<
|
||||
S = unknown,
|
||||
A = unknown,
|
||||
E extends { error: any } = { error: any },
|
||||
V = unknown
|
||||
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[];
|
||||
}
|
||||
> = {
|
||||
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;
|
||||
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]: (config: { game: string; players: string[] }) => Game;
|
||||
[key: string]: Game;
|
||||
} = {
|
||||
// renaissance,
|
||||
simple,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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
|
||||
@@ -50,6 +53,16 @@ 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 = ({
|
||||
config,
|
||||
state,
|
||||
@@ -62,13 +75,18 @@ export const resolveSimpleAction = ({
|
||||
humanKey: string;
|
||||
}): SimpleGameState => {
|
||||
const playerHand = state.playerHands[humanKey];
|
||||
|
||||
if (playerHand == null) {
|
||||
throw new Error(
|
||||
`${humanKey} is not a player in this game; they cannot perform actions`
|
||||
);
|
||||
throw {
|
||||
message: "You are not a part of this game!",
|
||||
class: "go away",
|
||||
} satisfies SimpleError;
|
||||
}
|
||||
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;
|
||||
@@ -87,6 +105,13 @@ export const resolveSimpleAction = ({
|
||||
};
|
||||
} 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],
|
||||
@@ -101,10 +126,16 @@ export const resolveSimpleAction = ({
|
||||
}
|
||||
};
|
||||
|
||||
type SimpleError = { error: "whoops!" };
|
||||
export type SimpleResult = string;
|
||||
|
||||
export default (config: SimpleConfiguration) =>
|
||||
({
|
||||
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),
|
||||
@@ -112,9 +143,16 @@ export default (config: SimpleConfiguration) =>
|
||||
getView: ({ state, humanKey }) =>
|
||||
getSimplePlayerView(config, state, humanKey),
|
||||
resolveQuit: () => null,
|
||||
} satisfies Game<
|
||||
SimpleGameState,
|
||||
SimpleAction,
|
||||
SimpleError,
|
||||
SimplePlayerView
|
||||
>);
|
||||
getResult: (state) =>
|
||||
Object.entries(state.playerHands).find(
|
||||
([_, hand]) => hand.length === config["cards to win"]
|
||||
)?.[0],
|
||||
}),
|
||||
} satisfies Game<
|
||||
SimpleGameState,
|
||||
SimpleAction,
|
||||
SimpleError,
|
||||
SimplePlayerView,
|
||||
SimpleResult,
|
||||
SimpleConfiguration
|
||||
>;
|
||||
|
||||
@@ -50,3 +50,5 @@ export const setDiff = <T>(
|
||||
});
|
||||
|
||||
export const set = <T>(arr: T[]) => new Set<T>(arr);
|
||||
|
||||
export const invert = <E>(obs: Observable<boolean, E>) => obs.map((o) => !o);
|
||||
|
||||
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;
|
||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -20,12 +20,21 @@ importers:
|
||||
'@elysiajs/eden':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.3(elysia@1.3.20(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(typescript@5.9.2))
|
||||
'@solid-primitives/memo':
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(solid-js@1.9.9)
|
||||
'@solid-primitives/scheduled':
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(solid-js@1.9.9)
|
||||
'@solid-primitives/storage':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(solid-js@1.9.9)
|
||||
'@solidjs/router':
|
||||
specifier: ^0.15.3
|
||||
version: 0.15.3(solid-js@1.9.9)
|
||||
color2k:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
@@ -587,11 +596,33 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@solid-primitives/memo@1.4.3':
|
||||
resolution: {integrity: sha512-CA+n9yaoqbYm+My5tY2RWb6EE16tVyehM4GzwQF4vCwvjYPAYk1JSRIVuMC0Xuj5ExD2XQJE5E2yAaKY2HTUsg==}
|
||||
peerDependencies:
|
||||
solid-js: ^1.6.12
|
||||
|
||||
'@solid-primitives/scheduled@1.5.2':
|
||||
resolution: {integrity: sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA==}
|
||||
peerDependencies:
|
||||
solid-js: ^1.6.12
|
||||
|
||||
'@solid-primitives/storage@4.3.3':
|
||||
resolution: {integrity: sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw==}
|
||||
peerDependencies:
|
||||
'@tauri-apps/plugin-store': '*'
|
||||
solid-js: ^1.6.12
|
||||
solid-start: '*'
|
||||
peerDependenciesMeta:
|
||||
'@tauri-apps/plugin-store':
|
||||
optional: true
|
||||
solid-start:
|
||||
optional: true
|
||||
|
||||
'@solid-primitives/utils@6.3.2':
|
||||
resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==}
|
||||
peerDependencies:
|
||||
solid-js: ^1.6.12
|
||||
|
||||
'@solidjs/router@0.15.3':
|
||||
resolution: {integrity: sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==}
|
||||
peerDependencies:
|
||||
@@ -907,6 +938,9 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
color2k@2.0.3:
|
||||
resolution: {integrity: sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
@@ -2807,10 +2841,25 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@solid-primitives/memo@1.4.3(solid-js@1.9.9)':
|
||||
dependencies:
|
||||
'@solid-primitives/scheduled': 1.5.2(solid-js@1.9.9)
|
||||
'@solid-primitives/utils': 6.3.2(solid-js@1.9.9)
|
||||
solid-js: 1.9.9
|
||||
|
||||
'@solid-primitives/scheduled@1.5.2(solid-js@1.9.9)':
|
||||
dependencies:
|
||||
solid-js: 1.9.9
|
||||
|
||||
'@solid-primitives/storage@4.3.3(solid-js@1.9.9)':
|
||||
dependencies:
|
||||
'@solid-primitives/utils': 6.3.2(solid-js@1.9.9)
|
||||
solid-js: 1.9.9
|
||||
|
||||
'@solid-primitives/utils@6.3.2(solid-js@1.9.9)':
|
||||
dependencies:
|
||||
solid-js: 1.9.9
|
||||
|
||||
'@solidjs/router@0.15.3(solid-js@1.9.9)':
|
||||
dependencies:
|
||||
solid-js: 1.9.9
|
||||
@@ -3209,6 +3258,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color2k@2.0.3: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
compare-func@2.0.0:
|
||||
|
||||
Reference in New Issue
Block a user