Compare commits

..

12 Commits

23 changed files with 504 additions and 313 deletions

View File

@@ -8,3 +8,8 @@ build:
start: start:
PORT=$(PORT) pnpm start PORT=$(PORT) pnpm start
note:
./notes/newfile
# touch ./notes/$$file.md
# code -r ./notes/$$file.md

2
deploy
View File

@@ -4,4 +4,4 @@ branch=$(git branch --show-current)
git switch prod git switch prod
git merge $branch git merge $branch
git push git push
git switch $brnach git switch $branch

7
notes/newfile Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
ts=$(date +"%Y-%m-%d-%H%M%S")
file=./notes/$ts.md
touch $file
echo -e "# $ts\n" > $file
echo "$file:end"
code --goto "$file:2"

View File

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

View File

@@ -8,6 +8,7 @@
}, },
"dependencies": { "dependencies": {
"@elysiajs/eden": "^1.3.2", "@elysiajs/eden": "^1.3.2",
"@solid-primitives/memo": "^1.4.3",
"@solid-primitives/scheduled": "^1.5.2", "@solid-primitives/scheduled": "^1.5.2",
"@solid-primitives/storage": "^4.3.3", "@solid-primitives/storage": "^4.3.3",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",

View File

@@ -11,6 +11,4 @@ const { api } = treaty<Api>(
export default api; export default api;
export const fromWebsocket = <T>(ws: any) => export const fromWebsocket = <T>(ws: any) =>
fromEvents(ws, "message").map( fromEvents(ws, "message").map((evt) => (evt as unknown as { data: T }).data);
(evt) => (evt as unknown as { data: T }).data
);

View File

@@ -1,8 +1,9 @@
import type { Hand } from "@games/shared/cards"; import type { Hand } from "@games/shared/cards";
import { For } from "solid-js"; import { For } from "solid-js";
import Card from "./Card"; import Card from "./Card";
import { Stylable } from "./toolbox";
export default (props: { handCount: number }) => { export default (props: { handCount: number } & Stylable) => {
return ( return (
<For each={Array(props.handCount)}> <For each={Array(props.handCount)}>
{(_, i) => { {(_, i) => {

View File

@@ -1,84 +0,0 @@
import { Accessor, createContext, For, useContext } from "solid-js";
import type {
SimpleAction,
SimplePlayerView,
SimpleResult,
} from "@games/shared/games/simple";
import { me } 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}
scale={0.8}
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"
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.playerNames[view().playerTurn] + "'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).filter(([key, _]) =>
table.players().includes(key)
)}
>
{([playerKey, handCount], i) => (
<Portal
mount={document.getElementById(`player-${playerKey}`)!}
ref={(ref) => {
console.log("Setting hand 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>
</GameContext.Provider>
);
};

View File

@@ -1,30 +1,18 @@
import { createSignal, onMount, useContext } from "solid-js"; import { onMount, useContext } from "solid-js";
import { playerColor } from "~/profile"; import { playerColor } from "~/profile";
import { TableContext } from "./Table"; import { TableContext } from "./Table";
import { Stylable } from "./toolbox"; import { Stylable } from "./toolbox";
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);
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);
onMount(() => console.log("Player mounted"));
return ( return (
<div <div
id={`player-${props.playerKey}`} ref={(e) => table?.setPlayers(props.playerKey, { ref: e })}
style={{ style={{
...props.style, ...props.style,
"background-color": playerColor(props.playerKey), "background-color": playerColor(props.playerKey),
...(playerReady() && table?.view() == null ...(table?.view() == null && table?.players[props.playerKey].ready
? { ? {
border: "10px solid green", border: "10px solid green",
} }
@@ -32,8 +20,8 @@ export default (props: { playerKey: string } & Stylable) => {
}} }}
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`} class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
> >
<p style={{ "font-size": "1em" }}> <p class="font-[1em] text-align-center">
{table?.playerNames[props.playerKey]} {table?.players[props.playerKey].name}
</p> </p>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import type { TWsIn, TWsOut } from "@games/server/src/table"; import type { TWsIn, TWsOut } from "@games/server/src/table";
import { fromPromise, merge, Stream } from "kefir"; import games from "@games/shared/games/index";
import { pool, Property, Stream } from "kefir";
import { import {
Accessor, Accessor,
createContext, createContext,
@@ -7,102 +8,140 @@ import {
createSignal, createSignal,
For, For,
onCleanup, onCleanup,
onMount,
Setter,
Show, Show,
} from "solid-js"; } from "solid-js";
import { createStore, SetStoreFunction, Store } from "solid-js/store";
import { Dynamic } from "solid-js/web";
import api, { fromWebsocket } from "~/api"; import api, { fromWebsocket } from "~/api";
import { import { createObservable, createSynced, cx, extractProperty } from "~/fn";
createObservable, import { me, name } from "~/profile";
createObservableStore, import GAMES from "./games";
createObservableWithInit,
cx,
} from "~/fn";
import { me, mePromise } from "~/profile";
import Game from "./Game";
import Player from "./Player"; import Player from "./Player";
import games from "@games/shared/games/index";
import { createStore, Store } from "solid-js/store";
import { name } from "~/profile";
type PlayerStore = Store<{
[key: string]: {
name: string;
ready: boolean;
ref?: HTMLDivElement;
};
}>;
export const TableContext = createContext<{ export const TableContext = createContext<{
view: Accessor<any>;
sendWs: (msg: TWsIn) => void;
wsEvents: Stream<TWsOut, any>; wsEvents: Stream<TWsOut, any>;
playerNames: Store<{ [key: string]: string }>; sendWs: (msg: TWsIn) => void;
players: Accessor<string[]>;
tableRef: HTMLDivElement;
gameConfig: Accessor<any>;
setGameConfig: Setter<any>;
players: PlayerStore;
setPlayers: SetStoreFunction<PlayerStore>;
view: Accessor<any>;
}>(); }>();
export default (props: { tableKey: string }) => { export default (props: { tableKey: string }) => {
const wsPromise = new Promise< // #region Websocket declaration
ReturnType<ReturnType<typeof api.ws>["subscribe"]> let ws: ReturnType<ReturnType<typeof api.ws>["subscribe"]> | undefined =
>((res) => { undefined;
const ws = api.ws(props).subscribe(); const wsEvents = pool<TWsOut, any>();
ws.on("open", () => res(ws)); const sendWs = (msg: TWsIn) => ws?.send(msg);
ws.on("error", () => res(ws));
// #endregion
// #region inbound table properties
const [players, setPlayers] = createStore<PlayerStore>({});
wsEvents
.thru(extractProperty("playersPresent"))
.onValue((P) =>
setPlayers(
Object.fromEntries(
P.map((p) => [
p,
p in players ? players[p] : { name: "", ready: false },
])
)
)
);
wsEvents.thru(extractProperty("playerNames")).onValue((P) =>
Object.entries(P)
.filter(([player]) => player in players)
.map(([player, name]) => setPlayers(player, "name", name))
);
wsEvents.thru(extractProperty("playersReady")).onValue((P) =>
Object.entries(P)
.filter(([player]) => player in players)
.map(([player, ready]) => setPlayers(player, "ready", ready))
);
// #endregion
// #region inbound game properties
const [gameConfig, setGameConfig] = createSynced({
ws: wsEvents.thru(extractProperty("gameConfig")) as Property<
{ game: string; players: string[] },
any
>,
sendWs: (gameConfig) => sendWs({ gameConfig }),
}); });
const view = wsEvents.thru(extractProperty("view")).thru(createObservable);
const sendWs = (msg: TWsIn) => wsPromise.then((ws) => ws.send(msg)); // #endregion
const wsEvents = fromPromise(wsPromise).flatMap((ws) =>
fromWebsocket<TWsOut>(ws)
);
onCleanup(() => wsPromise.then((ws) => ws.close()));
const presenceEvents = wsEvents.filter(
(evt) => evt.playersPresent !== undefined
);
const gameEvents = wsEvents.filter((evt) => evt.view !== undefined);
const resultEvents = wsEvents.filter((evt) => evt.results !== undefined);
const players = createObservableWithInit<string[]>(
presenceEvents.map((evt) => evt.playersPresent!),
[]
);
const playerNames = createObservableStore(
wsEvents
.filter((evt) => evt.playerNames != null)
.map(({ playerNames }) => playerNames!)
.toProperty(),
{}
);
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() })); onMount(() => {
createEffect(() => sendWs({ name: name() })); ws = api.ws(props).subscribe();
const viewProp = gameEvents.map((evt) => evt.view).toProperty(); ws.on("open", () => {
viewProp.log(); wsEvents.plug(fromWebsocket<TWsOut>(ws));
const view = createObservable(viewProp);
const results = createObservable<string>(
merge([
gameEvents
.filter((evt) => "view" in evt && evt.view == null)
.map(() => undefined),
resultEvents.map((evt) => evt.results),
])
);
// 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 ( return (
<TableContext.Provider <TableContext.Provider
value={{ value={{
sendWs,
wsEvents, wsEvents,
view, sendWs,
playerNames,
tableRef,
players, players,
setPlayers,
gameConfig,
setGameConfig,
view,
}} }}
> >
{/* Player avatars around the table */}
<div class="flex justify-around p-t-14"> <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) => { {(player, i) => {
const verticalOffset = () => { 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 x = Math.abs((2 * i() + 1) / (N * 2) - 0.5);
const y = Math.sqrt(1 - x * x); const y = Math.sqrt(1 - x * x);
return 1 - y; return 1 - y;
@@ -118,8 +157,10 @@ export default (props: { tableKey: string }) => {
}} }}
</For> </For>
</div> </div>
{/* The table body itself */}
<div <div
id="table" ref={tableRef}
class={cx( class={cx(
"fixed", "fixed",
@@ -141,29 +182,18 @@ export default (props: { tableKey: string }) => {
}} }}
> >
<Show when={view() == null}> <Show when={view() == null}>
<div class="absolute tc mt-8 flex gap-4"> <GamePicker />
<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>
</Show> </Show>
</div> </div>
<Show when={view() != null}>
<Game /> {/* The game being played */}
</Show> <Dynamic
<Show when={results() != null}> component={
<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"> gameConfig()?.game ?? "" in GAMES
{playerNames[results()!]} won! ? GAMES[gameConfig()!.game as keyof typeof GAMES]
</span> : undefined
</Show> }
/>
</TableContext.Provider> </TableContext.Provider>
); );
}; };

View File

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

View File

@@ -0,0 +1,149 @@
import type {
SimpleAction,
SimplePlayerView,
} from "@games/shared/games/simple";
import { Accessor, createEffect, For, Show, useContext } from "solid-js";
import { Portal } from "solid-js/web";
import { me } from "~/profile";
import { createObservable, extractProperty } from "../../fn";
import FannedHand from "../FannedHand";
import Hand from "../Hand";
import Pile from "../Pile";
import { TableContext } from "../Table";
export default () => {
const table = useContext(TableContext)!;
const view = table.view as Accessor<SimplePlayerView>;
const Configuration = () => (
<Show when={view() == null}>
<Portal mount={table.tableRef}>
<div class="absolute center grid grid-cols-2 gap-col-2 text-xl">
<label for="allow discards" style={{ "text-align": "right" }}>
Allow discards
</label>
<input
type="checkbox"
id="allow discards"
style={{ width: "50px" }}
checked={table.gameConfig()?.["can discard"] ?? false}
onChange={(evt) =>
table.setGameConfig({
...table.gameConfig(),
"can discard": evt.target.checked,
})
}
/>
<label for="to win" style={{ "text-align": "right" }}>
Cards to win
</label>
<input
type="number"
id="to win"
style={{
"text-align": "center",
width: "50px",
color: "var(--yellow)",
}}
value={table.gameConfig()["cards to win"]}
onChange={(evt) =>
table.setGameConfig({
...table.gameConfig(),
"cards to win": Number.parseInt(evt.target.value),
})
}
/>
</div>
</Portal>
</Show>
);
const submitAction = (action: SimpleAction) => table.sendWs({ action });
const ActiveGame = () => (
<Show when={view() != null}>
{/* Main pile in the middle of the table */}
<Pile
count={view().deckCount}
scale={0.8}
class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })}
/>
{/* Your own hand */}
<Hand
class="fixed bc"
hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })}
/>
{/* Other players' hands */}
<For
each={Object.entries(view().playerHandCounts).filter(
([key, _]) => key in table.players
)}
>
{([playerKey, handCount], i) => (
<Portal
mount={table.players[playerKey].ref}
ref={(ref) => {
const midOffset =
i() + 0.5 - Object.values(view().playerHandCounts).length / 2;
ref.style = `position: absolute; display: flex; justify-content: center; top: 65%;`;
}}
>
<FannedHand handCount={handCount} />
</Portal>
)}
</For>
{/* Turn indicator */}
<div
class="absolute tc text-align-center"
style={{
"background-color":
view().playerTurn == me() ? "var(--yellow)" : "transparent",
color: view().playerTurn == me() ? "var(--dark)" : "var(--light)",
}}
>
It's{" "}
<span class="font-bold">
{view().playerTurn == me()
? "your"
: table.players[view().playerTurn].name + "'s"}
</span>{" "}
turn
</div>
{/* Quit button */}
<button
class="button fixed tl m-4 p-1"
onClick={() => {
table.sendWs({ quit: true });
}}
>
Quit
</button>
</Show>
);
const results = table.wsEvents
.thru(extractProperty("results"))
.thru(createObservable);
const Results = () => (
<Show when={results() != null}>
<span class="bg-[var(--light)] text-[var(--dark)] rounded-[24px] border-2 border-[var(--dark)] absolute center p-4 shadow-lg text-[4em] text-center">
{table.players[results()!].name} won!
</span>
</Show>
);
return (
<>
<Configuration />
<ActiveGame />
<Results />
</>
);
};

View File

@@ -1,6 +1,8 @@
import { Observable } from "kefir"; import { createLatest } from "@solid-primitives/memo";
import { Accessor, createSignal } from "solid-js"; import { Observable, Property, Stream } from "kefir";
import { Accessor, createEffect, createSignal } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import type { ExtractPropertyType, UnionKeys } from "@games/shared/types";
declare global { declare global {
interface Array<T> { interface Array<T> {
@@ -39,11 +41,30 @@ export const createObservableWithInit = <T>(
export const cx = (...classes: string[]) => classes.join(" "); export const cx = (...classes: string[]) => classes.join(" ");
export const createObservableStore = <T extends object = {}>( export const createObservableStore =
obs: Observable<T, any>, <T extends object = {}>(init: T) =>
init: T (obs: Observable<T, any>) => {
) => { const [store, setStore] = createStore<T>(init);
const [store, setStore] = createStore<T>(init); obs.onValue((val) => setStore(val));
obs.onValue((val) => setStore(val)); return store;
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;
}; };

View File

@@ -1,12 +1,10 @@
import { createEffect, createResource, createSignal, Resource } from "solid-js";
import { ApiType } from "./fn";
import api from "./api";
import hash from "object-hash";
import { makePersisted } from "@solid-primitives/storage"; 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 mePromise = api.whoami.post().then((r) => r.data);
export const [me] = createResource(() => mePromise); export const [me] = createResource(() => mePromise);
createEffect(() => console.log(me()));
export const playerColor = (humanKey: string) => export const playerColor = (humanKey: string) =>
"#" + hash(humanKey).substring(0, 6); "#" + hash(humanKey).substring(0, 6);

View File

@@ -3,38 +3,44 @@ import { A } from "@solidjs/router";
export default () => { export default () => {
const randomTablePath = `/t/abcd`; const randomTablePath = `/t/abcd`;
return ( return (
<div class="flex flex-col absolute center"> <>
<h1>Welcome to games.drm.dev!</h1> <div class="flex flex-col absolute center">
<p> <h1>Welcome to games.drm.dev!</h1>
This website is a real-time multiplayer platform for playing <p>
card games online. This website is a real-time multiplayer platform for playing card
</p> games online.
<br /> </p>
<p> <br />
Games happen at <strong>tables</strong>. A table is any url of <p>
the form{" "} Games happen at <strong>tables</strong>. A table is any url of the
<span class="font-mono text-[var(--light-purple)]"> form{" "}
games.drm.dev/t/ <span class="font-mono text-[var(--light-purple)]">
<span class="text-[var(--yellow)]">*</span> games.drm.dev/t/
</span> <span class="text-[var(--yellow)]">*</span>
</p> </span>
<br /> </p>
<p> <br />
Go to the same one as your friend and you will find them there! <p>Go to the same one as your friend and you will find them there!</p>
</p> <br />
<br /> <p>
<p> If you have a table key in mind (the part after /t/), then plug it in
If you have a table key in mind (the part after /t/), then plug to your URL bar! Or, here's a couple links to random tables:
it in to your URL bar! Or, here's a couple links to random </p>
tables: <br />
</p> <p>
<br /> With no one in it:{" "}
<p> <A href={randomTablePath}>
With no one in it:{" "} https://www.games.drm.dev{randomTablePath}
<A href={randomTablePath}> </A>
https://www.games.drm.dev{randomTablePath} </p>
</A> </div>
</p> <a href="https://brainmade.org" target="_blank">
</div> <img
src="https://brainmade.org/white-logo.svg"
class="fixed bl m-2"
width="80"
/>
</a>
</>
); );
}; };

View File

@@ -1,21 +1,9 @@
import { Game } from "@games/shared/games";
import { Human } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Elysia, t } from "elysia"; import { Elysia } from "elysia";
import { combine } from "kefir"; import { generateTokenAndKey, resolveToken } from "./human";
import Bus from "kefir-bus";
import { liveTable, WsIn, WsOut } from "./table";
import { err } from "./logging"; import { err } from "./logging";
import { generateTokenAndKey, resolveToken, tokenExists } from "./human"; import { liveTable, WsIn, WsOut } from "./table";
import type { ExtractPropertyType, UnionKeys } from "@games/shared/types";
export const WS = Bus<
{
type: "open" | "message" | "error" | "close";
humanKey: string;
tableKey: string;
},
unknown
>();
const api = new Elysia({ prefix: "/api" }) const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => { .post("/whoami", async ({ cookie: { token } }) => {
@@ -57,6 +45,7 @@ const api = new Elysia({ prefix: "/api" })
...table.outputs.global, ...table.outputs.global,
...(table.outputs.player[humanKey] ?? {}), ...(table.outputs.player[humanKey] ?? {}),
}).forEach(([type, stream]) => }).forEach(([type, stream]) =>
// @ts-ignore
stream.onValue((v) => send({ [type]: v })) stream.onValue((v) => send({ [type]: v }))
); );
}, },

View File

@@ -16,7 +16,7 @@ new Elysia()
}) })
) )
.onError(({ error }) => console.error(error)) // .onError(({ error }) => console.error(error))
.get("/ping", () => "pong") .get("/ping", () => "pong")
.use(api) .use(api)

View File

@@ -10,7 +10,7 @@ export const log = (value: unknown) => LogBus.emit(value);
export const err = (value: unknown) => export const err = (value: unknown) =>
LogBus.emitEvent({ type: "error", value }); LogBus.emitEvent({ type: "error", value });
LogStream.log(); // LogStream.log();
LogStream.onError((err) => { // LogStream.onError((err) => {
console.error(err); // console.error(err);
}); // });

View File

@@ -12,18 +12,26 @@ import { t } from "elysia";
import { combine, constant, merge, Observable, pool, Property } from "kefir"; import { combine, constant, merge, Observable, pool, Property } from "kefir";
import Bus, { type Bus as TBus } from "kefir-bus"; import Bus, { type Bus as TBus } from "kefir-bus";
import { log } from "./logging"; import { log } from "./logging";
import simple from "@games/shared/games/simple";
export const WsOut = t.Object({ const DEFAULT_GAME_CONFIG = simple.defaultConfig;
playersPresent: t.Optional(t.Array(t.String())),
playerNames: t.Optional(t.Record(t.String(), t.String())), export const WsOut = t.Union([
playersReady: t.Optional(t.Nullable(t.Record(t.String(), t.Boolean()))), t.Object({ playersPresent: t.Array(t.String()) }),
gameConfig: t.Optional(t.Any()), t.Object({ playerNames: t.Record(t.String(), t.String()) }),
view: t.Optional(t.Any()), t.Object({ playersReady: t.Record(t.String(), t.Boolean()) }),
results: t.Optional(t.Any()), 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 type TWsOut = typeof WsOut.static;
export const WsIn = t.Union([ export const WsIn = t.Union([
t.Object({ name: t.String() }), 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({ ready: t.Boolean() }),
t.Object({ action: t.Any() }), t.Object({ action: t.Any() }),
t.Object({ quit: t.Literal(true) }), t.Object({ quit: t.Literal(true) }),
@@ -136,8 +144,14 @@ export const liveTable = <
}); });
}); });
const { name, ready, action, quit } = partition( const {
["name", "ready", "action", "quit"], name,
ready,
action,
quit,
gameConfig: clientGameConfigs,
} = partition(
["name", "ready", "action", "quit", "gameConfig"],
messages messages
) as unknown as { ) as unknown as {
// yuck // yuck
@@ -145,6 +159,7 @@ export const liveTable = <
ready: Observable<Attributed & { ready: boolean }, any>; ready: Observable<Attributed & { ready: boolean }, any>;
action: Observable<Attributed & { action: GameAction }, any>; action: Observable<Attributed & { action: GameAction }, any>;
quit: Observable<Attributed, any>; quit: Observable<Attributed, any>;
gameConfig: Observable<Attributed & { gameConfig: GameConfig }, any>;
}; };
const gameEnds = quit.map((_) => null); const gameEnds = quit.map((_) => null);
@@ -202,7 +217,7 @@ export const liveTable = <
const gameImpl = gameConfig const gameImpl = gameConfig
.filter((cfg) => cfg.game in GAMES) .filter((cfg) => cfg.game in GAMES)
.map((config) => GAMES[config.game as GameKey](config)) .map((config) => GAMES[config.game as GameKey].impl(config))
.toProperty(); .toProperty();
const withGame = <T>(obs: Observable<T, any>) => const withGame = <T>(obs: Observable<T, any>) =>
@@ -226,7 +241,7 @@ export const liveTable = <
prev, prev,
[{ action, humanKey }, game]: [ [{ action, humanKey }, game]: [
Attributed & { action: GameAction }, Attributed & { action: GameAction },
Game ReturnType<Game["impl"]>
] ]
) => ) =>
prev && prev &&
@@ -258,18 +273,19 @@ export const liveTable = <
gameConfigPool.plug( gameConfigPool.plug(
multiScan( multiScan(
{ DEFAULT_GAME_CONFIG,
game: "simple",
players: [] as string[],
},
[ [
playersPresent.filterBy(gameIsActive.map((active) => !active)), playersPresent.filterBy(gameIsActive.thru(invert)),
(prev, players) => ({ (prev, players) => ({
...prev, ...prev,
players, 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> ) as unknown as Observable<GameConfig, any>
); );

View File

@@ -3,21 +3,28 @@ import simple from "./simple";
export type Game< export type Game<
S = unknown, // state S = unknown, // state
A = unknown, // action A = unknown, // action
E extends { error: any } = { error: any }, // error E = unknown, // error
V = unknown, // view V = unknown, // view
R = unknown // results R = unknown, // results
C extends { game: string; players: string[] } = {
game: string;
players: string[];
}
> = { > = {
title: string; impl: (config: C) => {
rules: string; title: string;
init: () => S; rules: string;
resolveAction: (p: { state: S; action: A; humanKey: string }) => S | E; init: () => S;
getView: (p: { state: S; humanKey: string }) => V; resolveAction: (p: { state: S; action: A; humanKey: string }) => S | E;
resolveQuit: (p: { state: S; humanKey: string }) => S; getView: (p: { state: S; humanKey: string }) => V;
getResult: (state: S) => R | undefined; resolveQuit: (p: { state: S; humanKey: string }) => S;
getResult: (state: S) => R | undefined;
};
defaultConfig: C;
}; };
export const GAMES: { export const GAMES: {
[key: string]: (config: { game: string; players: string[] }) => Game; [key: string]: Game;
} = { } = {
// renaissance, // renaissance,
simple, simple,

View File

@@ -1,10 +1,13 @@
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards"; import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
import { heq } from "@games/shared/utils"; import { heq } from "@games/shared/utils";
import type { Game } from "."; import type { Game } from ".";
import { XOR } from "ts-xor";
export type SimpleConfiguration = { export type SimpleConfiguration = {
game: "simple"; game: "simple";
players: string[]; players: string[];
"can discard": boolean;
"cards to win": number;
}; };
// omniscient game state // omniscient game state
@@ -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 = ({ export const resolveSimpleAction = ({
config, config,
state, state,
@@ -62,13 +75,18 @@ export const resolveSimpleAction = ({
humanKey: string; humanKey: string;
}): SimpleGameState => { }): SimpleGameState => {
const playerHand = state.playerHands[humanKey]; const playerHand = state.playerHands[humanKey];
if (playerHand == null) { if (playerHand == null) {
throw new Error( throw {
`${humanKey} is not a player in this game; they cannot perform actions` message: "You are not a part of this game!",
); class: "go away",
} satisfies SimpleError;
} }
if (humanKey != config.players[state.turnIdx]) { if (humanKey != config.players[state.turnIdx]) {
throw new Error(`It's not ${humanKey}'s turn!`); throw {
message: "It's not your turn!",
class: "chill",
} satisfies SimpleError;
} }
const numPlayers = Object.keys(state.playerHands).length; const numPlayers = Object.keys(state.playerHands).length;
@@ -87,6 +105,13 @@ export const resolveSimpleAction = ({
}; };
} else { } else {
// action.type == discard // action.type == discard
if (config["can discard"] == false) {
throw {
message: "You're not allowed to discard!",
class: "ah ah",
} satisfies SimpleError;
}
const cardIndex = playerHand.findIndex(heq(action.card)); const cardIndex = playerHand.findIndex(heq(action.card));
return { return {
deck: [action.card, ...state.deck], deck: [action.card, ...state.deck],
@@ -103,10 +128,14 @@ export const resolveSimpleAction = ({
export type SimpleResult = string; export type SimpleResult = string;
type SimpleError = { error: "whoops!" }; export default {
defaultConfig: {
export default (config: SimpleConfiguration) => game: "simple",
({ players: [],
"can discard": true,
"cards to win": 5,
},
impl: (config: SimpleConfiguration) => ({
title: "Simple", title: "Simple",
rules: "You can draw, or you can discard. Then your turn is up.", rules: "You can draw, or you can discard. Then your turn is up.",
init: () => newSimpleGameState(config), init: () => newSimpleGameState(config),
@@ -116,12 +145,14 @@ export default (config: SimpleConfiguration) =>
resolveQuit: () => null, resolveQuit: () => null,
getResult: (state) => getResult: (state) =>
Object.entries(state.playerHands).find( Object.entries(state.playerHands).find(
([_, hand]) => hand.length === 52 ([_, hand]) => hand.length === config["cards to win"]
)?.[0], )?.[0],
} satisfies Game< }),
SimpleGameState, } satisfies Game<
SimpleAction, SimpleGameState,
SimpleError, SimpleAction,
SimplePlayerView, SimpleError,
SimpleResult SimplePlayerView,
>); SimpleResult,
SimpleConfiguration
>;

9
pkg/shared/types.ts Normal file
View File

@@ -0,0 +1,9 @@
export type UnionKeys<T> = T extends any ? keyof T : never;
export type ExtractPropertyType<
T,
P extends string | number | symbol
> = T extends {
[K in P]: any;
}
? T[P]
: never;

14
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@elysiajs/eden': '@elysiajs/eden':
specifier: ^1.3.2 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)) 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': '@solid-primitives/scheduled':
specifier: ^1.5.2 specifier: ^1.5.2
version: 1.5.2(solid-js@1.9.9) version: 1.5.2(solid-js@1.9.9)
@@ -593,6 +596,11 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'} 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': '@solid-primitives/scheduled@1.5.2':
resolution: {integrity: sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA==} resolution: {integrity: sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA==}
peerDependencies: peerDependencies:
@@ -2833,6 +2841,12 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {} '@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)': '@solid-primitives/scheduled@1.5.2(solid-js@1.9.9)':
dependencies: dependencies:
solid-js: 1.9.9 solid-js: 1.9.9