Compare commits

..

3 Commits

Author SHA1 Message Date
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
17 changed files with 127 additions and 103 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "games", "name": "games",
"type": "module", "type": "module",
"version": "0.0.4", "version": "0.0.5",
"scripts": { "scripts": {
"dev": "pnpm --parallel dev", "dev": "pnpm --parallel dev",
"build": "pnpm run -F client build", "build": "pnpm run -F client build",

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,

View File

@@ -2,10 +2,9 @@ import { Route, Router } from "@solidjs/router";
import { createResource, lazy, Suspense } from "solid-js"; import { createResource, 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 pkg from "^/package.json";
import "./style.css"; import "./style.css";
import api from "./api"; import api from "./api";
import Cookies from "js-cookie";
import { mePromise } from "./profile"; import { mePromise } from "./profile";
const Profile = () => { const Profile = () => {

View File

@@ -1,7 +1,7 @@
import { Component, createResource, JSX, Suspense } from "solid-js"; import { Component, Suspense } from "solid-js";
import type { Card } from "@games/shared/cards";
import { Clickable, Stylable } from "./toolbox"; import { Clickable, Stylable } from "./toolbox";
import { Card } from "../../../shared/cards";
const cardToSvgFilename = (card: Card) => { const cardToSvgFilename = (card: Card) => {
if (card.kind == "joker") { if (card.kind == "joker") {

View File

@@ -0,0 +1,21 @@
import type { Hand } from "@games/shared/cards";
import { For } from "solid-js";
import Card from "./Card";
export default (props: { handCount: number }) => {
return (
<div class="flex">
<For each={Array(props.handCount)}>
{() => (
<Card
face="down"
style={{
"margin-left": "-15px",
transform: "translate(0, 40px)",
}}
/>
)}
</For>
</div>
);
};

View File

@@ -1,12 +1,14 @@
import { Accessor, createContext, useContext } from "solid-js"; import { Accessor, createContext, For, useContext } from "solid-js";
import { import type {
SimpleAction, SimpleAction,
SimplePlayerView, SimplePlayerView,
} from "../../../server/src/games/simple"; } from "@games/server/src/games/simple";
import { me, profile } from "../profile"; import { me, profile } from "~/profile";
import Hand from "./Hand"; import Hand from "./Hand";
import Pile from "./Pile"; import Pile from "./Pile";
import { TableContext } from "./Table"; import { TableContext } from "./Table";
import { Portal } from "solid-js/web";
import FannedHand from "./FannedHand";
export const GameContext = createContext<{ export const GameContext = createContext<{
view: Accessor<SimplePlayerView>; view: Accessor<SimplePlayerView>;
@@ -40,6 +42,23 @@ export default () => {
</span>{" "} </span>{" "}
turn turn
</div> </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]) => (
<Portal
mount={document.getElementById(`player-${playerKey}`)!}
>
<div class="absolute center">
<FannedHand handCount={handCount} />
</div>
</Portal>
)}
</For>
</GameContext.Provider> </GameContext.Provider>
); );
}; };

View File

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

View File

@@ -1,8 +1,9 @@
import { createSignal, useContext } from "solid-js"; import { createSignal, useContext } from "solid-js";
import { playerColor, profile } from "../profile"; import { playerColor, profile } from "~/profile";
import { TableContext } from "./Table"; import { TableContext } from "./Table";
import { Stylable } from "./toolbox"; import { Stylable } from "./toolbox";
import { createObservable, createObservableWithInit } from "../fn"; import { createObservable, createObservableWithInit } from "~/fn";
import { GameContext } from "./Game";
export default (props: { playerKey: string } & Stylable) => { export default (props: { playerKey: string } & Stylable) => {
const table = useContext(TableContext); const table = useContext(TableContext);
@@ -13,8 +14,11 @@ export default (props: { playerKey: string } & Stylable) => {
.thru((Evt) => createObservableWithInit(Evt, false)) ?? .thru((Evt) => createObservableWithInit(Evt, false)) ??
createSignal(false)[0]; createSignal(false)[0];
const game = useContext(GameContext);
return ( return (
<div <div
id={`player-${props.playerKey}`}
style={{ style={{
...props.style, ...props.style,
"background-color": playerColor(props.playerKey), "background-color": playerColor(props.playerKey),

View File

@@ -1,20 +1,19 @@
import type { TWsIn, TWsOut } from "@games/server/src/table";
import { fromPromise, Stream } from "kefir";
import { import {
Accessor, Accessor,
createContext, createContext,
createEffect, createEffect,
createResource,
createSignal, createSignal,
For, For,
onCleanup, onCleanup,
Show, Show,
} from "solid-js"; } from "solid-js";
import { TWsIn, TWsOut } from "../../../server/src/table"; import api, { fromWebsocket } from "~/api";
import api, { fromWebsocket } from "../api"; import { createObservable, createObservableWithInit, cx } from "~/fn";
import { createObservable, createObservableWithInit, cx } from "../fn"; import { me, mePromise } from "~/profile";
import { me } from "../profile";
import Game from "./Game"; import Game from "./Game";
import Player from "./Player"; import Player from "./Player";
import { fromPromise, Stream } from "kefir";
export const TableContext = createContext<{ export const TableContext = createContext<{
view: Accessor<any>; view: Accessor<any>;
@@ -38,7 +37,7 @@ export default (props: { tableKey: string }) => {
onCleanup(() => wsPromise.then((ws) => ws.close())); onCleanup(() => wsPromise.then((ws) => ws.close()));
const presenceEvents = wsEvents.filter((evt) => evt.players != null); const presenceEvents = wsEvents.filter((evt) => evt.players != null);
const gameEvents = wsEvents.filter((evt) => evt.view != null); const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
const players = createObservableWithInit<string[]>( const players = createObservableWithInit<string[]>(
presenceEvents.map((evt) => evt.players!), presenceEvents.map((evt) => evt.players!),
@@ -46,6 +45,15 @@ export default (props: { tableKey: string }) => {
); );
const [ready, setReady] = createSignal(false); 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() })); createEffect(() => sendWs({ ready: ready() }));
const view = createObservable(gameEvents.map((evt) => evt.view)); const view = createObservable(gameEvents.map((evt) => evt.view));

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,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

@@ -1,6 +1,6 @@
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { createEffect, createResource, For } from "solid-js"; import { createEffect, createResource, For } from "solid-js";
import api from "../api"; import api from "~/api";
export default () => { export default () => {
const [games] = createResource(async () => const [games] = createResource(async () =>

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/*": ["./packages/*"],
"@/*": ["./public/*"] "$/*": ["./packages/client/*"],
"~/*": ["./packages/client/src/*"]
} }
} }
} }

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

View File

@@ -12,7 +12,7 @@ import { transform } from "./kefir-extension";
export const WsOut = t.Object({ export const WsOut = t.Object({
players: t.Optional(t.Array(t.String())), players: t.Optional(t.Array(t.String())),
playersReady: t.Optional(t.Record(t.String(), t.Boolean())), playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))),
view: t.Optional(t.Any()), view: t.Optional(t.Any()),
}); });
export type TWsOut = typeof WsOut.static; export type TWsOut = typeof WsOut.static;
@@ -31,15 +31,15 @@ type TablePayload<GameConfig, GameState, GameAction> = {
never never
>; >;
readys: TBus<Attributed & { ready: boolean }, never>; readys: TBus<Attributed & { ready: boolean }, any>;
actions: TBus<Attributed & GameAction, never>; actions: TBus<Attributed & GameAction, any>;
quits: TBus<Attributed, never>; quits: TBus<Attributed, any>;
}; };
outputs: { outputs: {
playersPresent: Property<string[], never>; playersPresent: Property<string[], any>;
playersReady: Property<{ [key: string]: boolean }, unknown>; playersReady: Property<{ [key: string]: boolean } | null, any>;
gameConfig: Property<GameConfig | null, never>; gameConfig: Property<GameConfig | null, any>;
gameState: Property<GameState | null, never>; gameState: Property<GameState | null, any>;
}; };
}; };
@@ -60,6 +60,7 @@ export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
quits: Bus(), quits: Bus(),
}; };
const { connectionChanges, readys, actions, quits } = inputs; const { connectionChanges, readys, actions, quits } = inputs;
quits.log("quits");
// ======= // =======
const playersPresent = connectionChanges const playersPresent = connectionChanges
@@ -78,34 +79,45 @@ export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
.map((counts) => Object.keys(counts)) .map((counts) => Object.keys(counts))
.toProperty(); .toProperty();
const gameEnds = quits.map((_) => null);
const gameStarts = pool<null, any>();
const playersReady = transform( const playersReady = transform(
{} as { [key: string]: boolean }, null as { [key: string]: boolean } | null,
[ [
playersPresent, playersPresent,
(prev, players: string[]) => (prev, players: string[]) =>
Object.fromEntries( Object.fromEntries(
players.map((p) => [p, prev[p] ?? false]) players.map((p) => [p, prev?.[p] ?? false])
), ),
], ],
[ [
readys, readys,
(prev, evt: { humanKey: string; ready: boolean }) => (prev, evt: { humanKey: string; ready: boolean }) =>
prev[evt.humanKey] != null prev?.[evt.humanKey] != null
? { ...prev, [evt.humanKey]: evt.ready } ? { ...prev, [evt.humanKey]: evt.ready }
: prev, : prev,
],
[gameStarts, () => null],
[
combine([gameEnds], [playersPresent], (_, players) => players),
(_, players: string[]) =>
Object.fromEntries(players.map((p) => [p, false])),
] ]
) )
.toProperty() .toProperty()
.log("playersReady"); .log("playersReady");
const gameStarts = playersReady gameStarts.plug(
.filter( playersReady
(pr) => .filter(
Object.values(pr).length > 0 && (pr) =>
Object.values(pr).every((ready) => ready) Object.values(pr ?? {}).length > 0 &&
) Object.values(pr!).every((ready) => ready)
.map((_) => null) )
.log("gameStarts"); .map((_) => null)
.log("gameStarts")
);
const gameConfigPool = pool< const gameConfigPool = pool<
{ {
@@ -144,8 +156,12 @@ export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
humanKey: evt.action.humanKey, humanKey: evt.action.humanKey,
}) })
: prev, : prev,
] ],
[quits, () => null]
).toProperty(); ).toProperty();
gameState
.map((state) => JSON.stringify(state).substring(0, 10))
.log("gameState");
const gameIsActive = gameState const gameIsActive = gameState
.map((gs) => gs != null) .map((gs) => gs != null)
@@ -166,9 +182,9 @@ export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
inputs, inputs,
outputs: { outputs: {
playersPresent, playersPresent,
playersReady, playersReady: playersReady.toProperty(),
gameConfig: gameConfig as Property<unknown, never>, gameConfig: gameConfig as Property<unknown, any>,
gameState: gameState as Property<unknown, never>, gameState: gameState as Property<unknown, any>,
}, },
}; };