cooking with kefir
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"@types/bun": "latest",
|
||||
"@types/kefir": "^3.8.11",
|
||||
"concurrently": "^9.2.0",
|
||||
"prisma": "6.13.0"
|
||||
"prisma": "6.13.0",
|
||||
"ts-xor": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { simpleApi } from "./games/simple";
|
||||
import { Action, GameState, getKnowledge, getView } from "./games/simple";
|
||||
import { human } from "./human";
|
||||
import dayjs from "dayjs";
|
||||
import db from "./db";
|
||||
import { liveTable } from "./table";
|
||||
|
||||
const api = new Elysia({ prefix: "/api" })
|
||||
.post("/whoami", async ({ cookie: { token } }) => {
|
||||
@@ -48,26 +49,46 @@ const api = new Elysia({ prefix: "/api" })
|
||||
})
|
||||
)
|
||||
.get("/games", () => [{ key: "simple", name: "simple" }])
|
||||
.ws("/:tableKey", {
|
||||
response: t.Object({
|
||||
players: t.Optional(t.Array(t.String())),
|
||||
view: t.Optional(t.Any()),
|
||||
}),
|
||||
|
||||
.get("/instances", ({ query: { game }, humanKey }) =>
|
||||
db.instance.findMany({
|
||||
where: {
|
||||
game: {
|
||||
name: game,
|
||||
},
|
||||
players: {
|
||||
some: {
|
||||
key: humanKey,
|
||||
},
|
||||
},
|
||||
async open({
|
||||
data: {
|
||||
params: { tableKey },
|
||||
humanKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
send,
|
||||
}) {
|
||||
const table = liveTable<GameState, Action>(tableKey);
|
||||
|
||||
.use(simpleApi);
|
||||
table.outputs.playersPresent.onValue((players) =>
|
||||
send({ players })
|
||||
);
|
||||
table.outputs.gameState.onValue((gameState) =>
|
||||
send({
|
||||
view:
|
||||
gameState &&
|
||||
getView(getKnowledge(gameState, humanKey), humanKey),
|
||||
})
|
||||
);
|
||||
|
||||
table.input.emit({ humanKey, presence: "joined" });
|
||||
},
|
||||
async close({
|
||||
data: {
|
||||
params: { tableKey },
|
||||
humanKey,
|
||||
},
|
||||
}) {
|
||||
liveTable(tableKey).input.emit({
|
||||
humanKey,
|
||||
presence: "left",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default api;
|
||||
export type Api = typeof api;
|
||||
|
||||
@@ -4,8 +4,11 @@ import { Elysia, t } from "elysia";
|
||||
import db from "../db";
|
||||
import { human } from "../human";
|
||||
import { ElysiaWS } from "elysia/dist/ws";
|
||||
import K, { Stream } from "kefir";
|
||||
import K, { Property, Stream } from "kefir";
|
||||
import Bus, { type Bus as TBus } from "kefir-bus";
|
||||
import { Prisma, Instance } from "@prisma/client";
|
||||
import type { XOR } from "ts-xor";
|
||||
import { liveTable } from "../table";
|
||||
|
||||
// omniscient game state
|
||||
export type GameState = {
|
||||
@@ -58,7 +61,7 @@ export const getKnowledge = (
|
||||
),
|
||||
});
|
||||
|
||||
const getView = (state: vGameState, humanId: string): PlayerView => ({
|
||||
export const getView = (state: vGameState, humanId: string): PlayerView => ({
|
||||
humanId,
|
||||
deckCount: state.deck.length,
|
||||
myHand: state.players[humanId] as Hand,
|
||||
@@ -74,14 +77,6 @@ export const resolveAction = (
|
||||
humanId: string,
|
||||
action: Action
|
||||
): GameState => {
|
||||
// if (action.prevHash != hash(state)) {
|
||||
// throw new Error(
|
||||
// `action thinks it's applying to ${
|
||||
// action.prevHash
|
||||
// }, but we're checking it against ${hash(state)}`
|
||||
// );
|
||||
// }
|
||||
|
||||
const playerHand = state.players[humanId];
|
||||
if (action.type == "draw") {
|
||||
const [drawn, ...rest] = state.deck;
|
||||
@@ -106,147 +101,3 @@ export const resolveAction = (
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const instanceEvents: {
|
||||
[instanceId: string]: TBus<
|
||||
{ view?: PlayerView; players?: string[] },
|
||||
never
|
||||
>;
|
||||
} = {};
|
||||
|
||||
export const simpleApi = new Elysia({ prefix: "/simple" })
|
||||
.use(human)
|
||||
.post("/newGame", ({ humanKey }) => {
|
||||
return db.instance.create({
|
||||
data: {
|
||||
gameKey: "simple",
|
||||
createdByKey: humanKey,
|
||||
players: {
|
||||
connect: [{ key: humanKey }],
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
.group("/:instanceId", (app) =>
|
||||
app
|
||||
.ws("/", {
|
||||
async open(ws) {
|
||||
console.log("Got ws connection");
|
||||
ws.send("Hello!");
|
||||
|
||||
// send initial state
|
||||
const instanceId = ws.data.params.instanceId;
|
||||
const humanKey = ws.data.humanKey;
|
||||
|
||||
const instance = await db.instance.update({
|
||||
data: {
|
||||
players: {
|
||||
connect: { key: humanKey },
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
select: {
|
||||
createdByKey: true,
|
||||
gameState: true,
|
||||
players: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (instance == null) {
|
||||
ws.close(1011, "no such instance");
|
||||
return;
|
||||
}
|
||||
|
||||
// register this socket as a listener for events of this instance
|
||||
if (!instanceEvents[instanceId]) {
|
||||
instanceEvents[instanceId] = Bus();
|
||||
}
|
||||
// @ts-ignore
|
||||
ws.data.cb = instanceEvents[instanceId].onValue((evt) =>
|
||||
ws.send(evt)
|
||||
);
|
||||
ws.send({ creator: instance.createdByKey });
|
||||
if (instance.gameState != null) {
|
||||
ws.send(
|
||||
getView(
|
||||
getKnowledge(
|
||||
instance.gameState as GameState,
|
||||
humanKey
|
||||
),
|
||||
humanKey
|
||||
)
|
||||
);
|
||||
}
|
||||
instanceEvents[instanceId]?.emit({
|
||||
players: instance.players.map((p) => p.key),
|
||||
});
|
||||
},
|
||||
close(ws) {
|
||||
console.log("Got ws close");
|
||||
const instanceId = ws.data.params.instanceId;
|
||||
// @ts-ignore
|
||||
instanceEvents[instanceId]?.offValue(ws.data.cb);
|
||||
db.instance
|
||||
.update({
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
data: {
|
||||
players: {
|
||||
disconnect: { key: ws.data.humanKey },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
players: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((instance) => {
|
||||
instanceEvents[instanceId]?.emit({
|
||||
players: instance.players.map((p) => p.key),
|
||||
});
|
||||
});
|
||||
},
|
||||
})
|
||||
.post(
|
||||
"/",
|
||||
({ params: { instanceId }, body: { action }, humanKey }) =>
|
||||
db.instance
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
})
|
||||
.then(async (game) => {
|
||||
const newState = resolveAction(
|
||||
game.gameState as GameState,
|
||||
humanKey,
|
||||
action
|
||||
);
|
||||
await db.instance.update({
|
||||
data: { gameState: newState },
|
||||
where: {
|
||||
id: instanceId,
|
||||
},
|
||||
});
|
||||
return getView(
|
||||
getKnowledge(newState, humanKey),
|
||||
humanKey
|
||||
);
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
action: t.Any(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
66
packages/server/src/table.ts
Normal file
66
packages/server/src/table.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { XOR } from "ts-xor";
|
||||
import Bus, { type Bus as TBus } from "kefir-bus";
|
||||
import { merge, Observable, Property, Stream } from "kefir";
|
||||
import { Game } from "@prisma/client";
|
||||
|
||||
type TableInputEvent<GameAction> = { humanKey: string } & XOR<
|
||||
{ presence: "joined" | "left" },
|
||||
{ proposeGame: string },
|
||||
{ startGame: true },
|
||||
{ action: GameAction }
|
||||
>;
|
||||
|
||||
type InMemoryTable<GameState> = {
|
||||
playersPresent: string[];
|
||||
gameState: GameState | null;
|
||||
};
|
||||
|
||||
type TableOutputEvents<GameState> = {
|
||||
playersPresent: Property<string[], never>;
|
||||
gameState: Property<GameState | null, never>;
|
||||
};
|
||||
|
||||
type TablePayload<GameAction, GameState> = {
|
||||
input: TBus<TableInputEvent<GameAction>, never>;
|
||||
outputs: TableOutputEvents<GameState>;
|
||||
};
|
||||
|
||||
const tables: {
|
||||
[key: string]: TablePayload<unknown, unknown>;
|
||||
} = {};
|
||||
|
||||
export const liveTable = <GameState, GameAction>(key: string) => {
|
||||
if (!(key in tables)) {
|
||||
const inputEvents = Bus<TableInputEvent<GameAction>, never>();
|
||||
|
||||
tables[key] = {
|
||||
input: inputEvents,
|
||||
outputs: {
|
||||
playersPresent: inputEvents
|
||||
.filter((evt) => Boolean(evt.presence))
|
||||
.scan((prev, evt) => {
|
||||
if (evt.presence == "joined") {
|
||||
prev.push(evt.humanKey);
|
||||
} else if (evt.presence == "left") {
|
||||
prev.splice(prev.indexOf(evt.humanKey), 1);
|
||||
}
|
||||
return prev;
|
||||
}, [] as string[]),
|
||||
gameState: inputEvents
|
||||
.filter((evt) => Boolean(evt.startGame || evt.action))
|
||||
.scan((prev, evt) => {
|
||||
return prev;
|
||||
}, null as GameState | null),
|
||||
},
|
||||
};
|
||||
|
||||
// cleanup
|
||||
tables[key].outputs.playersPresent
|
||||
.slidingWindow(2)
|
||||
.filter(([prev, curr]) => prev.length > 0 && curr.length == 0)
|
||||
.onValue((_) => {
|
||||
delete tables[key];
|
||||
});
|
||||
}
|
||||
return tables[key] as TablePayload<GameAction, GameState>;
|
||||
};
|
||||
Reference in New Issue
Block a user