package -> pkg

This commit is contained in:
2025-08-29 20:45:53 -04:00
parent 0d6d3d6d32
commit f38a5a69df
54 changed files with 5 additions and 5 deletions

16
pkg/client/src/api.ts Normal file
View File

@@ -0,0 +1,16 @@
import { treaty } from "@elysiajs/eden";
import { fromEvents } from "kefir";
import { type Api } from "@games/server/src/api";
const { api } = treaty<Api>(
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
{
fetch: { credentials: "include" },
}
);
export default api;
export const fromWebsocket = <T>(ws: any) =>
fromEvents(ws, "message").map(
(evt) => (evt as unknown as { data: T }).data
);

64
pkg/client/src/app.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { Route, Router } from "@solidjs/router";
import { createResource, lazy, Suspense } from "solid-js";
import { render } from "solid-js/web";
import "virtual:uno.css";
import pkg from "^/package.json";
import "./style.css";
import api from "./api";
import { mePromise } from "./profile";
const Profile = () => {
let dialogRef!: HTMLDialogElement;
const [profile] = createResource(() =>
mePromise.then(() => api.profile.get())
);
return (
<>
<div
onClick={() => dialogRef.showModal()}
class="i-solar-user-circle-bold button s-10 m-2 cursor-pointer fixed tr"
/>
<dialog ref={dialogRef} closedby="any">
<div class="fixed tr bg-emerald-100 m-2 p-4 rounded-xl border-2 shadow-md shadow-black">
Name:{" "}
<input
value={profile()?.data?.name ?? ""}
onChange={(e) => {
dialogRef.close();
void api.setName.post({ name: e.target.value });
}}
class="bg-emerald-200 border-1.5 rounded-full px-4"
/>
</div>
</dialog>
</>
);
};
const App = () => {
return (
<Router
root={(props) => (
<>
<Suspense>{props.children}</Suspense>
<Profile />
{/* Version */}
<span class="fixed br m-2 font-mono text-xs">
{"v" + pkg.version}
</span>
</>
)}
>
<Route path="/" component={lazy(() => import("./routes/index"))} />
<Route
path="/:tableKey"
component={lazy(() => import("./routes/[table]"))}
/>
</Router>
);
};
render(App, document.getElementById("app")!);

View File

@@ -0,0 +1,49 @@
import { Component, Suspense } from "solid-js";
import type { Card } from "@games/shared/cards";
import { Clickable, Sizable, Stylable } from "./toolbox";
const cardToSvgFilename = (card: Card) => {
if (card.kind == "joker") {
return `JOKER-${card.color == "black" ? "2" : "3"}`;
}
const value =
{ ace: 1, jack: 11, queen: 12, king: 13 }[
card.rank as "ace" | "jack" | "queen" | "king" // fuck you typescript
] ?? (card.rank as number);
return `${card.suit.toUpperCase()}-${value}${
value >= 11 ? "-" + (card.rank as string).toUpperCase() : ""
}`;
};
export default ((props) => {
return (
<Suspense>
<img
onClick={props.onClick}
draggable={false}
class={props.class}
style={props.style}
width={props.width ?? "100px"}
height={props.height}
src={
props.face == "down"
? "/views/back.svg"
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
}
/>
</Suspense>
);
}) satisfies Component<
(
| {
card: Card;
face?: "up";
}
| { card?: Card; face: "down" }
) &
Stylable &
Clickable &
Sizable
>;

View File

@@ -0,0 +1,29 @@
import type { Hand } from "@games/shared/cards";
import { For } from "solid-js";
import Card from "./Card";
export default (props: { handCount: number }) => {
return (
<For each={Array(props.handCount)}>
{(_, i) => {
const midOffset = i() + 0.5 - props.handCount / 2;
return (
<Card
face="down"
width="40px"
style={{
"margin-left": "-10px",
"margin-right": "-10px",
transform: `rotate(${
midOffset * 0.2
}rad) translate(0px, ${
2 ** Math.abs(midOffset) * 2
}px)`,
"box-shadow": "-4px 4px 4px rgba(0, 0, 0, 0.7)",
}}
/>
);
}}
</For>
);
};

View File

@@ -0,0 +1,73 @@
import { Accessor, createContext, For, useContext } from "solid-js";
import type {
SimpleAction,
SimplePlayerView,
} from "@games/server/src/games/simple";
import { me, profile } from "~/profile";
import Hand from "./Hand";
import Pile from "./Pile";
import { TableContext } from "./Table";
import { Portal } from "solid-js/web";
import FannedHand from "./FannedHand";
export const GameContext = createContext<{
view: Accessor<SimplePlayerView>;
submitAction: (action: SimpleAction) => any;
}>();
export default () => {
const table = useContext(TableContext)!;
const view = table.view as Accessor<SimplePlayerView>;
const submitAction = (action: SimpleAction) => table.sendWs({ action });
return (
<GameContext.Provider value={{ view, submitAction }}>
<Pile
count={view().deckCount}
class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })}
/>
<Hand
class="fixed bc"
hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })}
/>
<div class="absolute tc text-align-center">
It's{" "}
<span class="font-bold">
{view().playerTurn == me()
? "your"
: profile(view().playerTurn)()?.name + "'s"}
</span>{" "}
turn
</div>
<button
class="button fixed tl m-4 p-1"
onClick={() => table.sendWs({ quit: true })}
>
Quit
</button>
<For each={Object.entries(view().playerHandCounts)}>
{([playerKey, handCount], i) => (
<Portal
mount={document.getElementById(`player-${playerKey}`)!}
ref={(ref) => {
const midOffset =
i() +
0.5 -
Object.values(view().playerHandCounts).length /
2;
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%; transform: translate(${Math.abs(
midOffset * 0
)}px, 0px) rotate(${midOffset * 1}rad)`;
}}
>
<FannedHand handCount={handCount} />
</Portal>
)}
</For>
</GameContext.Provider>
);
};

View File

@@ -0,0 +1,24 @@
import { Component, For } from "solid-js";
import type { Card as TCard, Hand as THand } from "@games/shared/cards";
import Card from "./Card";
import { Stylable } from "./toolbox";
export default ((props) => {
return (
<div class={"hand " + props.class} style={props.style}>
<For each={props.hand}>
{(card, i) => (
<Card
card={card}
style={{
cursor: "pointer",
}}
onClick={() => props.onClickCard?.(card, i())}
/>
)}
</For>
</div>
);
}) satisfies Component<
{ hand: THand; onClickCard?: (card: TCard, i: number) => any } & Stylable
>;

View File

@@ -0,0 +1,22 @@
import { Component, For, JSX, Show } from "solid-js";
import Card from "./Card";
import { Clickable, Stylable } from "./toolbox";
export default ((props) => {
return (
<Show when={props.count > 0}>
<Card
onClick={props.onClick}
style={props.style}
class={props.class + " shadow-lg shadow-black"}
face="down"
/>
</Show>
);
}) satisfies Component<
{
count: number;
} & Stylable &
Clickable
>;

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import { JSX } from "solid-js";
export type Stylable = {
class?: string;
style?: JSX.CSSProperties;
};
export type Clickable = {
onClick?:
| JSX.EventHandlerUnion<
HTMLDivElement,
MouseEvent,
JSX.EventHandler<HTMLDivElement, MouseEvent>
>
| undefined;
};
export type Sizable = {
width?: string;
height?: string;
};

39
pkg/client/src/fn.ts Normal file
View File

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

1
pkg/client/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

26
pkg/client/src/profile.ts Normal file
View File

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

View File

@@ -0,0 +1,82 @@
import { Board, Card, Hand, Pile, Stack, Suit } from "./types/cards";
import { clone } from "./fn";
const AGG: Suit = "spades";
const CUL: Suit = "hearts";
const TECH: Suit = "diamonds";
const MIL: Suit = "clubs";
type GameState = {
mainPile: Pile;
discardStack: Stack;
deadPile: Pile;
players: {
hand: Hand;
board: Board;
}[];
playerTurn: number;
lastMove: Move;
prevState: GameState | null;
};
type Move =
| {
type: "draw";
fromPile: "main" | "discard";
}
| { type: "play"; card: Card }
| { type: "remove"; card: Card }
| { type: "attack"; army: Card[] }
| { type: "pass" };
type Action = Move | { type: "discard"; cards: Card[] };
const techTier = (techValue: number) => Math.ceil(techValue / 14);
const moveCost = (move: Move) => (move.type == "attack" ? move.army.length : 1);
const movesSpent = (state: GameState): number =>
state.playerTurn != state.prevState?.playerTurn
? 0
: moveCost(state.lastMove) + movesSpent(state.prevState);
const value = (suit: Suit) => (board: Board) =>
board
.filter((card) => card.suit === suit)
.reduce((v, card) => v + card.value, 0);
export const getNextState = (p: {
state: GameState;
move: Move;
}): GameState | { illegal: string } => {
const { state, move } = p;
const currentPlayer = state.players[state.playerTurn];
const population = currentPlayer.board.thru(value(AGG));
const culture = currentPlayer.board.thru(value(CUL));
const tech = techTier(currentPlayer.board.thru(value(TECH)));
const movesRemaining = tech - movesSpent(state);
const newState = clone(state);
if (move.type === "draw") {
const pile =
move.fromPile === "main"
? newState.mainPile
: newState.discardStack;
if (pile.length === 0) {
return {
illegal: `The ${move.fromPile} pile is empty; cannot draw`,
};
}
newState.players[newState.playerTurn].hand.push(pile.pop()!);
if (movesRemaining == 1) {
newState.playerTurn =
(newState.playerTurn + 1) % newState.players.length;
}
return newState;
}
return { illegal: "idk bruh" };
};

View File

@@ -0,0 +1,15 @@
import { A, useParams } from "@solidjs/router";
import Table from "~/components/Table";
import { Show } from "solid-js";
import { me } from "~/profile";
export default () => {
const { tableKey } = useParams();
return (
<Show when={me() != null}>
<Table tableKey={tableKey} />
</Show>
);
};

View File

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

61
pkg/client/src/style.css Normal file
View File

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