Compare commits
5 Commits
9b918b1c6a
...
0f015841ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f015841ff | |||
| 6c45e7b114 | |||
| a117f6703f | |||
| 4bcf071668 | |||
| 6c64886f2a |
@@ -26,9 +26,10 @@ const Profile = () => {
|
|||||||
Name:{" "}
|
Name:{" "}
|
||||||
<input
|
<input
|
||||||
value={profile()?.data?.name ?? ""}
|
value={profile()?.data?.name ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
api.setName.post({ name: e.target.value })
|
dialogRef.close();
|
||||||
}
|
void api.setName.post({ name: e.target.value });
|
||||||
|
}}
|
||||||
class="bg-emerald-200 border-1.5 rounded-full px-4"
|
class="bg-emerald-200 border-1.5 rounded-full px-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ 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 { me, profile } from "../profile";
|
||||||
|
import Hand from "./Hand";
|
||||||
import Pile from "./Pile";
|
import Pile from "./Pile";
|
||||||
import { TableContext } from "./Table";
|
import { TableContext } from "./Table";
|
||||||
import Hand from "./Hand";
|
|
||||||
|
|
||||||
export const GameContext = createContext<{
|
export const GameContext = createContext<{
|
||||||
view: Accessor<SimplePlayerView>;
|
view: Accessor<SimplePlayerView>;
|
||||||
@@ -31,6 +31,15 @@ export default () => {
|
|||||||
hand={view().myHand}
|
hand={view().myHand}
|
||||||
onClickCard={(card) => submitAction({ type: "discard", card })}
|
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>
|
||||||
</GameContext.Provider>
|
</GameContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Component, For, useContext } from "solid-js";
|
import { Component, For } from "solid-js";
|
||||||
|
import type { Card as TCard, Hand as THand } from "../../../shared/cards";
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
import type { Hand as THand, Card as TCard } from "../../../shared/cards";
|
|
||||||
import { GameContext } from "./Game";
|
|
||||||
import { produce } from "solid-js/store";
|
|
||||||
import { Stylable } from "./toolbox";
|
import { Stylable } from "./toolbox";
|
||||||
|
|
||||||
export default ((props) => {
|
export default ((props) => {
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
|
import { createSignal, useContext } from "solid-js";
|
||||||
import { playerColor, profile } from "../profile";
|
import { playerColor, profile } from "../profile";
|
||||||
|
import { TableContext } from "./Table";
|
||||||
import { Stylable } from "./toolbox";
|
import { Stylable } from "./toolbox";
|
||||||
|
import { createObservable, createObservableWithInit } from "../fn";
|
||||||
|
|
||||||
export default (props: { playerKey: string } & Stylable) => {
|
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];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...props.style,
|
...props.style,
|
||||||
"background-color": playerColor(props.playerKey),
|
"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`}
|
class={`${props.class} w-20 h-20 rounded-full flex justify-center items-center`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,35 +2,42 @@ import {
|
|||||||
Accessor,
|
Accessor,
|
||||||
createContext,
|
createContext,
|
||||||
createEffect,
|
createEffect,
|
||||||
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { SimplePlayerView } from "../../../server/src/games/simple";
|
|
||||||
import api, { fromWebsocket } from "../api";
|
|
||||||
import { me, playerColor, profile } from "../profile";
|
|
||||||
import { fromEvents, Stream, stream } from "kefir";
|
|
||||||
import Bus from "kefir-bus";
|
|
||||||
import { createObservable, createObservableWithInit, WSEvent } from "../fn";
|
|
||||||
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 api, { fromWebsocket } from "../api";
|
||||||
|
import { createObservable, createObservableWithInit, cx } from "../fn";
|
||||||
|
import { me } from "../profile";
|
||||||
import Game from "./Game";
|
import Game from "./Game";
|
||||||
|
import Player from "./Player";
|
||||||
|
import { fromPromise, Stream } from "kefir";
|
||||||
|
|
||||||
export const TableContext = createContext<{
|
export const TableContext = createContext<{
|
||||||
players: Accessor<string[]>;
|
|
||||||
view: Accessor<any>;
|
view: Accessor<any>;
|
||||||
sendWs: (msg: TWsIn) => void;
|
sendWs: (msg: TWsIn) => void;
|
||||||
|
wsEvents: Stream<TWsOut, any>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
export default (props: { tableKey: string }) => {
|
export default (props: { tableKey: string }) => {
|
||||||
|
const wsPromise = new Promise<
|
||||||
|
ReturnType<ReturnType<typeof api.ws>["subscribe"]>
|
||||||
|
>((res) => {
|
||||||
const ws = api.ws(props).subscribe();
|
const ws = api.ws(props).subscribe();
|
||||||
const wsEvents = fromWebsocket<TWsOut>(ws);
|
ws.on("open", () => res(ws));
|
||||||
onCleanup(() => ws.close());
|
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 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[]>(
|
||||||
@@ -38,14 +45,16 @@ export default (props: { tableKey: string }) => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [ready, setReady] = createSignal(false);
|
||||||
|
createEffect(() => sendWs({ ready: ready() }));
|
||||||
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: (evt) => ws.send(evt),
|
sendWs,
|
||||||
|
wsEvents,
|
||||||
view,
|
view,
|
||||||
players,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex justify-around p-t-10">
|
<div class="flex justify-around p-t-10">
|
||||||
@@ -72,18 +81,33 @@ export default (props: { tableKey: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="table"
|
id="table"
|
||||||
class="fixed bg-radial from-orange-950 to-stone-950 border-neutral-950 border-4 top-40 bottom-20 left-10 right-10 shadow-lg"
|
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={{
|
style={{
|
||||||
"border-radius": "50% 50%",
|
"border-radius": "50%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={view() == null}>
|
<Show when={view() == null}>
|
||||||
<div class="absolute center">
|
<div class="absolute center">
|
||||||
<button
|
<button
|
||||||
onClick={() => ws.send({ startGame: true })}
|
onClick={() => setReady((prev) => !prev)}
|
||||||
class="button p-1 "
|
class="button p-1 "
|
||||||
>
|
>
|
||||||
Start Game!
|
{ready() ? "Not Ready" : "Ready"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -35,3 +35,5 @@ export const createObservableWithInit = <T>(
|
|||||||
|
|
||||||
return signal;
|
return signal;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cx = (...classes: string[]) => classes.join(" ");
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
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 { me } from "../profile";
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { tableKey } = useParams();
|
const { tableKey } = useParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Show when={me() != null}>
|
||||||
<Table tableKey={tableKey} />
|
<Table tableKey={tableKey} />
|
||||||
<A href={"/"} class="fixed tl m-4 px-2 py-1.5 button">
|
<A href={"/"} class="fixed tl m-4 px-2 py-1.5 button">
|
||||||
Back
|
Back
|
||||||
</A>
|
</A>
|
||||||
</>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,11 +22,13 @@
|
|||||||
"elysia-rate-limit": "^4.4.0",
|
"elysia-rate-limit": "^4.4.0",
|
||||||
"kefir": "^3.8.8",
|
"kefir": "^3.8.8",
|
||||||
"kefir-bus": "^2.3.1",
|
"kefir-bus": "^2.3.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"object-hash": "^3.0.0"
|
"object-hash": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/kefir": "^3.8.11",
|
"@types/kefir": "^3.8.11",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.0",
|
||||||
"prisma": "6.13.0",
|
"prisma": "6.13.0",
|
||||||
"ts-xor": "^1.3.0"
|
"ts-xor": "^1.3.0"
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import {
|
import {
|
||||||
|
getSimplePlayerView,
|
||||||
SimpleAction,
|
SimpleAction,
|
||||||
|
SimpleConfiguration,
|
||||||
SimpleGameState,
|
SimpleGameState,
|
||||||
getKnowledge,
|
|
||||||
getView,
|
|
||||||
} from "./games/simple";
|
} from "./games/simple";
|
||||||
import { human } from "./human";
|
import { human } from "./human";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
import { liveTable, WsOut, WsIn } from "./table";
|
import { liveTable, WsOut, WsIn } from "./table";
|
||||||
import { Human } from "@prisma/client";
|
import { Human } from "@prisma/client";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { combine } from "kefir";
|
||||||
|
|
||||||
const api = new Elysia({ prefix: "/api" })
|
const api = new Elysia({ prefix: "/api" })
|
||||||
.post("/whoami", async ({ cookie: { token } }) => {
|
.post("/whoami", async ({ cookie: { token } }) => {
|
||||||
@@ -72,19 +74,35 @@ const api = new Elysia({ prefix: "/api" })
|
|||||||
},
|
},
|
||||||
send,
|
send,
|
||||||
}) {
|
}) {
|
||||||
const table = liveTable<SimpleGameState, SimpleAction>(tableKey);
|
const table = liveTable<
|
||||||
|
SimpleConfiguration,
|
||||||
|
SimpleGameState,
|
||||||
|
SimpleAction
|
||||||
|
>(tableKey);
|
||||||
|
|
||||||
|
table.inputs.connectionChanges.emit({
|
||||||
|
humanKey,
|
||||||
|
presence: "joined",
|
||||||
|
});
|
||||||
|
|
||||||
table.outputs.playersPresent.onValue((players) =>
|
table.outputs.playersPresent.onValue((players) =>
|
||||||
send({ players })
|
send({ players })
|
||||||
);
|
);
|
||||||
table.outputs.gameState.onValue((gameState) =>
|
|
||||||
send({
|
table.outputs.playersReady.onValue((readys) =>
|
||||||
view:
|
send({ playersReady: readys })
|
||||||
gameState &&
|
|
||||||
getView(getKnowledge(gameState, humanKey), humanKey),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
table.inputs.presenceChanges.emit({ humanKey, presence: "joined" });
|
|
||||||
|
combine(
|
||||||
|
[table.outputs.gameState],
|
||||||
|
[table.outputs.gameConfig],
|
||||||
|
(state, config) =>
|
||||||
|
state &&
|
||||||
|
config &&
|
||||||
|
getSimplePlayerView(config, state, humanKey)
|
||||||
|
)
|
||||||
|
.toProperty()
|
||||||
|
.onValue((view) => send({ view }));
|
||||||
},
|
},
|
||||||
|
|
||||||
response: WsOut,
|
response: WsOut,
|
||||||
@@ -100,15 +118,15 @@ const api = new Elysia({ prefix: "/api" })
|
|||||||
body
|
body
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
inputs: { gameProposals, gameStarts, gameActions },
|
inputs: { readys, actions, quits },
|
||||||
} = liveTable(tableKey);
|
} = liveTable(tableKey);
|
||||||
|
|
||||||
if ("proposeGame" in body) {
|
if ("ready" in body) {
|
||||||
gameProposals.emit(body);
|
readys.emit({ humanKey, ...body });
|
||||||
} else if ("startGame" in body) {
|
|
||||||
gameStarts.emit(body);
|
|
||||||
} else if ("action" in body) {
|
} else if ("action" in body) {
|
||||||
gameActions.emit({ humanKey, ...body.action });
|
actions.emit({ humanKey, ...body.action });
|
||||||
|
} else if ("quit" in body) {
|
||||||
|
quits.emit({ humanKey });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -118,7 +136,7 @@ const api = new Elysia({ prefix: "/api" })
|
|||||||
humanKey,
|
humanKey,
|
||||||
},
|
},
|
||||||
}) {
|
}) {
|
||||||
liveTable(tableKey).inputs.presenceChanges.emit({
|
liveTable(tableKey).inputs.connectionChanges.emit({
|
||||||
humanKey,
|
humanKey,
|
||||||
presence: "left",
|
presence: "left",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,104 +1,103 @@
|
|||||||
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";
|
||||||
|
|
||||||
// omniscient game state
|
export type SimpleConfiguration = {
|
||||||
export type SimpleGameState = {
|
game: "simple";
|
||||||
prev?: {
|
players: string[];
|
||||||
action: SimpleAction;
|
|
||||||
};
|
|
||||||
|
|
||||||
deck: Pile;
|
|
||||||
players: { [humanId: string]: Hand };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// a particular player's knowledge of the global game state
|
// omniscient game state
|
||||||
export type vSimpleGameState = {
|
export type SimpleGameState = {
|
||||||
humanId: string;
|
deck: Pile;
|
||||||
|
turnIdx: number;
|
||||||
deck: Pile<vCard>;
|
playerHands: { [humanKey: string]: Hand };
|
||||||
players: { [humanId: string]: Hand<vCard> };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// a particular player's point of view in the game
|
// a particular player's point of view in the game
|
||||||
export type SimplePlayerView = {
|
export type SimplePlayerView = {
|
||||||
humanId: string;
|
|
||||||
|
|
||||||
deckCount: number;
|
deckCount: number;
|
||||||
playerHandCounts: { [humanId: string]: number };
|
playerTurn: string;
|
||||||
|
playerHandCounts: { [humanKey: string]: number };
|
||||||
myHand: Hand<Card>;
|
myHand: Hand<Card>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
|
export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
|
||||||
|
|
||||||
export const newGame = (players: string[]) => {
|
export const newSimpleGameState = (
|
||||||
console.log("new game called with", JSON.stringify(players));
|
config: SimpleConfiguration
|
||||||
|
): SimpleGameState => {
|
||||||
|
const { players } = config;
|
||||||
return {
|
return {
|
||||||
deck: shuffle(newDeck()),
|
deck: shuffle(newDeck()),
|
||||||
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
|
turnIdx: 0,
|
||||||
} as SimpleGameState;
|
playerHands: Object.fromEntries(
|
||||||
|
players.map((humanKey) => [humanKey, []])
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getKnowledge = (
|
export const getSimplePlayerView = (
|
||||||
|
config: SimpleConfiguration,
|
||||||
state: SimpleGameState,
|
state: SimpleGameState,
|
||||||
humanId: string
|
humanKey: string
|
||||||
): vSimpleGameState => ({
|
|
||||||
humanId,
|
|
||||||
deck: state.deck.map((_) => null),
|
|
||||||
players: Object.fromEntries(
|
|
||||||
Object.entries(state.players).map(([id, hand]) => [
|
|
||||||
id,
|
|
||||||
hand.map(id === humanId ? (card) => card : (_) => null),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getView = (
|
|
||||||
state: vSimpleGameState,
|
|
||||||
humanId: string
|
|
||||||
): SimplePlayerView => ({
|
): SimplePlayerView => ({
|
||||||
humanId,
|
|
||||||
deckCount: state.deck.length,
|
deckCount: state.deck.length,
|
||||||
myHand: state.players[humanId] as Hand,
|
playerTurn: config.players[state.turnIdx],
|
||||||
|
myHand: state.playerHands[humanKey] as Hand,
|
||||||
playerHandCounts: Object.fromEntries(
|
playerHandCounts: Object.fromEntries(
|
||||||
Object.entries(state.players)
|
Object.entries(state.playerHands)
|
||||||
.filter(([id]) => id != humanId)
|
.filter(([id]) => id != humanKey)
|
||||||
.map(([id, hand]) => [id, hand.length])
|
.map(([id, hand]) => [id, hand.length])
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveAction = (
|
export const resolveSimpleAction = ({
|
||||||
state: SimpleGameState,
|
config,
|
||||||
humanId: string,
|
state,
|
||||||
action: SimpleAction
|
humanKey,
|
||||||
): SimpleGameState => {
|
action,
|
||||||
console.log("attempting to resolve action", JSON.stringify(action));
|
}: {
|
||||||
if (!(humanId in state.players)) {
|
config: SimpleConfiguration;
|
||||||
throw Error(
|
state: SimpleGameState;
|
||||||
`${humanId} is not a player in this game; they cannot perform actions`
|
humanKey: string;
|
||||||
|
action: SimpleAction;
|
||||||
|
}): SimpleGameState => {
|
||||||
|
const playerHand = state.playerHands[humanKey];
|
||||||
|
if (playerHand == null) {
|
||||||
|
throw new Error(
|
||||||
|
`${humanKey} is not a player in this game; they cannot perform actions`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const playerHand = state.players[humanId];
|
if (humanKey != config.players[state.turnIdx]) {
|
||||||
if (action.type == "draw") {
|
throw new Error(`It's not ${humanKey}'s turn!`);
|
||||||
const [drawn, ...rest] = state.deck;
|
|
||||||
console.log("drew card", JSON.stringify(drawn));
|
|
||||||
return {
|
|
||||||
deck: rest,
|
|
||||||
players: {
|
|
||||||
...state.players,
|
|
||||||
[humanId]: [drawn, ...playerHand],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numPlayers = Object.keys(state.playerHands).length;
|
||||||
|
const newTurnIdx = (state.turnIdx + 1) % numPlayers;
|
||||||
|
|
||||||
|
if (action.type == "draw") {
|
||||||
|
const [drawn, ...rest] = state.deck;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deck: rest,
|
||||||
|
playerHands: {
|
||||||
|
...state.playerHands,
|
||||||
|
[humanKey]: [drawn, ...playerHand],
|
||||||
|
},
|
||||||
|
turnIdx: newTurnIdx,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
// action.type == discard
|
// action.type == discard
|
||||||
const index = playerHand.findIndex(heq(action.card));
|
const cardIndex = playerHand.findIndex(heq(action.card));
|
||||||
return {
|
return {
|
||||||
deck: [action.card, ...state.deck],
|
deck: [action.card, ...state.deck],
|
||||||
players: {
|
playerHands: {
|
||||||
...state.players,
|
...state.playerHands,
|
||||||
[humanId]: playerHand
|
[humanKey]: playerHand
|
||||||
.slice(0, index)
|
.slice(0, cardIndex)
|
||||||
.concat(playerHand.slice(index + 1)),
|
.concat(playerHand.slice(cardIndex + 1)),
|
||||||
},
|
},
|
||||||
|
turnIdx: newTurnIdx,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import api from "./api";
|
|
||||||
import { Elysia, env } from "elysia";
|
|
||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { staticPlugin } from "@elysiajs/static";
|
import { staticPlugin } from "@elysiajs/static";
|
||||||
import { error } from "node:console";
|
import { Elysia, env } from "elysia";
|
||||||
|
import api from "./api";
|
||||||
|
|
||||||
const port = env.PORT || 5001;
|
const port = env.PORT || 5001;
|
||||||
|
|
||||||
@@ -12,9 +11,9 @@ const app = new Elysia()
|
|||||||
origin: ["http://localhost:3000", "https://games.drm.dev"],
|
origin: ["http://localhost:3000", "https://games.drm.dev"],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.onRequest(({ request }) => {
|
// .onRequest(({ request }) => {
|
||||||
console.log(request.method, request.url);
|
// console.log(request.method, request.url);
|
||||||
})
|
// })
|
||||||
.onError(({ error }) => {
|
.onError(({ error }) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return error;
|
return error;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const transform = <
|
|||||||
>(
|
>(
|
||||||
initValue: T,
|
initValue: T,
|
||||||
...mutations: Mutations
|
...mutations: Mutations
|
||||||
): Observable<T, any> =>
|
): Observable<T, unknown> =>
|
||||||
merge(
|
merge(
|
||||||
mutations.map(([source, mutation]) =>
|
mutations.map(([source, mutation]) =>
|
||||||
source.map((event) => ({ event, mutation }))
|
source.map((event) => ({ event, mutation }))
|
||||||
|
|||||||
@@ -1,104 +1,186 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { combine, Property } from "kefir";
|
import { combine, pool, Property } from "kefir";
|
||||||
import Bus, { type Bus as TBus } from "kefir-bus";
|
import Bus, { type Bus as TBus } from "kefir-bus";
|
||||||
import {
|
import {
|
||||||
newGame,
|
newSimpleGameState,
|
||||||
resolveAction,
|
resolveSimpleAction,
|
||||||
SimpleAction,
|
SimpleAction,
|
||||||
|
SimpleConfiguration,
|
||||||
SimpleGameState,
|
SimpleGameState,
|
||||||
} from "./games/simple";
|
} from "./games/simple";
|
||||||
import { transform } from "./kefir-extension";
|
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())),
|
||||||
view: t.Optional(t.Any()),
|
view: t.Optional(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({ proposeGame: t.String() }),
|
t.Object({ ready: t.Boolean() }),
|
||||||
t.Object({ startGame: t.Literal(true) }),
|
|
||||||
t.Object({ action: t.Any() }),
|
t.Object({ action: t.Any() }),
|
||||||
|
t.Object({ quit: t.Literal(true) }),
|
||||||
]);
|
]);
|
||||||
export type TWsIn = typeof WsIn.static;
|
export type TWsIn = typeof WsIn.static;
|
||||||
|
|
||||||
type Attributed = { humanKey: string };
|
type Attributed = { humanKey: string };
|
||||||
type TablePayload<GameState, GameAction> = {
|
type TablePayload<GameConfig, GameState, GameAction> = {
|
||||||
inputs: {
|
inputs: {
|
||||||
presenceChanges: TBus<
|
connectionChanges: TBus<
|
||||||
Attributed & { presence: "joined" | "left" },
|
Attributed & { presence: "joined" | "left" },
|
||||||
never
|
never
|
||||||
>;
|
>;
|
||||||
gameProposals: TBus<{ proposeGame: string }, never>;
|
|
||||||
gameStarts: TBus<{ startGame: true }, never>;
|
readys: TBus<Attributed & { ready: boolean }, never>;
|
||||||
gameActions: TBus<Attributed & GameAction, never>;
|
actions: TBus<Attributed & GameAction, never>;
|
||||||
|
quits: TBus<Attributed, never>;
|
||||||
};
|
};
|
||||||
outputs: {
|
outputs: {
|
||||||
playersPresent: Property<string[], never>;
|
playersPresent: Property<string[], never>;
|
||||||
|
playersReady: Property<{ [key: string]: boolean }, unknown>;
|
||||||
|
gameConfig: Property<GameConfig | null, never>;
|
||||||
gameState: Property<GameState | null, never>;
|
gameState: Property<GameState | null, never>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const tables: {
|
const tables: {
|
||||||
[key: string]: TablePayload<unknown, unknown>;
|
[key: string]: TablePayload<unknown, unknown, unknown>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
export const liveTable = <GameState, GameAction>(key: string) => {
|
export const liveTable = <GameConfig, GameState, GameAction>(key: string) => {
|
||||||
if (!(key in tables)) {
|
if (!(key in tables)) {
|
||||||
const inputs: TablePayload<GameState, GameAction>["inputs"] = {
|
const inputs: TablePayload<
|
||||||
presenceChanges: Bus(),
|
GameConfig,
|
||||||
gameProposals: Bus(),
|
GameState,
|
||||||
gameStarts: Bus(),
|
GameAction
|
||||||
gameActions: Bus(),
|
>["inputs"] = {
|
||||||
|
connectionChanges: Bus(),
|
||||||
|
readys: Bus(),
|
||||||
|
actions: Bus(),
|
||||||
|
quits: Bus(),
|
||||||
};
|
};
|
||||||
const { presenceChanges, gameProposals, gameStarts, gameActions } =
|
const { connectionChanges, readys, actions, quits } = inputs;
|
||||||
inputs;
|
|
||||||
|
|
||||||
// =======
|
// =======
|
||||||
const playersPresent = presenceChanges.scan((prev, evt) => {
|
|
||||||
if (evt.presence == "joined") {
|
const playersPresent = connectionChanges
|
||||||
prev.push(evt.humanKey);
|
.scan((prev, evt) => {
|
||||||
} else if (evt.presence == "left") {
|
if (evt.presence == "left" && prev[evt.humanKey] == 1) {
|
||||||
prev.splice(prev.indexOf(evt.humanKey), 1);
|
const { [evt.humanKey]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
}
|
}
|
||||||
return prev;
|
return {
|
||||||
}, [] as string[]);
|
...prev,
|
||||||
|
[evt.humanKey]:
|
||||||
|
(prev[evt.humanKey] ?? 0) +
|
||||||
|
(evt.presence == "joined" ? 1 : -1),
|
||||||
|
};
|
||||||
|
}, {} as { [key: string]: number })
|
||||||
|
.map((counts) => Object.keys(counts))
|
||||||
|
.toProperty();
|
||||||
|
|
||||||
|
const playersReady = transform(
|
||||||
|
{} as { [key: string]: boolean },
|
||||||
|
[
|
||||||
|
playersPresent,
|
||||||
|
(prev, players: string[]) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
players.map((p) => [p, prev[p] ?? false])
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
readys,
|
||||||
|
(prev, evt: { humanKey: string; ready: boolean }) =>
|
||||||
|
prev[evt.humanKey] != null
|
||||||
|
? { ...prev, [evt.humanKey]: evt.ready }
|
||||||
|
: prev,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.toProperty()
|
||||||
|
.log("playersReady");
|
||||||
|
|
||||||
|
const gameStarts = playersReady
|
||||||
|
.filter(
|
||||||
|
(pr) =>
|
||||||
|
Object.values(pr).length > 0 &&
|
||||||
|
Object.values(pr).every((ready) => ready)
|
||||||
|
)
|
||||||
|
.map((_) => null)
|
||||||
|
.log("gameStarts");
|
||||||
|
|
||||||
|
const gameConfigPool = pool<
|
||||||
|
{
|
||||||
|
game: string;
|
||||||
|
players: string[];
|
||||||
|
},
|
||||||
|
never
|
||||||
|
>();
|
||||||
|
|
||||||
|
const gameConfig = gameConfigPool.toProperty();
|
||||||
|
|
||||||
const gameState = transform(
|
const gameState = transform(
|
||||||
null as SimpleGameState | null,
|
null as SimpleGameState | null,
|
||||||
[
|
[
|
||||||
combine([gameStarts], [playersPresent], (evt, players) => ({
|
combine([gameStarts], [gameConfigPool], (_, config) => config),
|
||||||
...evt,
|
(prev, startConfig: SimpleConfiguration) =>
|
||||||
players,
|
prev == null ? newSimpleGameState(startConfig) : prev,
|
||||||
})),
|
|
||||||
(prev, evt: { players: string[] }) =>
|
|
||||||
prev == null
|
|
||||||
? (newGame(evt.players) as SimpleGameState)
|
|
||||||
: prev,
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
gameActions,
|
combine([actions], [gameConfigPool], (action, config) => ({
|
||||||
(prev, evt: Attributed & SimpleAction) =>
|
action,
|
||||||
|
config,
|
||||||
|
})),
|
||||||
|
(
|
||||||
|
prev,
|
||||||
|
evt: {
|
||||||
|
action: Attributed & SimpleAction;
|
||||||
|
config: SimpleConfiguration;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
prev != null
|
prev != null
|
||||||
? resolveAction(prev, evt.humanKey, evt)
|
? resolveSimpleAction({
|
||||||
|
config: evt.config,
|
||||||
|
state: prev,
|
||||||
|
action: evt.action,
|
||||||
|
humanKey: evt.action.humanKey,
|
||||||
|
})
|
||||||
: prev,
|
: prev,
|
||||||
]
|
]
|
||||||
).toProperty();
|
).toProperty();
|
||||||
|
|
||||||
|
const gameIsActive = gameState
|
||||||
|
.map((gs) => gs != null)
|
||||||
|
.skipDuplicates()
|
||||||
|
.toProperty()
|
||||||
|
.log("gameIsActive");
|
||||||
|
|
||||||
|
gameConfigPool.plug(
|
||||||
|
playersPresent
|
||||||
|
.filterBy(gameIsActive.map((active) => !active))
|
||||||
|
.map((players) => ({
|
||||||
|
game: "simple",
|
||||||
|
players,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
tables[key] = {
|
tables[key] = {
|
||||||
inputs,
|
inputs,
|
||||||
outputs: {
|
outputs: {
|
||||||
playersPresent,
|
playersPresent,
|
||||||
|
playersReady,
|
||||||
|
gameConfig: gameConfig as Property<unknown, never>,
|
||||||
gameState: gameState as Property<unknown, never>,
|
gameState: gameState as Property<unknown, never>,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
tables[key].outputs.playersPresent
|
tables[key].outputs.playersPresent
|
||||||
.slidingWindow(2)
|
.debounce(30000, { immediate: false })
|
||||||
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0)
|
.filter((players) => players.length === 0)
|
||||||
|
.skip(1)
|
||||||
.onValue((_) => {
|
.onValue((_) => {
|
||||||
|
console.log("DELETING LIVE TABLE");
|
||||||
delete tables[key];
|
delete tables[key];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return tables[key] as TablePayload<GameState, GameAction>;
|
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
|
||||||
};
|
};
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -99,6 +99,9 @@ importers:
|
|||||||
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)
|
||||||
|
lodash:
|
||||||
|
specifier: ^4.17.21
|
||||||
|
version: 4.17.21
|
||||||
object-hash:
|
object-hash:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
@@ -109,6 +112,9 @@ importers:
|
|||||||
'@types/kefir':
|
'@types/kefir':
|
||||||
specifier: ^3.8.11
|
specifier: ^3.8.11
|
||||||
version: 3.8.11
|
version: 3.8.11
|
||||||
|
'@types/lodash':
|
||||||
|
specifier: ^4.17.20
|
||||||
|
version: 4.17.20
|
||||||
concurrently:
|
concurrently:
|
||||||
specifier: ^9.2.0
|
specifier: ^9.2.0
|
||||||
version: 9.2.0
|
version: 9.2.0
|
||||||
@@ -474,6 +480,9 @@ packages:
|
|||||||
'@types/kefir@3.8.11':
|
'@types/kefir@3.8.11':
|
||||||
resolution: {integrity: sha512-5TRdFXQYsVUvqIH6nYjslHzBgn4hnptcutXnqAhfbKdWD/799c44hFhQGF3887E2t/Q4jSp3RvNFCaQ+b9w6vQ==}
|
resolution: {integrity: sha512-5TRdFXQYsVUvqIH6nYjslHzBgn4hnptcutXnqAhfbKdWD/799c44hFhQGF3887E2t/Q4jSp3RvNFCaQ+b9w6vQ==}
|
||||||
|
|
||||||
|
'@types/lodash@4.17.20':
|
||||||
|
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
|
||||||
|
|
||||||
'@types/node@24.2.0':
|
'@types/node@24.2.0':
|
||||||
resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==}
|
resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==}
|
||||||
|
|
||||||
@@ -1640,6 +1649,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.2.0
|
'@types/node': 24.2.0
|
||||||
|
|
||||||
|
'@types/lodash@4.17.20': {}
|
||||||
|
|
||||||
'@types/node@24.2.0':
|
'@types/node@24.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.10.0
|
undici-types: 7.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user