[wip] configs are synced but gameplay is broken

This commit is contained in:
2025-09-07 22:56:56 -04:00
parent 46002403c8
commit ae6a79aadd
9 changed files with 239 additions and 154 deletions

View File

@@ -8,6 +8,7 @@
},
"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",

View File

@@ -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);

View File

@@ -1,9 +1,11 @@
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 (
<div class={props.class} style={props.style}>
<For each={Array(props.handCount)}>
{(_, i) => {
const midOffset = i() + 0.5 - props.handCount / 2;
@@ -25,5 +27,6 @@ export default (props: { handCount: number }) => {
);
}}
</For>
</div>
);
};

View File

@@ -1,5 +1,4 @@
import { createSignal, onMount, useContext } from "solid-js";
import { createObservableWithInit, extractProperty } from "~/fn";
import { onMount, useContext } from "solid-js";
import { playerColor } from "~/profile";
import { TableContext } from "./Table";
import { Stylable } from "./toolbox";

View File

@@ -1,6 +1,6 @@
import type { TWsIn, TWsOut } from "@games/server/src/table";
import games from "@games/shared/games/index";
import { fromPromise, Stream } from "kefir";
import { fromPromise, pool, Property, Stream } from "kefir";
import {
Accessor,
createContext,
@@ -8,19 +8,15 @@ 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,
createObservableStore,
createObservableWithInit,
cx,
extractProperty,
} from "~/fn";
import { me, mePromise, name } from "~/profile";
import { createObservable, createSynced, cx, extractProperty } from "~/fn";
import { me, name } from "~/profile";
import GAMES from "./games";
import Player from "./Player";
@@ -35,6 +31,10 @@ export const TableContext = createContext<{
wsEvents: Stream<TWsOut, any>;
sendWs: (msg: TWsIn) => void;
tableRef: HTMLDivElement;
gameConfig: Accessor<any>;
setGameConfig: Setter<any>;
players: PlayerStore;
setPlayers: SetStoreFunction<PlayerStore>;
@@ -42,19 +42,10 @@ export const TableContext = createContext<{
}>();
export default (props: { tableKey: string }) => {
// #region Websocket Setup
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()));
// #region Websocket declaration
const wsEvents = pool<TWsOut, any>();
let sendWs: (msg: TWsIn) => void = () => {};
// #endregion
// #region inbound table properties
@@ -87,18 +78,26 @@ export default (props: { tableKey: string }) => {
// #endregion
// #region inbound game properties
const gameConfig = wsEvents
.thru(extractProperty("gameConfig"))
.thru(createObservable);
const [gameConfig, setGameConfig] = createSynced({
ws: wsEvents.thru(extractProperty("gameConfig")) as Property<any, any>,
sendWs: (gameConfig) => sendWs({ gameConfig }),
});
const view = wsEvents.thru(extractProperty("view")).thru(createObservable);
// #endregion
// #region outbound signals
const [ready, setReady] = createSignal(false);
createEffect(() => sendWs({ ready: ready() }));
onMount(() => {
const ws = api.ws(props).subscribe();
ws.on("open", () => {
wsEvents.plug(fromWebsocket<TWsOut>(ws));
sendWs = ws.send.bind(ws);
createEffect(() => sendWs({ ready: ready() }));
createEffect(() => sendWs({ name: name() }));
// #endregion
});
onCleanup(() => ws.close());
});
const GamePicker = () => {
return (
@@ -115,13 +114,19 @@ export default (props: { tableKey: string }) => {
);
};
let tableRef!: HTMLDivElement;
return (
<TableContext.Provider
value={{
wsEvents,
sendWs,
tableRef,
players,
setPlayers,
gameConfig,
setGameConfig,
view,
}}
>
@@ -149,7 +154,7 @@ export default (props: { tableKey: string }) => {
{/* The table body itself */}
<div
id="table"
ref={tableRef}
class={cx(
"fixed",

View File

@@ -1,41 +1,68 @@
import { Accessor, createContext, For, Show, useContext } from "solid-js";
import type {
SimpleAction,
SimplePlayerView,
SimpleResult,
} 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";
import { Portal } from "solid-js/web";
import FannedHand from "../FannedHand";
import { createObservable, extractProperty } from "../../fn";
import { Property } from "kefir";
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 });
createEffect(() => console.log(table.gameConfig()));
const results = table.wsEvents
.thru(extractProperty("results"))
.thru(createObservable);
return (
<GameContext.Provider value={{ view, submitAction }}>
{/* Configuration */}
const Configuration = () => (
<Show when={view() == null}>
<div>Configuration!</div>
</Show>
<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,
})
}
/>
{/* Active game */}
<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": 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
@@ -102,13 +129,24 @@ export default () => {
Quit
</button>
</Show>
);
{/* Results */}
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>
</GameContext.Provider>
);
return (
<>
<Configuration />
<ActiveGame />
<Results />
</>
);
};

View File

@@ -1,5 +1,6 @@
import { createLatest } from "@solid-primitives/memo";
import { Observable, Property, Stream } from "kefir";
import { Accessor, createSignal } from "solid-js";
import { Accessor, createEffect, createSignal } from "solid-js";
import { createStore } from "solid-js/store";
declare global {
@@ -63,3 +64,13 @@ export const extractProperty =
(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

@@ -26,6 +26,9 @@ export const WsOut = t.Union([
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) }),
@@ -138,8 +141,14 @@ export const liveTable = <
});
});
const { name, ready, action, quit } = partition(
["name", "ready", "action", "quit"],
const {
name,
ready,
action,
quit,
gameConfig: clientGameConfigs,
} = partition(
["name", "ready", "action", "quit", "gameConfig"],
messages
) as unknown as {
// yuck
@@ -147,6 +156,7 @@ export const liveTable = <
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);
@@ -265,13 +275,19 @@ export const liveTable = <
players: [] as string[],
},
[
playersPresent.filterBy(gameIsActive.map((active) => !active)),
playersPresent.filterBy(gameIsActive.thru(invert)),
(prev, players) => ({
...prev,
players,
}),
],
[
clientGameConfigs
// .filterBy(gameIsActive.thru(invert))
.map(({ gameConfig }) => gameConfig),
// @ts-ignore
(prev, config) => ({ ...config, players: prev.players }),
]
// TODO: Add player defined config changes
) as unknown as Observable<GameConfig, any>
);

14
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ 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)
@@ -593,6 +596,11 @@ 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:
@@ -2833,6 +2841,12 @@ 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