lots of necessary plumbing
This commit is contained in:
@@ -1,22 +1,10 @@
|
||||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
Show,
|
||||
} 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, cx, WSEvent } from "../fn";
|
||||
import { EdenWS } from "@elysiajs/eden/treaty";
|
||||
import { Accessor, createContext, For, onCleanup, Show } from "solid-js";
|
||||
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 Player from "./Player";
|
||||
|
||||
export const TableContext = createContext<{
|
||||
players: Accessor<string[]>;
|
||||
@@ -95,10 +83,10 @@ export default (props: { tableKey: string }) => {
|
||||
<Show when={view() == null}>
|
||||
<div class="absolute center">
|
||||
<button
|
||||
onClick={() => ws.send({ startGame: true })}
|
||||
onClick={() => ws.send({ ready: true })}
|
||||
class="button p-1 "
|
||||
>
|
||||
Start Game!
|
||||
Ready
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import {
|
||||
getSimplePlayerView,
|
||||
SimpleAction,
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
getKnowledge,
|
||||
getView,
|
||||
} from "./games/simple";
|
||||
import { human } from "./human";
|
||||
import dayjs from "dayjs";
|
||||
@@ -11,6 +11,7 @@ import db from "./db";
|
||||
import { liveTable, WsOut, WsIn } from "./table";
|
||||
import { Human } from "@prisma/client";
|
||||
import _ from "lodash";
|
||||
import { combine } from "kefir";
|
||||
|
||||
const api = new Elysia({ prefix: "/api" })
|
||||
.post("/whoami", async ({ cookie: { token } }) => {
|
||||
@@ -73,18 +74,29 @@ const api = new Elysia({ prefix: "/api" })
|
||||
},
|
||||
send,
|
||||
}) {
|
||||
const table = liveTable<SimpleGameState, SimpleAction>(tableKey);
|
||||
const table = liveTable<
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
SimpleAction
|
||||
>(tableKey);
|
||||
|
||||
table.outputs.playersPresent
|
||||
.skipDuplicates((p1, p2) => _.isEqual(new Set(p1), new Set(p2)))
|
||||
.onValue((players) => send({ players }));
|
||||
table.outputs.gameState.onValue((gameState) =>
|
||||
send({
|
||||
view:
|
||||
gameState &&
|
||||
getView(getKnowledge(gameState, humanKey), humanKey),
|
||||
})
|
||||
table.outputs.playersPresent.onValue((players) =>
|
||||
send({ players })
|
||||
);
|
||||
|
||||
table.outputs.playersReady.onValue((readys) =>
|
||||
send({ playersReady: readys })
|
||||
);
|
||||
|
||||
combine(
|
||||
[table.outputs.gameState],
|
||||
[table.outputs.gameConfig],
|
||||
(state, config) =>
|
||||
state &&
|
||||
config &&
|
||||
getSimplePlayerView(config, state, humanKey)
|
||||
).onValue((view) => send({ view }));
|
||||
|
||||
table.inputs.connectionChanges.emit({
|
||||
humanKey,
|
||||
presence: "joined",
|
||||
@@ -104,15 +116,15 @@ const api = new Elysia({ prefix: "/api" })
|
||||
body
|
||||
) {
|
||||
const {
|
||||
inputs: { gameProposals, gameStarts, gameActions },
|
||||
inputs: { readys, actions, quits },
|
||||
} = liveTable(tableKey);
|
||||
|
||||
if ("proposeGame" in body) {
|
||||
gameProposals.emit(body);
|
||||
} else if ("startGame" in body) {
|
||||
gameStarts.emit(body);
|
||||
if ("ready" in body) {
|
||||
readys.emit({ humanKey, ...body });
|
||||
} else if ("action" in body) {
|
||||
gameActions.emit({ humanKey, ...body.action });
|
||||
actions.emit({ humanKey, ...body.action });
|
||||
} else if ("quit" in body) {
|
||||
quits.emit({ humanKey });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,104 +1,103 @@
|
||||
import { Card, Hand, newDeck, Pile, shuffle, vCard } from "@games/shared/cards";
|
||||
import { heq } from "@games/shared/utils";
|
||||
|
||||
// omniscient game state
|
||||
export type SimpleGameState = {
|
||||
prev?: {
|
||||
action: SimpleAction;
|
||||
};
|
||||
|
||||
deck: Pile;
|
||||
players: { [humanId: string]: Hand };
|
||||
export type SimpleConfiguration = {
|
||||
game: "simple";
|
||||
players: string[];
|
||||
};
|
||||
|
||||
// a particular player's knowledge of the global game state
|
||||
export type vSimpleGameState = {
|
||||
humanId: string;
|
||||
|
||||
deck: Pile<vCard>;
|
||||
players: { [humanId: string]: Hand<vCard> };
|
||||
// omniscient game state
|
||||
export type SimpleGameState = {
|
||||
deck: Pile;
|
||||
turnIdx: number;
|
||||
playerHands: { [humanKey: string]: Hand };
|
||||
};
|
||||
|
||||
// a particular player's point of view in the game
|
||||
export type SimplePlayerView = {
|
||||
humanId: string;
|
||||
|
||||
deckCount: number;
|
||||
playerHandCounts: { [humanId: string]: number };
|
||||
playerTurn: string;
|
||||
playerHandCounts: { [humanKey: string]: number };
|
||||
myHand: Hand<Card>;
|
||||
};
|
||||
|
||||
export type SimpleAction = { type: "draw" } | { type: "discard"; card: Card };
|
||||
|
||||
export const newGame = (players: string[]) => {
|
||||
console.log("new game called with", JSON.stringify(players));
|
||||
export const newSimpleGameState = (
|
||||
config: SimpleConfiguration
|
||||
): SimpleGameState => {
|
||||
const { players } = config;
|
||||
return {
|
||||
deck: shuffle(newDeck()),
|
||||
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
|
||||
} as SimpleGameState;
|
||||
turnIdx: 0,
|
||||
playerHands: Object.fromEntries(
|
||||
players.map((humanKey) => [humanKey, []])
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const getKnowledge = (
|
||||
export const getSimplePlayerView = (
|
||||
config: SimpleConfiguration,
|
||||
state: SimpleGameState,
|
||||
humanId: 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
|
||||
humanKey: string
|
||||
): SimplePlayerView => ({
|
||||
humanId,
|
||||
deckCount: state.deck.length,
|
||||
myHand: state.players[humanId] as Hand,
|
||||
playerTurn: config.players[state.turnIdx],
|
||||
myHand: state.playerHands[humanKey] as Hand,
|
||||
playerHandCounts: Object.fromEntries(
|
||||
Object.entries(state.players)
|
||||
.filter(([id]) => id != humanId)
|
||||
Object.entries(state.playerHands)
|
||||
.filter(([id]) => id != humanKey)
|
||||
.map(([id, hand]) => [id, hand.length])
|
||||
),
|
||||
});
|
||||
|
||||
export const resolveAction = (
|
||||
state: SimpleGameState,
|
||||
humanId: string,
|
||||
action: SimpleAction
|
||||
): 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`
|
||||
export const resolveSimpleAction = ({
|
||||
config,
|
||||
state,
|
||||
humanKey,
|
||||
action,
|
||||
}: {
|
||||
config: SimpleConfiguration;
|
||||
state: SimpleGameState;
|
||||
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 (action.type == "draw") {
|
||||
const [drawn, ...rest] = state.deck;
|
||||
console.log("drew card", JSON.stringify(drawn));
|
||||
return {
|
||||
deck: rest,
|
||||
players: {
|
||||
...state.players,
|
||||
[humanId]: [drawn, ...playerHand],
|
||||
},
|
||||
};
|
||||
if (humanKey != config.players[state.turnIdx]) {
|
||||
throw new Error(`It's not ${humanKey}'s turn!`);
|
||||
}
|
||||
|
||||
// action.type == discard
|
||||
const index = playerHand.findIndex(heq(action.card));
|
||||
return {
|
||||
deck: [action.card, ...state.deck],
|
||||
players: {
|
||||
...state.players,
|
||||
[humanId]: playerHand
|
||||
.slice(0, index)
|
||||
.concat(playerHand.slice(index + 1)),
|
||||
},
|
||||
};
|
||||
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
|
||||
const cardIndex = playerHand.findIndex(heq(action.card));
|
||||
return {
|
||||
deck: [action.card, ...state.deck],
|
||||
playerHands: {
|
||||
...state.playerHands,
|
||||
[humanKey]: playerHand
|
||||
.slice(0, cardIndex)
|
||||
.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 { staticPlugin } from "@elysiajs/static";
|
||||
import { error } from "node:console";
|
||||
import { Elysia, env } from "elysia";
|
||||
import api from "./api";
|
||||
|
||||
const port = env.PORT || 5001;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const transform = <
|
||||
>(
|
||||
initValue: T,
|
||||
...mutations: Mutations
|
||||
): Observable<T, any> =>
|
||||
): Observable<T, unknown> =>
|
||||
merge(
|
||||
mutations.map(([source, mutation]) =>
|
||||
source.map((event) => ({ event, mutation }))
|
||||
|
||||
@@ -2,91 +2,135 @@ import { t } from "elysia";
|
||||
import { combine, Property } from "kefir";
|
||||
import Bus, { type Bus as TBus } from "kefir-bus";
|
||||
import {
|
||||
newGame,
|
||||
resolveAction,
|
||||
newSimpleGameState,
|
||||
resolveSimpleAction,
|
||||
SimpleAction,
|
||||
SimpleConfiguration,
|
||||
SimpleGameState,
|
||||
} from "./games/simple";
|
||||
import { transform } from "./kefir-extension";
|
||||
|
||||
export const WsOut = t.Object({
|
||||
players: t.Optional(t.Array(t.String())),
|
||||
playersReady: t.Optional(t.Record(t.String(), t.String())),
|
||||
view: t.Optional(t.Any()),
|
||||
});
|
||||
export type TWsOut = typeof WsOut.static;
|
||||
export const WsIn = t.Union([
|
||||
t.Object({ proposeGame: t.String() }),
|
||||
t.Object({ startGame: t.Literal(true) }),
|
||||
t.Object({ ready: t.Boolean() }),
|
||||
t.Object({ action: t.Any() }),
|
||||
t.Object({ quit: t.Literal(true) }),
|
||||
]);
|
||||
export type TWsIn = typeof WsIn.static;
|
||||
|
||||
type Attributed = { humanKey: string };
|
||||
type TablePayload<GameState, GameAction> = {
|
||||
type TablePayload<GameConfig, GameState, GameAction> = {
|
||||
inputs: {
|
||||
connectionChanges: TBus<
|
||||
Attributed & { presence: "joined" | "left" },
|
||||
never
|
||||
>;
|
||||
gameProposals: TBus<{ proposeGame: string }, never>;
|
||||
gameStarts: TBus<{ startGame: true }, never>;
|
||||
gameActions: TBus<Attributed & GameAction, never>;
|
||||
|
||||
readys: TBus<Attributed & { ready: boolean }, never>;
|
||||
actions: TBus<Attributed & GameAction, never>;
|
||||
quits: TBus<Attributed, never>;
|
||||
};
|
||||
outputs: {
|
||||
playersPresent: Property<string[], never>;
|
||||
playersReady: Property<{ [key: string]: boolean }, unknown>;
|
||||
gameConfig: Property<GameConfig | null, never>;
|
||||
gameState: Property<GameState | null, never>;
|
||||
};
|
||||
};
|
||||
|
||||
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)) {
|
||||
const inputs: TablePayload<GameState, GameAction>["inputs"] = {
|
||||
const inputs: TablePayload<
|
||||
GameConfig,
|
||||
GameState,
|
||||
GameAction
|
||||
>["inputs"] = {
|
||||
connectionChanges: Bus(),
|
||||
gameProposals: Bus(),
|
||||
gameStarts: Bus(),
|
||||
gameActions: Bus(),
|
||||
readys: Bus(),
|
||||
actions: Bus(),
|
||||
quits: Bus(),
|
||||
};
|
||||
const { connectionChanges, gameProposals, gameStarts, gameActions } =
|
||||
inputs;
|
||||
const { connectionChanges, readys, actions, quits } = inputs;
|
||||
|
||||
// =======
|
||||
const playerConnectionCounts = connectionChanges.scan((prev, evt) => {
|
||||
if (evt.presence == "left" && prev[evt.humanKey] == 1) {
|
||||
const { [evt.humanKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[evt.humanKey]:
|
||||
(prev[evt.humanKey] ?? 0) +
|
||||
(evt.presence == "joined" ? 1 : -1),
|
||||
};
|
||||
}, {} as { [key: string]: number });
|
||||
const playersPresent = playerConnectionCounts.map((counts) =>
|
||||
Object.keys(counts)
|
||||
|
||||
const playersPresent = connectionChanges
|
||||
.scan((prev, evt) => {
|
||||
if (evt.presence == "left" && prev[evt.humanKey] == 1) {
|
||||
const { [evt.humanKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[evt.humanKey]:
|
||||
(prev[evt.humanKey] ?? 0) +
|
||||
(evt.presence == "joined" ? 1 : -1),
|
||||
};
|
||||
}, {} as { [key: string]: number })
|
||||
.map((counts) => Object.keys(counts));
|
||||
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
const gameStarts = playersReady
|
||||
.filter((pr) => Object.values(pr).every((ready) => ready))
|
||||
.map((_) => null);
|
||||
|
||||
const gameConfig = playersPresent.map((players) => ({
|
||||
game: "simple",
|
||||
players,
|
||||
}));
|
||||
|
||||
const gameState = transform(
|
||||
null as SimpleGameState | null,
|
||||
[
|
||||
combine([gameStarts], [playersPresent], (evt, players) => ({
|
||||
...evt,
|
||||
players,
|
||||
})),
|
||||
(prev, evt: { players: string[] }) =>
|
||||
prev == null
|
||||
? (newGame(evt.players) as SimpleGameState)
|
||||
: prev,
|
||||
combine([gameStarts], [gameConfig], (_, config) => config),
|
||||
(prev, startConfig: SimpleConfiguration) =>
|
||||
prev == null ? newSimpleGameState(startConfig) : prev,
|
||||
],
|
||||
[
|
||||
gameActions,
|
||||
(prev, evt: Attributed & SimpleAction) =>
|
||||
combine([actions], [gameConfig], (action, config) => ({
|
||||
action,
|
||||
config,
|
||||
})),
|
||||
(
|
||||
prev,
|
||||
evt: {
|
||||
action: Attributed & SimpleAction;
|
||||
config: SimpleConfiguration;
|
||||
}
|
||||
) =>
|
||||
prev != null
|
||||
? resolveAction(prev, evt.humanKey, evt)
|
||||
? resolveSimpleAction({
|
||||
config: evt.config,
|
||||
state: prev,
|
||||
action: evt.action,
|
||||
humanKey: evt.action.humanKey,
|
||||
})
|
||||
: prev,
|
||||
]
|
||||
).toProperty();
|
||||
@@ -95,17 +139,19 @@ export const liveTable = <GameState, GameAction>(key: string) => {
|
||||
inputs,
|
||||
outputs: {
|
||||
playersPresent,
|
||||
playersReady,
|
||||
gameConfig: gameConfig as Property<unknown, never>,
|
||||
gameState: gameState as Property<unknown, never>,
|
||||
},
|
||||
};
|
||||
|
||||
// cleanup
|
||||
tables[key].outputs.playersPresent
|
||||
.slidingWindow(2)
|
||||
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0)
|
||||
.debounce(30000)
|
||||
.filter((players) => players.length === 0)
|
||||
.onValue((_) => {
|
||||
delete tables[key];
|
||||
});
|
||||
}
|
||||
return tables[key] as TablePayload<GameState, GameAction>;
|
||||
return tables[key] as TablePayload<GameConfig, GameState, GameAction>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user