Compare commits

..

35 Commits

Author SHA1 Message Date
601e3660d3 has to be a new version 2025-08-17 22:43:01 -04:00
f5de37523a better logging 2025-08-17 22:26:41 -04:00
e30adf8d82 track migrations, whoops 2025-08-17 22:15:31 -04:00
00779b6515 use the right url 2025-08-17 21:47:29 -04:00
e58b702bc4 idk cors 2025-08-17 20:50:15 -04:00
ac48d43827 less restrictive cors 2025-08-17 20:33:14 -04:00
0484ca6fc1 force redeploy 2025-08-17 20:13:20 -04:00
ef5a5e059a ""auth"" working nicely 2025-08-17 19:57:55 -04:00
c755b83d3d can change name 2025-08-16 00:15:07 -05:00
1c915d1713 some proper auth 2025-08-15 15:27:24 -05:00
4419dd7acc style cleanup 2025-08-12 19:42:30 -04:00
01d1571b0e works I guess? 2025-08-11 23:41:48 -04:00
bfb4fd3a63 Merge branch 'prod' into dev 2025-08-11 22:31:10 -04:00
e12def2e10 use older vite 2025-08-10 21:22:41 -04:00
eed6db3373 build ready (I hope) 2025-08-10 18:53:00 -04:00
a8a6d02cc7 build ready (I hope) 2025-08-10 18:46:45 -04:00
32c516bf37 working e2e 2025-08-10 12:40:02 -04:00
5e8978c550 closer 2025-08-09 15:48:14 -04:00
2ff5d781fd wip but I need to sleep 2025-08-09 01:40:42 -04:00
a7e339a8ce prisma experiment 2025-08-08 23:31:07 -04:00
96df75972a elysia is the truth 2025-08-08 22:44:39 -04:00
fb204e8869 housekeeping 2025-08-08 21:44:08 -04:00
eb064273ed full e2e behavior, nice 2025-08-08 00:04:46 -04:00
9d4b17b762 lets fuckin go 2025-08-07 22:44:33 -04:00
a90a914d2f works! 2025-08-07 19:16:57 -04:00
e9b8258fd9 getting there 2025-08-06 23:44:06 -04:00
839d596b55 basic shape 2025-08-06 23:29:55 -04:00
3891e8b85b deployment 2025-08-06 00:08:18 -04:00
2ce46088d5 cooking with sqlite 2025-08-05 23:49:13 -04:00
9277089e04 remove arangojs 2025-08-05 19:33:18 -04:00
0263ee9a4f setup prisma<>sqlite 2025-08-05 19:31:41 -04:00
5fd0df8135 arangodb 2025-08-05 00:26:05 -04:00
5527506cb9 something 2025-08-04 00:16:33 -04:00
0dc0a9ce62 can put them back 2025-08-03 22:16:19 -04:00
5f05eb4adf favicon 2025-08-03 17:59:25 -04:00
70 changed files with 2007 additions and 5584 deletions

View File

@@ -6,4 +6,5 @@ README.md
.vinxi
.git
.gitignore
.dockerignore
.dockerignore
*.db

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.output
.vinxi
*.db
# ---> Node
# Logs

View File

@@ -1,40 +0,0 @@
FROM arm64v8/node:22 AS base
ENV SHARP_IGNORE_GLOBAL_CLI_BINARIES=1
ENV VIPS_DISABLE_DEPS=1
ENV PNPM_SCRIPT_RUNNER_ALLOW_BUILD=true
RUN corepack enable
WORKDIR /app
FROM base AS builder
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --dangerously-allow-all-builds
COPY . .
# ^ copy source code
RUN --mount=type=cache,target=/app/.vinxi pnpm run build
# ^ produces .output (build artifact) and .vinxi (build cache)
FROM base AS production_builder
RUN apt-get update && apt-get install -y jq
COPY --from=builder /app/package.json ./
RUN jq 'del(.devDependencies)' package.json > package.prod.json
COPY --from=builder /app/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile --dangerously-allow-all-builds
# Prod image
FROM arm64v8/node:22-alpine
ENV SHARP_IGNORE_GLOBAL_CLI_BINARIES=1
ENV VIPS_DISABLE_DEPS=1
ENV PNPM_SCRIPT_RUNNER_ALLOW_BUILD=true
RUN corepack enable
WORKDIR /app
COPY --from=production_builder /app/package.prod.json ./package.json
COPY --from=production_builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
COPY --from=builder /app/.output ./.output
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@@ -1,13 +1,10 @@
SHELL := /bin/bash
build:
sudo docker buildx build --load \
--cache-from=type=local,src=/var/cache/docker-build \
--cache-to=type=local,dest=/var/cache/docker-build \
--platform linux/arm64 \
--progress=plain \
--tag games .
pnpm install
pnpm run -F client build
pnpm run -F server dbdeploy
pnpm run -F server dbtypes
start:
sudo docker run -p $(PORT):3000 -t games
PORT=$(PORT) pnpm start

View File

@@ -1,32 +1 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
games!!!

View File

@@ -1,3 +0,0 @@
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({});

BIN
assets/sources/cards.png LFS Normal file

Binary file not shown.

View File

@@ -1 +0,0 @@
../sources/Card_back_01.svg

View File

@@ -1 +0,0 @@
../sources/Vector-Cards-Version-3.2/FACES (BORDERED)/STANDARD BORDERED/Single Cards (One Per FIle)/

View File

@@ -3,17 +3,16 @@
"type": "module",
"version": "0.0.2",
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start"
"dev": "pnpm --parallel dev",
"build": "pnpm run -F client build",
"start": "pnpm run -F server start"
},
"dependencies": {
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.0",
"solid-js": "^1.9.5",
"vinxi": "^0.5.7"
"pnpm": {
"overrides": {
"object-hash": "^3.0.0"
}
},
"engines": {
"node": ">=22"
"devDependencies": {
"@types/object-hash": "^3.0.6"
}
}

View File

@@ -0,0 +1,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script type="module" src="/src/app.tsx"></script>
</head>
<body>
<div id="app" />
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "@games/client",
"type": "module",
"version": "0.0.3",
"scripts": {
"dev": "vite --port 3000",
"build": "vite build"
},
"dependencies": {
"@elysiajs/eden": "^1.3.2",
"@solid-primitives/scheduled": "^1.5.2",
"@solidjs/router": "^0.15.3",
"js-cookie": "^3.0.5",
"object-hash": "^3.0.0",
"solid-js": "^1.9.5"
},
"devDependencies": {
"@iconify-json/solar": "^1.2.4",
"@types/js-cookie": "^3.0.6",
"@unocss/preset-icons": "^66.4.2",
"@unocss/preset-wind4": "^66.4.2",
"unocss": "^66.4.2",
"vite": "^4.4.9",
"vite-plugin-solid": "^2.11.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1 @@
../../../../assets/sources/Card_back_01.svg

View File

@@ -0,0 +1 @@
../../../../assets/sources/Vector-Cards-Version-3.2/FACES (BORDERED)/STANDARD BORDERED/Single Cards (One Per FIle)

View File

@@ -0,0 +1,10 @@
import { type Api } from "../../server/src/api";
import { treaty } from "@elysiajs/eden";
const { api } = treaty<Api>(
import.meta.env.DEV ? "http://localhost:5001" : window.location.origin,
{
fetch: { credentials: "include" },
}
);
export default api;

View File

@@ -0,0 +1,66 @@
import { Route, Router } from "@solidjs/router";
import { createResource, lazy, Suspense } from "solid-js";
import { render } from "solid-js/web";
import "virtual:uno.css";
import pkg from "../package.json";
import "./style.css";
import api from "./api";
import Cookies from "js-cookie";
const Profile = () => {
let dialogRef!: HTMLDialogElement;
const [profile] = createResource(async () => api.profile.get());
return (
<>
<div
onClick={() => dialogRef.showModal()}
class="i-solar-user-circle-bold button s-10 m-2 cursor-pointer fixed tr"
/>
<dialog ref={dialogRef} closedby="any">
<div class="fixed tr bg-emerald-100 m-2 p-4 rounded-xl border-2 shadow-md shadow-black">
Name:{" "}
<input
value={profile()?.data?.name ?? ""}
onChange={(e) =>
api.setName.post({ name: e.target.value })
}
class="bg-emerald-200 border-1.5 rounded-full px-4"
/>
</div>
</dialog>
</>
);
};
const App = () => (
<Router
root={(props) => (
<>
<Suspense>{props.children}</Suspense>
<Profile />
{/* Version */}
<span class="fixed br m-2 font-mono text-xs">
{"v" + pkg.version}
</span>
</>
)}
>
<Route path="/" component={lazy(() => import("./routes/index"))} />
<Route
path="/:game"
component={lazy(() => import("./routes/[game]/index"))}
/>
<Route
path="/:game/:instance"
component={lazy(() => import("./routes/[game]/[instance]"))}
/>
</Router>
);
// todo: fix this
(Cookies.get("token") == null ? api.whoami.post() : Promise.resolve()).then(
() => render(App, document.getElementById("app")!)
);

View File

@@ -1,5 +1,7 @@
import { Component, createResource, JSX, Suspense } from "solid-js";
import { Card } from "../types/cards";
import { Clickable, Stylable } from "./toolbox";
import { Card } from "../../../shared/cards";
const cardToSvgFilename = (card: Card) => {
if (card.kind == "joker") {
@@ -16,34 +18,30 @@ const cardToSvgFilename = (card: Card) => {
};
export default ((props) => {
const [svgPath] = createResource(() =>
props.face == "down"
? // @ts-ignore
import("~/../assets/views/back.svg")
: import(
`~/../assets/views/cards/${cardToSvgFilename(
props.card
)}.svg`
)
);
return (
<Suspense>
<img
onClick={props.onClick}
draggable={false}
class={props.class}
style={{
"border-radius": "5px",
...props.style,
}}
style={props.style}
width="100px"
src={svgPath()?.default}
src={
props.face == "down"
? "/views/back.svg"
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
}
/>
</Suspense>
);
}) satisfies Component<{
class?: string;
style?: JSX.CSSProperties;
card: Card;
face?: "up" | "down";
}>;
}) satisfies Component<
(
| {
card: Card;
face?: "up";
}
| { card?: Card; face: "down" }
) &
Stylable &
Clickable
>;

View File

@@ -0,0 +1,43 @@
import { Accessor, createContext, createResource, Show } from "solid-js";
import {
GameState,
Action,
vGameState,
PlayerView,
} from "../../../server/src/games/simple";
import api from "../api";
import Hand from "./Hand";
import Pile from "./Pile";
export const GameContext = createContext<{
view: Accessor<PlayerView | undefined>;
submitAction: (action: Action) => Promise<any>;
}>();
export default (props: { instanceId: string }) => {
const [view, { mutate }] = createResource(() =>
api
.simple(props)
.get()
.then((res) => res.data as PlayerView)
);
const submitAction = (action: Action) =>
api
.simple(props)
.post({ action })
.then((res) => res.status == 200 && mutate(res.data as PlayerView));
return (
<GameContext.Provider value={{ view, submitAction }}>
<Show when={view.latest != undefined}>
<Pile
count={view.latest!.deckCount}
class="cursor-pointer fixed center"
onClick={() => submitAction({ type: "draw" })}
/>
<Hand class="fixed bc" hand={view.latest!.myHand} />
</Show>
</GameContext.Provider>
);
};

View File

@@ -0,0 +1,26 @@
import { Component, For, useContext } from "solid-js";
import Card from "./Card";
import { Hand } from "../../../shared/cards";
import { GameContext } from "./Game";
import { produce } from "solid-js/store";
import { Stylable } from "./toolbox";
export default ((props) => {
const { submitAction, view } = useContext(GameContext)!;
return (
<div class={"hand " + props.class} style={props.style}>
<For each={props.hand}>
{(card) => (
<Card
card={card}
style={{
cursor: "pointer",
}}
onClick={() => submitAction({ type: "discard", card })}
/>
)}
</For>
</div>
);
}) satisfies Component<{ hand: Hand } & Stylable>;

View File

@@ -0,0 +1,22 @@
import { Component, For, JSX, Show } from "solid-js";
import Card from "./Card";
import { Clickable, Stylable } from "./toolbox";
export default ((props) => {
return (
<Show when={props.count > 0}>
<Card
onClick={props.onClick}
style={props.style}
class={props.class + " shadow-lg shadow-black"}
face="down"
/>
</Show>
);
}) satisfies Component<
{
count: number;
} & Stylable &
Clickable
>;

View File

@@ -0,0 +1,16 @@
import { JSX } from "solid-js";
export type Stylable = {
class?: string;
style?: JSX.CSSProperties;
};
export type Clickable = {
onClick?:
| JSX.EventHandlerUnion<
HTMLDivElement,
MouseEvent,
JSX.EventHandler<HTMLDivElement, MouseEvent>
>
| undefined;
};

View File

@@ -0,0 +1,16 @@
import { A, useParams } from "@solidjs/router";
import Game from "../../components/Game";
export default () => {
const params = useParams<{ game: string; instance: string }>();
return (
<>
<Game instanceId={params.instance} />
<A href={`/${params.game}`} class="fixed tl m-4 px-2 py-1.5 button">
Back
</A>
</>
);
};

View File

@@ -0,0 +1,38 @@
import { A, useParams } from "@solidjs/router";
import { createEffect, createResource, For, Suspense } from "solid-js";
import api from "../../api";
export default () => {
const param = useParams<{ game: string }>();
const [instances, { refetch }] = createResource(
() => param.game,
async () => api.instances.get({ query: param }).then((res) => res.data)
);
return (
<Suspense>
<div style={{ padding: "20px" }}>
<p class="text-[40px]">{param.game}</p>
<button
class="px-2 py-1.5 m-4 button rounded"
onClick={() => api.simple.newGame.post().then(refetch)}
>
New Game
</button>
<ul>
<For each={instances() ?? []}>
{(instance) => (
<li>
<A href={`/${param.game}/${instance.id}`}>
{instance.id}
</A>
</li>
)}
</For>
</ul>
</div>
</Suspense>
);
};

View File

@@ -0,0 +1,17 @@
import { A } from "@solidjs/router";
import { createEffect, createResource, For } from "solid-js";
import api from "../api";
export default () => {
const [games] = createResource(async () =>
api.games.get().then((res) => res.data)
);
return (
<div style={{ padding: "20px" }}>
<For each={games()}>
{(game) => <A href={`/${game.key}`}>{game.name}</A>}
</For>
</div>
);
};

View File

@@ -0,0 +1,62 @@
html {
height: 100vh;
}
body {
margin: 0;
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande",
"Lucida Sans", Arial, sans-serif;
color: white;
height: 100%;
}
body::before {
z-index: -1;
content: "";
position: absolute;
width: 100%;
height: 100%;
}
/* a {
color: rgb(18, 229, 113);
}
a:visited {
color: rgb(23, 138, 125);
} */
.button {
cursor: pointer;
background-color: white;
color: black;
box-shadow: 0px 5px 10px black;
border-radius: 10%;
transition: background-color 0.15s, color 0.15s, transform 0.15s;
}
.button:hover {
background-color: rgb(23, 138, 125);
color: white;
transform: scale(1.1);
}
#app {
height: 100%;
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
}
.hand {
height: 160px;
background: radial-gradient(rgb(24, 70, 82), rgb(1, 42, 41));
min-width: 100px;
width: fit-content;
max-width: 90%;
border: 2px dashed white;
border-radius: 12px;
margin-bottom: 50px;
padding: 10px;
overflow: scroll;
scrollbar-width: none;
display: flex;
gap: 5px;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"],
"$/*": ["./"],
"@/*": ["./public/*"]
}
}
}

View File

@@ -0,0 +1,40 @@
import presetWind4 from "@unocss/preset-wind4";
import { defineConfig } from "unocss";
import { presetIcons } from "unocss";
import {} from "@iconify-json/solar";
export default defineConfig({
presets: [
presetWind4(),
presetIcons({
collections: {
solar: () =>
import("@iconify-json/solar/icons.json").then(
(i) => i.default as any
),
},
}),
],
shortcuts: [[/^s-(\d+)$/, ([, d]) => `w-${d} h-${d}`]],
rules: [
["tl", { top: 0, left: 0 }],
["tr", { top: 0, right: 0 }],
["bl", { bottom: 0, left: 0 }],
["br", { bottom: 0, right: 0 }],
[
"bc",
{
bottom: 0,
left: 0,
right: 0,
"margin-left": "auto",
"margin-right": "auto",
},
],
[
"center",
{ top: "50%", left: "50%", transform: "translate(-50%, -50%)" },
],
],
});

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import UnoCSS from "unocss/vite";
export default defineConfig({
plugins: [solidPlugin(), UnoCSS()],
build: {
outDir: "../server/public",
emptyOutDir: true,
},
});

3
packages/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# deps
node_modules/
public

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "Game" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"rules" TEXT
);
-- CreateTable
CREATE TABLE "Instance" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"gameId" INTEGER NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Game_name_key" ON "Game"("name");

View File

@@ -0,0 +1,34 @@
/*
Warnings:
- The primary key for the `Game` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `id` on the `Game` table. All the data in the column will be lost.
- You are about to drop the column `gameId` on the `Instance` table. All the data in the column will be lost.
- Added the required column `key` to the `Game` table without a default value. This is not possible if the table is not empty.
- Added the required column `gameKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Game" (
"key" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"rules" TEXT
);
INSERT INTO "new_Game" ("createdAt", "name", "rules", "updatedAt") SELECT "createdAt", "name", "rules", "updatedAt" FROM "Game";
DROP TABLE "Game";
ALTER TABLE "new_Game" RENAME TO "Game";
CREATE TABLE "new_Instance" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("gameState", "id") SELECT "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- The primary key for the `Instance` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,28 @@
/*
Warnings:
- Added the required column `createdByKey` to the `Instance` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Human" (
"key" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Instance" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdByKey" TEXT NOT NULL,
"gameKey" TEXT NOT NULL,
"gameState" JSONB NOT NULL,
CONSTRAINT "Instance_createdByKey_fkey" FOREIGN KEY ("createdByKey") REFERENCES "Human" ("key") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Instance_gameKey_fkey" FOREIGN KEY ("gameKey") REFERENCES "Game" ("key") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Instance" ("gameKey", "gameState", "id") SELECT "gameKey", "gameState", "id" FROM "Instance";
DROP TABLE "Instance";
ALTER TABLE "new_Instance" RENAME TO "Instance";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,12 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Human" (
"key" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL DEFAULT ''
);
INSERT INTO "new_Human" ("key", "name") SELECT "key", "name" FROM "Human";
DROP TABLE "Human";
ALTER TABLE "new_Human" RENAME TO "Human";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -0,0 +1,32 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Game {
key String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
rules String?
instances Instance[]
}
model Human {
key String @id @default(cuid(2))
name String @default("")
Instance Instance[]
}
model Instance {
id String @id @default(cuid(2))
createdByKey String
createdBy Human @relation(fields: [createdByKey], references: [key])
gameKey String
game Game @relation(fields: [gameKey], references: [key])
gameState Json
}

View File

@@ -0,0 +1,27 @@
{
"name": "@games/server",
"scripts": {
"dev": "concurrently 'pnpm run devserver' 'pnpm run dbstudio'",
"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",
"dbtypes": "pnpm dlx prisma generate",
"dbsync": "pnpm dlx prisma migrate dev",
"dbwipe": "pnpm dlx prisma migrate reset",
"prod": "NODE_ENV=production bun run src/index.ts",
"start": "concurrently 'pnpm run prod' 'pnpm run dbstudio'"
},
"dependencies": {
"@elysiajs/cors": "^1.3.3",
"@elysiajs/static": "^1.3.0",
"@games/shared": "workspace:*",
"@prisma/client": "6.13.0",
"elysia": "^1.3.8",
"object-hash": "^3.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"concurrently": "^9.2.0",
"prisma": "6.13.0"
}
}

View File

@@ -0,0 +1,6 @@
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: path.join("db", "schema.prisma"),
});

View File

@@ -0,0 +1,56 @@
import { prisma } from "./db/db";
import { Elysia, t } from "elysia";
import { Prisma } from "@prisma/client";
import { simpleApi } from "./games/simple";
import { human } from "./human";
const api = new Elysia({ prefix: "/api" })
.post("/whoami", async ({ cookie: { token } }) => {
if (token.value == null) {
const newHuman = await prisma.human.create({
data: {},
});
token.value = newHuman.key;
}
return token.value;
})
.use(human)
.post(
"/setName",
({ body: { name }, humanKey }) =>
prisma.human.update({
where: {
key: humanKey,
},
data: {
name,
},
}),
{
body: t.Object({
name: t.String(),
}),
}
)
.get("/profile", ({ humanKey }) =>
prisma.human.findFirst({ where: { key: humanKey } })
)
.get("/games", () => prisma.game.findMany())
.get("/instances", ({ query: { game } }) =>
prisma.instance.findMany({
where: {
game: {
name: game,
},
},
select: {
id: true,
},
})
)
.use(simpleApi);
export default api;
export type Api = typeof api;

View File

@@ -0,0 +1,4 @@
"use server";
import { prisma } from "./db";
export const queryAll = async () => await prisma.game.findMany();

View File

@@ -0,0 +1,25 @@
"use server";
import { GameState, newDeck, shuffle } from "../types/cards";
import { prisma } from "./db";
export const queryInstances = async (gameName: string) =>
prisma.instance.findMany({ where: { game: { name: gameName } } });
export const createInstance = (gameName: string) =>
prisma.instance.create({
data: {
gameState: { deck: shuffle(newDeck()), hand: [] } as GameState,
game: { connect: { name: gameName } },
},
});
export const getGameState = (instanceId: number) =>
prisma.instance
.findUnique({ where: { id: instanceId } })
.then((i) => i?.gameState as GameState | undefined);
export const updateGameState = async (
instanceId: number,
gameState: GameState
) => prisma.instance.update({ where: { id: instanceId }, data: { gameState } });

View File

@@ -0,0 +1,5 @@
"use server";
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

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,176 @@
import {
Card,
Hand,
newDeck,
Pile,
shuffle,
vCard,
} from "../../../shared/cards";
import { heq } from "@games/shared/utils";
import { Elysia, t } from "elysia";
import { prisma } from "../db/db";
import { human } from "../human";
// omniscient game state
export type GameState = {
prev?: {
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[]) => {
console.log("new game called with", JSON.stringify(players));
return {
deck: shuffle(newDeck()),
players: Object.fromEntries(players.map((humanId) => [humanId, []])),
} as GameState;
};
export const getKnowledge = (
state: GameState,
humanId: string
): vGameState => ({
humanId,
deck: state.deck.map((_) => null),
players: Object.fromEntries(
Object.entries(state.players).map(([id, hand]) => [
id,
hand.map(id === humanId ? (card) => card : (_) => null),
])
),
});
const getView = (state: vGameState, humanId: string): PlayerView => ({
humanId,
deckCount: state.deck.length,
myHand: state.players[humanId] as Hand,
playerHandCounts: Object.fromEntries(
Object.entries(state.players)
.filter(([id]) => id != humanId)
.map(([id, hand]) => [id, hand.length])
),
});
export const resolveAction = (
state: GameState,
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;
return {
deck: rest,
players: {
...state.players,
[humanId]: [drawn, ...playerHand],
},
};
}
// 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)),
},
};
};
export const simpleApi = new Elysia({ prefix: "/simple" })
.use(human)
.post("/newGame", ({ humanKey }) => {
return prisma.instance.create({
data: {
gameState: newGame([humanKey]),
gameKey: "simple",
createdByKey: humanKey,
},
});
})
.group("/:instanceId", (app) =>
app
.get("/", ({ params: { instanceId }, humanKey }) =>
prisma.instance
.findUnique({
where: {
id: instanceId,
},
})
.then((game) =>
getView(
getKnowledge(
game!.gameState as GameState,
humanKey
),
humanKey
)
)
)
.post(
"/",
({ params: { instanceId }, body: { action }, humanKey }) =>
prisma.instance
.findUniqueOrThrow({
where: {
id: instanceId,
},
})
.then(async (game) => {
const newState = resolveAction(
game.gameState as GameState,
humanKey,
action
);
await prisma.instance.update({
data: { gameState: newState },
where: {
id: instanceId,
},
});
return getView(
getKnowledge(newState, humanKey),
humanKey
);
}),
{
body: t.Object({
action: t.Any(),
}),
}
)
);

View File

@@ -0,0 +1,8 @@
import Elysia from "elysia";
export const human = new Elysia({ name: "human" })
.derive(async ({ cookie: { token }, status }) => {
const humanKey = token.value;
return humanKey != null ? { humanKey } : status(401);
})
.as("scoped");

View File

@@ -0,0 +1,34 @@
import api from "./api";
import { Elysia, env } from "elysia";
import { cors } from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
import { error } from "node:console";
const port = env.PORT || 5001;
const app = new Elysia()
.use(
cors({
origin: ["localhost:3000", "games.drm.dev"],
})
)
.onRequest(({ request }) => {
console.log(request.method, request.url);
})
.onError(({ error }) => {
console.error(error);
return error;
})
.get("/ping", () => "pong")
.use(api)
.get("/*", () => Bun.file("./public/index.html"))
.use(
staticPlugin({
assets: "public",
prefix: "/",
alwaysStatic: true,
})
)
.listen(port);
console.log("server started on", port);

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"esModuleInterop": true
}
}

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

View File

@@ -0,0 +1,7 @@
{
"name": "@games/shared",
"version": "1.0.0",
"dependencies": {
"object-hash": "^3.0.0"
}
}

View File

@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"esModuleInterop": true
}
}

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

6213
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- "@parcel/watcher"
- esbuild
packages:
- "packages/*"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 B

View File

@@ -1,60 +0,0 @@
html {
height: 100vh;
}
body {
margin: 0;
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande",
"Lucida Sans", Arial, sans-serif;
color: white;
height: 100%;
}
body::before {
z-index: -1;
content: "";
font-size: 28px;
position: absolute;
width: 100%;
height: 100%;
}
#app {
height: 100%;
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));
}
.full {
height: 100%;
width: 100%;
}
.w-full {
width: 100%;
}
.center {
display: flex;
justify-content: center;
align-items: center;
}
.column {
display: flex;
flex-direction: column;
}
.free {
position: absolute;
top: 0;
left: 0;
}
.fixed-br {
position: fixed;
bottom: 0;
right: 0;
}
.clear {
pointer-events: none;
}

View File

@@ -1,36 +0,0 @@
import "./app.css";
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
import pkg from "~/../package.json";
const Version = () => (
<div class="full free clear">
<span
style={{
margin: "5px",
"font-size": "0.8rem",
"font-family": "monospace",
"pointer-events": "all",
}}
class="fixed-br"
>
v{pkg.version}
</span>
</div>
);
export default function App() {
return (
<Router
root={(props) => (
<>
<Suspense>{props.children}</Suspense>
<Version />
</>
)}
>
<FileRoutes />
</Router>
);
}

View File

@@ -1,40 +0,0 @@
import { createContext, JSX } from "solid-js";
import Card from "./Card";
import Hand from "./Hand";
import Pile from "./Pile";
import { newDeck, shuffle, Hand as THand } from "../types/cards";
import { createStore, produce } from "solid-js/store";
const GameContext = createContext();
export default () => {
const [gameState, setGameState] = createStore({
pile: shuffle(newDeck()),
hand: [] as THand,
});
return (
<GameContext.Provider value={{ gameState, setGameState }}>
<div
onClick={() => {}}
class="full column"
style={{ "row-gap": "20px", "font-size": "32px" }}
>
<div class="full center">
<Pile
pile={gameState.pile}
style={{ cursor: "pointer" }}
onClick={() =>
setGameState(
produce((state) => {
state.hand.push(state.pile.pop()!);
})
)
}
/>
</div>
<Hand hand={gameState.hand} />
</div>
</GameContext.Provider>
);
};

View File

@@ -1,24 +0,0 @@
import { Component, For } from "solid-js";
import Card from "./Card";
import { Hand } from "../types/cards";
export default ((props) => {
return (
<div
style={{
border: "2px dashed white",
"border-radius": "12px",
margin: "10px",
"margin-bottom": "25px",
padding: "10px",
height: "200px",
overflow: "scroll",
"scrollbar-width": "none",
display: "flex",
gap: "5px",
}}
>
<For each={props.hand}>{(card) => <Card card={card} />}</For>
</div>
);
}) satisfies Component<{ hand: Hand }>;

View File

@@ -1,44 +0,0 @@
import { Component, For, JSX } from "solid-js";
import Card from "./Card";
import { Pile } from "../types/cards";
export default ((props) => {
return (
<div
class="center"
style={{ width: "200px", height: "400px", ...props.style }}
onClick={props.onClick}
>
<For each={props.pile}>
{(card, i) => (
<Card
card={card}
face="down"
style={{
position: "absolute",
transform: `translate(${i() * 0.8}px, ${
-i() * 0.4
}px)`,
"z-index": 100 - i(),
border: `0.1px solid rgb(${
60 - i() + Math.random() * 10
}, ${60 - i() + Math.random() * 10}, ${
60 - i() + Math.random() * 10
});`,
}}
/>
)}
</For>
</div>
);
}) satisfies Component<{
pile: Pile;
style?: JSX.CSSProperties;
onClick?:
| JSX.EventHandlerUnion<
HTMLDivElement,
MouseEvent,
JSX.EventHandler<HTMLDivElement, MouseEvent>
>
| undefined;
}>;

View File

@@ -1,4 +0,0 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);

View File

@@ -1,21 +0,0 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

View File

@@ -1,5 +0,0 @@
import Game from "../components/Game";
export default () => {
return <Game />;
};

View File

@@ -1,19 +1,8 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vinxi/types/client"],
"isolatedModules": true,
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
"@games/*": ["packages/*"]
}
}
}