Compare commits

...

18 Commits

Author SHA1 Message Date
eed6db3373 build ready (I hope) 2025-08-10 18:53:00 -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
60 changed files with 1122 additions and 5300 deletions

View File

@@ -7,3 +7,4 @@ README.md
.git
.gitignore
.dockerignore
*.db

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
.output
.vinxi
*.db
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,9 @@
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
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({});

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,19 @@
{
"name": "@games/client",
"type": "module",
"version": "0.0.2",
"scripts": {
"dev": "vite --port 3000",
"build": "vite build"
},
"dependencies": {
"@elysiajs/eden": "^1.3.2",
"@solidjs/router": "^0.15.3",
"solid-js": "^1.9.5",
"object-hash": "^3.0.0"
},
"devDependencies": {
"vite": "^7.0.6",
"vite-plugin-solid": "^2.11.8"
}
}

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

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,9 @@
import { type Api } from "../../server/src/api";
import { treaty } from "@elysiajs/eden";
const { api } = treaty<Api>("http://localhost:5001", {
headers: {
human: "daniel",
},
});
export default api;

View File

@@ -0,0 +1,45 @@
import "./style.css";
import { Route, Router } from "@solidjs/router";
import { lazy, Suspense } from "solid-js";
import pkg from "../package.json";
import { render } from "solid-js/web";
import Root from "./routes/index";
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>
);
const App = () => (
<Router
root={(props) => (
<>
<Suspense>{props.children}</Suspense>
<Version />
</>
)}
>
<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>
);
render(App, document.getElementById("app")!);

View File

@@ -1,6 +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") {
@@ -17,17 +18,6 @@ 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
@@ -39,14 +29,22 @@ export default ((props) => {
...props.style,
}}
width="100px"
src={svgPath()?.default}
src={
props.face == "down"
? "/views/back.svg"
: `/views/cards/${cardToSvgFilename(props.card)}.svg`
}
/>
</Suspense>
);
}) satisfies Component<
{
(
| {
card: Card;
face?: "up" | "down";
} & Stylable &
face?: "up";
}
| { card?: Card; face: "down" }
) &
Stylable &
Clickable
>;

View File

@@ -0,0 +1,49 @@
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}>
<div
class="full column center"
style={{ "row-gap": "20px", "font-size": "32px" }}
>
<div class="full center">
<Pile
count={view.latest!.deckCount}
style={{ cursor: "pointer" }}
onClick={() => submitAction({ type: "draw" })}
/>
</div>
<Hand hand={view.latest!.myHand} />
</div>
</Show>
</GameContext.Provider>
);
};

View File

@@ -1,11 +1,11 @@
import { Component, For, useContext } from "solid-js";
import Card from "./Card";
import { Hand } from "../types/cards";
import { Hand } from "../../../shared/cards";
import { GameContext } from "./Game";
import { produce } from "solid-js/store";
export default ((props) => {
const { setGameState } = useContext(GameContext)!;
const { submitAction, view } = useContext(GameContext)!;
return (
<div
@@ -19,7 +19,7 @@ export default ((props) => {
margin: "10px",
"margin-bottom": "25px",
padding: "10px",
height: "200px",
height: "180px",
overflow: "scroll",
"scrollbar-width": "none",
display: "flex",
@@ -33,20 +33,7 @@ export default ((props) => {
style={{
cursor: "pointer",
}}
onClick={() =>
setGameState(
produce((state) => {
const index = state.hand.indexOf(card);
console.log(index);
state.deck.push(
state.hand.splice(
props.hand.indexOf(card),
1
)[0]!
);
})
)
}
onClick={() => submitAction({ type: "discard", card })}
/>
)}
</For>

View File

@@ -0,0 +1,28 @@
import { Component, For, JSX, Show } from "solid-js";
import Card from "./Card";
import { Clickable, Stylable } from "./toolbox";
export default ((props) => {
return (
<div
{...props}
class={`center ${props.class ?? ""}}`.trim()}
style={{
width: "200px",
height: "400px",
...(props.style as JSX.CSSProperties),
}}
onClick={props.onClick}
>
<Show when={props.count > 0}>
<Card face="down" />
</Show>
</div>
);
}) satisfies Component<
{
count: number;
} & Stylable &
Clickable
>;

View File

@@ -0,0 +1,28 @@
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}`}
style={{
position: "absolute",
padding: "10px",
top: "0",
left: "0",
margin: "20px",
"background-color": "white",
"border-radius": "8px",
border: "2px solid black",
}}
>
Back
</A>
</>
);
};

View File

@@ -0,0 +1,43 @@
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" }}>
<h1 style={{ margin: 0 }}>{param.game}</h1>
<button
onClick={() =>
api.simple.newGame
.post({
players: ["daniel"],
})
.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.name}`}>{game.name}</A>}
</For>
</div>
);
};

View File

@@ -18,6 +18,13 @@ body::before {
height: 100%;
}
a {
color: rgb(18, 229, 113);
}
a:visited {
color: rgb(23, 138, 125);
}
#app {
height: 100%;
background: radial-gradient(rgb(24, 82, 65), rgb(1, 42, 16));

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,10 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
plugins: [solidPlugin()],
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,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
name String
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,37 @@
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]
.group("/prisma", (app) =>
app
.post("/game", ({ body }: { body: Prisma.GameFindManyArgs }) =>
prisma.game.findMany(body)
)
.post(
"/instance",
({ body }: { body: Prisma.InstanceFindManyArgs }) =>
prisma.instance.findMany(body)
)
)
.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,184 @@
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";
// 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" })
// .guard({ headers: t.Object({ Human: t.String() }) })
.post(
"/newGame",
(args: { body: { players: string[] }; headers: { human: string } }) => {
return prisma.instance.create({
data: {
gameState: newGame(args.body.players),
gameKey: "simple",
createdByKey: args.headers.human,
},
});
}
)
.group("/:instanceId", (app) =>
app
.get(
"/",
({ params: { instanceId }, headers: { human: humanId } }) =>
prisma.instance
.findUnique({
where: {
id: instanceId,
},
})
.then((game) =>
getView(
getKnowledge(
game!.gameState as GameState,
humanId!
),
humanId!
)
)
)
.post(
"/",
({
params: { instanceId },
body: { action },
headers: { human: humanId },
}) =>
prisma.instance
.findUniqueOrThrow({
where: {
id: instanceId,
},
})
.then(async (game) => {
const newState = resolveAction(
game.gameState as GameState,
humanId!,
action
);
await prisma.instance.update({
data: { gameState: newState },
where: {
id: instanceId,
},
});
return getView(
getKnowledge(newState, humanId!),
humanId!
);
}),
{
body: t.Object({
action: t.Any(),
}),
}
)
);

View File

@@ -0,0 +1,30 @@
import api from "./api";
import { Elysia, env } from "elysia";
import { cors } from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
const port = env.PORT || 5001;
const app = new Elysia()
.use(cors())
.onRequest(({ request }) => {
console.log(request.method, request.url);
})
.onError(({ code, error, body, headers }) => {
console.error("headers", JSON.stringify(headers, null, 2));
console.error("body", JSON.stringify(body, null, 2));
console.error(code, 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
@@ -55,8 +56,3 @@ export const shuffle = (cards: Card[]) => {
}
return cards;
};
export type GameState = {
deck: Pile;
hand: Hand;
};

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

5367
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/*"

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

View File

@@ -1,46 +0,0 @@
import { Component, For, JSX } from "solid-js";
import Card from "./Card";
import { Pile } from "../types/cards";
import { type ComponentProps } from "solid-js";
import { Clickable, Stylable } from "./toolbox";
export default ((props) => {
return (
<div
{...props}
class={`center ${props.class ?? ""}}`.trim()}
style={{
width: "200px",
height: "400px",
...(props.style as JSX.CSSProperties),
}}
onClick={props.onClick}
>
<For each={props.pile}>
{(card, i) => (
<Card
card={card}
face="down"
style={{
position: "absolute",
transform: `translate(${i() * 0.5}px, ${
i() * 0.2
}px)`,
// "z-index": 100 - i(),
border: `0.1px solid rgb(${
10 + i() + Math.random() * 50
}, ${10 + i() + Math.random() * 50}, ${
10 + i() + Math.random() * 50
});`,
}}
/>
)}
</For>
</div>
);
}) satisfies Component<
{
pile: Pile;
} & Stylable &
Clickable
>;

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/*"]
}
}
}