wip but I need to sleep

This commit is contained in:
2025-08-09 01:40:42 -04:00
parent a7e339a8ce
commit 2ff5d781fd
12 changed files with 213 additions and 82 deletions

View File

@@ -6,5 +6,13 @@
"dev": "pnpm --parallel dev",
"build": "pnpm run -F client build",
"start": "pnpm run -F server start"
},
"pnpm": {
"overrides": {
"object-hash": "^3.0.0"
}
},
"devDependencies": {
"@types/object-hash": "^3.0.6"
}
}

View File

@@ -1,34 +1,29 @@
import { Accessor, createContext, createResource, Show } from "solid-js";
import { GameState } from "../../../shared/types/cards";
import { GameState, Action } from "../../../server/src/games/simple";
import api from "../api";
import Hand from "./Hand";
import Pile from "./Pile";
export const GameContext = createContext<{
gameState: Accessor<GameState | undefined>;
setGameState: (state: GameState) => Promise<any>;
submitAction: (action: Action) => Promise<any>;
}>();
export default (props: { instanceId: number }) => {
const [gameState, { refetch, mutate }] = createResource(() =>
export default (props: { instanceId: string }) => {
const [gameState, { mutate }] = createResource(() =>
api
.gameState({ gameId: props.instanceId.toString() })
.simple(props)
.get()
.then((res) => res.data as GameState)
);
const setGameState = (state: GameState) => {
mutate(state);
return api
.gameState({ gameId: props.instanceId.toString() })
.put({
gameState: state,
})
.then(refetch);
};
const submitAction = (action: Action) =>
api
.simple(props)
.post({ action })
.then((res) => mutate(res.data as GameState));
return (
<GameContext.Provider value={{ gameState, setGameState }}>
<GameContext.Provider value={{ gameState, submitAction }}>
<Show when={gameState.latest != undefined}>
<div
class="full column center"
@@ -38,16 +33,12 @@ export default (props: { instanceId: number }) => {
<Pile
pile={gameState.latest!.deck}
style={{ cursor: "pointer" }}
onClick={() => {
const [drawn, ...rest] = gameState()!.deck;
setGameState({
deck: rest,
hand: [drawn, ...gameState()!.hand],
});
}}
onClick={() =>
api.simple({ instanceId: props.instanceId })
}
/>
</div>
<Hand hand={gameState.latest!.hand} />
<Hand hand={gameState.latest!.players[0]} />
</div>
</Show>
</GameContext.Provider>

View File

@@ -1,11 +1,11 @@
import { Component, For, useContext } from "solid-js";
import Card from "./Card";
import { Hand } from "../../../shared/types/cards";
import { Hand } from "../../../shared/cards";
import { GameContext } from "./Game";
import { produce } from "solid-js/store";
export default ((props) => {
const { setGameState, gameState } = useContext(GameContext)!;
const { submitAction, gameState } = useContext(GameContext)!;
return (
<div
@@ -33,16 +33,7 @@ export default ((props) => {
style={{
cursor: "pointer",
}}
onClick={() => {
const index = gameState()!.hand.indexOf(card);
setGameState({
deck: [card, ...gameState()!.deck],
hand: [
...gameState()!.hand.slice(0, index),
...gameState()!.hand.slice(index + 1),
],
});
}}
onClick={() => submitAction({ type: "discard", card })}
/>
)}
</For>

View File

@@ -8,17 +8,17 @@ datasource db {
}
model Game {
id Int @id @default(autoincrement())
key String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
name String
rules String?
instances Instance[]
}
model Instance {
id Int @id @default(autoincrement())
gameId Int
game Game @relation(fields: [gameId], references: [id])
id Int @id @default(autoincrement())
gameKey String
game Game @relation(fields: [gameKey], references: [key])
gameState Json
}

View File

@@ -5,7 +5,9 @@
"devserver": "NODE_ENV=development PORT=5001 bun run --hot src/index.ts",
"dbstudio": "pnpm dlx prisma studio --browser none",
"dbdeploy": "pnpm dlx prisma migrate deploy",
"dbsync": "concurrently 'pnpm dlx prisma generate' 'pnpm dlx prisma migrate dev'",
"dbtypes": "pnpm dlx prisma generate",
"dbsync": "pnpm dlx prisma migrate dev",
"dbwipe": "pnpm dlx prisma migrate reset",
"start": "NODE_ENV=production PORT=5001 bun run src/index.ts"
},
"dependencies": {

View File

@@ -1,6 +1,7 @@
import { prisma } from "./db/db";
import { Elysia, t } from "elysia";
import { Prisma } from "@prisma/client";
import { simpleApi } from "./games/simple";
const api = new Elysia({ prefix: "/api" })
// [wip]
@@ -30,31 +31,7 @@ const api = new Elysia({ prefix: "/api" })
},
})
)
.use(simpleApi);
.get("/gameState/:gameId", ({ params: { gameId } }) =>
prisma.instance
.findUnique({
where: {
id: Number(gameId),
},
})
.then((game) => game?.gameState)
)
.put(
"/gameState/:gameId",
({ params: { gameId }, body: { gameState } }) =>
prisma.instance.update({
data: { gameState },
where: {
id: Number(gameId),
},
}),
{
body: t.Object({
gameState: t.Any(),
}),
}
);
export default api;
export type Api = typeof api;

View File

@@ -0,0 +1,10 @@
import * as renaissance from "./renaissance";
import * as simple from "./simple";
const games = {
renaissance,
simple,
};
export default games;
export type Game = keyof typeof games;

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1,145 @@
import {
Card,
Hand,
newDeck,
Pile,
shuffle,
vCard,
} from "../../../shared/cards";
import hash from "object-hash";
import { heq } from "../../../shared/utils";
import { Elysia, t } from "elysia";
import { prisma } from "../db/db";
// omniscient game state
export type GameState = {
prev?: {
hash: string;
action: Action;
};
deck: Pile;
players: { [humanId: string]: Hand };
};
// a particular player's knowledge of the global game state
export type vGameState = {
humanId: string;
deck: Pile<vCard>;
players: { [humanId: string]: Hand<vCard> };
};
// a particular player's point of view in the game
export type PlayerView = {
humanId: string;
deckCount: number;
playerHandCounts: { [humanId: string]: number };
myHand: Hand<Card>;
};
export type Action = { type: "draw" } | { type: "discard"; card: Card };
export const newGame = (players: string[]) =>
({
deck: shuffle(newDeck()),
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
} as GameState);
export const getKnowledge = (state: GameState, humanId: string) => ({
hash: hash(state),
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 resolveAction = (state: GameState, 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[action.humanId];
if (action.type == "draw") {
const [drawn, ...rest] = state.deck;
return {
deck: rest,
players: {
...state.players,
[action.humanId]: [drawn, ...playerHand],
},
};
}
// action.type == discard
const index = playerHand.findIndex(heq(action.card));
return {
deck: [action.card, ...state.deck],
players: {
...state.players,
[action.humanId]: playerHand
.slice(0, index)
.concat(playerHand.slice(index + 1)),
},
};
};
export const simpleApi = new Elysia({ prefix: "/simple" })
.post(
"/newGame",
({ body: { players } }: { body: { players: string[] } }) =>
newGame(players)
)
.group("/:instanceId", (app) =>
app
.get("/", ({ params: { instanceId } }) =>
prisma.instance
.findUnique({
where: {
id: Number(instanceId),
},
})
.then((game) => game?.gameState)
)
.post(
"/",
({ params: { instanceId }, body: { action } }) =>
prisma.instance
.findUniqueOrThrow({
where: {
id: Number(instanceId),
},
})
.then((game) => {
const newState = resolveAction(
game.gameState as GameState,
action
);
const knownState = getKnowledge(
newState,
action.humanId
);
void prisma.instance.update({
data: { gameState: newState },
where: {
id: Number(instanceId),
},
});
return knownState;
}),
{
body: t.Object({
action: t.Any(),
}),
}
)
);

View File

@@ -26,10 +26,11 @@ export type Card =
}
| { kind: "joker"; color: "red" | "black" };
export type Pile = Card[];
export type Stack = Card[];
export type Hand = Card[];
export type Board = Card[];
export type vCard = Card | null | number;
export type Pile<C extends vCard = Card> = C[];
export type Stack<C extends vCard = Card> = C[];
export type Hand<C extends vCard = Card> = C[];
export type Board<C extends vCard = Card> = C[];
export const newDeck = (withJokers = false): Pile =>
suits
@@ -55,13 +56,3 @@ export const shuffle = (cards: Card[]) => {
}
return cards;
};
export type GameState = {
deck: Pile;
hand: Hand;
};
export const newGame = () =>
({
deck: shuffle(newDeck()),
hand: [],
} as GameState);

3
packages/shared/utils.ts Normal file
View File

@@ -0,0 +1,3 @@
import hash, { NotUndefined } from "object-hash";
export const heq = (a: NotUndefined) => (b: NotUndefined) => hash(a) == hash(b);

14
pnpm-lock.yaml generated
View File

@@ -4,9 +4,16 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
object-hash: ^3.0.0
importers:
.: {}
.:
devDependencies:
'@types/object-hash':
specifier: ^3.0.6
version: 3.0.6
packages/client:
dependencies:
@@ -513,6 +520,9 @@ packages:
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
'@types/object-hash@3.0.6':
resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==}
'@types/react@19.1.9':
resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==}
@@ -1447,6 +1457,8 @@ snapshots:
'@types/normalize-package-data@2.4.4': {}
'@types/object-hash@3.0.6': {}
'@types/react@19.1.9':
dependencies:
csstype: 3.1.3