end to end!

This commit is contained in:
2025-08-23 20:32:50 -04:00
parent a2e8887a0b
commit 3347452ec4
12 changed files with 114 additions and 73 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "games", "name": "games",
"type": "module", "type": "module",
"version": "0.0.2", "version": "0.0.4",
"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,7 +1,7 @@
{ {
"name": "@games/client", "name": "@games/client",
"type": "module", "type": "module",
"version": "0.0.3", "version": "0.0.4",
"scripts": { "scripts": {
"dev": "vite --port 3000", "dev": "vite --port 3000",
"build": "vite build" "build": "vite build"
@@ -11,6 +11,7 @@
"@solid-primitives/scheduled": "^1.5.2", "@solid-primitives/scheduled": "^1.5.2",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"kefir": "^3.8.8",
"kefir-bus": "^2.3.1", "kefir-bus": "^2.3.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"solid-js": "^1.9.5" "solid-js": "^1.9.5"

View File

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

View File

@@ -1,25 +1,36 @@
import { Accessor, createContext } from "solid-js"; import { Accessor, createContext, useContext } from "solid-js";
import { import {
SimpleAction, SimpleAction,
SimplePlayerView, SimplePlayerView,
vSimpleGameState,
} from "../../../server/src/games/simple"; } from "../../../server/src/games/simple";
import Pile from "./Pile";
import { TableContext } from "./Table";
import Hand from "./Hand";
export const GameContext = createContext<{ export const GameContext = createContext<{
view: Accessor<SimplePlayerView | undefined>; view: Accessor<SimplePlayerView>;
submitAction: (action: SimpleAction) => Promise<any>; submitAction: (action: SimpleAction) => any;
}>(); }>();
export default () => { export default () => {
const table = useContext(TableContext)!;
const view = table.view as Accessor<SimplePlayerView>;
const submitAction = (action: SimpleAction) => table.sendWs({ action });
return ( return (
<> <GameContext.Provider value={{ view, submitAction }}>
Game started! <Pile
{/* <Pile count={view().deckCount}
count={view()!.deckCount}
class="cursor-pointer fixed center" class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })} onClick={() => submitAction({ type: "draw" })}
/> />
<Hand class="fixed bc" hand={view()!.myHand} /> */} <Hand
</> class="fixed bc"
hand={view().myHand}
onClickCard={(card) => submitAction({ type: "discard", card })}
/>
</GameContext.Provider>
); );
}; };

View File

@@ -1,26 +1,26 @@
import { Component, For, useContext } from "solid-js"; import { Component, For, useContext } from "solid-js";
import Card from "./Card"; import Card from "./Card";
import { Hand } from "../../../shared/cards"; import type { Hand as THand, Card as TCard } from "../../../shared/cards";
import { GameContext } from "./Game"; import { GameContext } from "./Game";
import { produce } from "solid-js/store"; import { produce } from "solid-js/store";
import { Stylable } from "./toolbox"; import { Stylable } from "./toolbox";
export default ((props) => { export default ((props) => {
const { submitAction, view } = useContext(GameContext)!;
return ( return (
<div class={"hand " + props.class} style={props.style}> <div class={"hand " + props.class} style={props.style}>
<For each={props.hand}> <For each={props.hand}>
{(card) => ( {(card, i) => (
<Card <Card
card={card} card={card}
style={{ style={{
cursor: "pointer", cursor: "pointer",
}} }}
onClick={() => submitAction({ type: "discard", card })} onClick={() => props.onClickCard?.(card, i())}
/> />
)} )}
</For> </For>
</div> </div>
); );
}) satisfies Component<{ hand: Hand } & Stylable>; }) satisfies Component<
{ hand: THand; onClickCard?: (card: TCard, i: number) => any } & Stylable
>;

View File

@@ -0,0 +1,18 @@
import { playerColor, profile } from "../profile";
import { Stylable } from "./toolbox";
export default (props: { playerKey: string } & Stylable) => {
return (
<div
style={{
...props.style,
"background-color": playerColor(props.playerKey),
}}
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
>
<p style={{ "font-size": "1em" }}>
{profile(props.playerKey)()?.name}
</p>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { import {
Accessor, Accessor,
createContext, createContext,
createEffect,
createSignal, createSignal,
For, For,
onCleanup, onCleanup,
@@ -14,6 +15,8 @@ import Bus from "kefir-bus";
import { createObservable, createObservableWithInit, WSEvent } from "../fn"; import { createObservable, createObservableWithInit, WSEvent } from "../fn";
import { EdenWS } from "@elysiajs/eden/treaty"; import { EdenWS } from "@elysiajs/eden/treaty";
import { TWsIn, TWsOut } from "../../../server/src/table"; import { TWsIn, TWsOut } from "../../../server/src/table";
import Player from "./Player";
import Game from "./Game";
export const TableContext = createContext<{ export const TableContext = createContext<{
players: Accessor<string[]>; players: Accessor<string[]>;
@@ -27,18 +30,20 @@ export default (props: { tableKey: string }) => {
onCleanup(() => ws.close()); onCleanup(() => 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 != null);
const players = createObservableWithInit<string[]>( const players = createObservableWithInit<string[]>(
presenceEvents.map((evt) => evt.players!), presenceEvents.map((evt) => evt.players!),
[] []
); );
const view = createObservable(gameEvents.map((evt) => evt.view)); const view = createObservable(gameEvents.map((evt) => evt.view));
return ( return (
<TableContext.Provider <TableContext.Provider
value={{ value={{
sendWs: ws.send, sendWs: (evt) => ws.send(evt),
view, view,
players, players,
}} }}
@@ -53,19 +58,14 @@ export default (props: { tableKey: string }) => {
return 1 - y; return 1 - y;
}; };
return ( return (
<div <Player
playerKey={player}
style={{ style={{
transform: `translate(0, ${ transform: `translate(0, ${
verticalOffset() * 150 verticalOffset() * 150
}vh)`, }vh)`,
"background-color": playerColor(player),
}} }}
class="w-20 h-20 rounded-full flex justify-center items-center" />
>
<p style={{ "font-size": "1em" }}>
{profile(player)()?.name}
</p>
</div>
); );
}} }}
</For> </For>
@@ -89,7 +89,7 @@ export default (props: { tableKey: string }) => {
</Show> </Show>
</div> </div>
<Show when={view() != null}> <Show when={view() != null}>
<div>Game started!</div> <Game />
</Show> </Show>
</TableContext.Provider> </TableContext.Provider>
); );

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

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

View File

@@ -62,31 +62,6 @@ const api = new Elysia({ prefix: "/api" })
) )
.get("/games", () => [{ key: "simple", name: "simple" }]) .get("/games", () => [{ key: "simple", name: "simple" }])
.ws("/ws/:tableKey", { .ws("/ws/:tableKey", {
response: WsOut,
body: WsIn,
message(
{
data: {
humanKey,
params: { tableKey },
},
},
body
) {
const {
inputs: { gameProposals, gameStarts, gameActions },
} = liveTable(tableKey);
if ("proposeGame" in body) {
gameProposals.emit(body);
} else if ("startGame" in body) {
gameStarts.emit(body);
} else if ("action" in body) {
gameActions.emit(body);
}
},
async open({ async open({
data: { data: {
params: { tableKey }, params: { tableKey },
@@ -108,6 +83,32 @@ const api = new Elysia({ prefix: "/api" })
); );
table.inputs.presenceChanges.emit({ humanKey, presence: "joined" }); table.inputs.presenceChanges.emit({ humanKey, presence: "joined" });
}, },
response: WsOut,
body: WsIn,
message(
{
data: {
humanKey,
params: { tableKey },
},
},
body
) {
const {
inputs: { gameProposals, gameStarts, gameActions },
} = liveTable(tableKey);
if ("proposeGame" in body) {
gameProposals.emit(body);
} else if ("startGame" in body) {
gameStarts.emit(body);
} else if ("action" in body) {
gameActions.emit({ humanKey, ...body.action });
}
},
async close({ async close({
data: { data: {
params: { tableKey }, params: { tableKey },

View File

@@ -71,9 +71,16 @@ export const resolveAction = (
humanId: string, humanId: string,
action: SimpleAction action: SimpleAction
): SimpleGameState => { ): SimpleGameState => {
console.log("attempting to resolve action", JSON.stringify(action));
if (!(humanId in state.players)) {
throw Error(
`${humanId} is not a player in this game; they cannot perform actions`
);
}
const playerHand = state.players[humanId]; const playerHand = state.players[humanId];
if (action.type == "draw") { if (action.type == "draw") {
const [drawn, ...rest] = state.deck; const [drawn, ...rest] = state.deck;
console.log("drew card", JSON.stringify(drawn));
return { return {
deck: rest, deck: rest,
players: { players: {

View File

@@ -21,15 +21,16 @@ export const WsIn = t.Union([
]); ]);
export type TWsIn = typeof WsIn.static; export type TWsIn = typeof WsIn.static;
type Attributed = { humanKey: string };
type TablePayload<GameState, GameAction> = { type TablePayload<GameState, GameAction> = {
inputs: { inputs: {
presenceChanges: TBus< presenceChanges: TBus<
{ humanKey: string; presence: "joined" | "left" }, Attributed & { presence: "joined" | "left" },
never never
>; >;
gameProposals: TBus<{ proposeGame: string }, never>; gameProposals: TBus<{ proposeGame: string }, never>;
gameStarts: TBus<{ startGame: true }, never>; gameStarts: TBus<{ startGame: true }, never>;
gameActions: TBus<GameAction, never>; gameActions: TBus<Attributed & GameAction, never>;
}; };
outputs: { outputs: {
playersPresent: Property<string[], never>; playersPresent: Property<string[], never>;
@@ -63,26 +64,22 @@ export const liveTable = <GameState, GameAction>(key: string) => {
}, [] as string[]); }, [] as string[]);
const gameState = transform( const gameState = transform(
null as GameState | null, null as SimpleGameState | null,
[ [
gameStarts.thru((Evt) => combine([gameStarts], [playersPresent], (evt, players) => ({
combine([Evt], [playersPresent], (evt, players) => ({
...evt, ...evt,
players, players,
})) })),
),
(prev, evt: { players: string[] }) => (prev, evt: { players: string[] }) =>
prev == null ? (newGame(evt.players) as GameState) : prev, prev == null
? (newGame(evt.players) as SimpleGameState)
: prev,
], ],
[ [
gameActions, gameActions,
(prev, evt: GameAction) => (prev, evt: Attributed & SimpleAction) =>
prev != null prev != null
? (resolveAction( ? resolveAction(prev, evt.humanKey, evt)
prev as unknown as SimpleGameState,
"evt",
evt as SimpleAction
) as GameState)
: prev, : prev,
] ]
).toProperty(); ).toProperty();

3
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
js-cookie: js-cookie:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5 version: 3.0.5
kefir:
specifier: ^3.8.8
version: 3.8.8
kefir-bus: kefir-bus:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1(kefir@3.8.8) version: 2.3.1(kefir@3.8.8)