First Commit
This commit is contained in:
commit
96f4cedd3e
|
|
@ -0,0 +1,490 @@
|
|||
# Fertig Classic Games
|
||||
|
||||
A Phaser 3.90 framework for classic tabletop games (Backgammon, Parchisi, ...)
|
||||
and casino games (Blackjack, Texas Hold 'Em, ...), with accounts, profiles,
|
||||
match history, and multiplayer lobbies.
|
||||
|
||||
The frontend uses **native browser ES modules** — no bundler, no build step.
|
||||
The backend is Node.js + Express + Socket.IO with SQLite for persistence.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick start](#quick-start)
|
||||
- [Configuration (`.env`)](#configuration-env)
|
||||
- [Running the server](#running-the-server)
|
||||
- [Project layout](#project-layout)
|
||||
- [Database schema](#database-schema)
|
||||
- [REST API](#rest-api)
|
||||
- [Socket.IO events](#socketio-events)
|
||||
- [Frontend architecture](#frontend-architecture)
|
||||
- [Adding a new game](#adding-a-new-game)
|
||||
- [Email verification](#email-verification)
|
||||
- [Profile pictures](#profile-pictures)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Roadmap](#roadmap)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Account creation with email + username + password (bcrypt hashed)
|
||||
- Email verification with configurable SMTP — falls back to logging dev links
|
||||
to the console when SMTP is not set up
|
||||
- Session cookies backed by SQLite (httpOnly, SameSite=Lax)
|
||||
- Profile management: display name, bio, avatar upload (PNG / JPEG / WebP)
|
||||
- Match history (wins / losses / draws) ready to be populated by games
|
||||
- Multiplayer lobby system over Socket.IO with rooms, presence, and broadcast
|
||||
- Pluggable game registry — register tabletop or casino games server-side
|
||||
- Base classes (`TabletopGame`, `CasinoGame`) that handle the turn loop /
|
||||
betting loop scaffolding so new games only implement rules
|
||||
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
|
||||
- Vector-only placeholder graphics — drop sprites in later without
|
||||
refactoring scenes
|
||||
- Mouse + keyboard controls
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20 or newer** (uses `node --watch`, native fetch, ES modules)
|
||||
- **npm 9 or newer**
|
||||
- A C/C++ toolchain for `better-sqlite3` and `bcrypt` to build native bindings:
|
||||
- **Linux**: `build-essential`, `python3`
|
||||
- **macOS**: Xcode command line tools (`xcode-select --install`)
|
||||
- **Windows**: `npm install --global windows-build-tools` (older Windows)
|
||||
or install Visual Studio Build Tools
|
||||
|
||||
No bundler, no Docker, no external database required to get started.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone <this-repo>
|
||||
cd fertig-classic-games
|
||||
|
||||
cp example.env .env
|
||||
# Edit .env — at minimum, set SESSION_SECRET to a long random string.
|
||||
# Generate one with:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
|
||||
npm install
|
||||
npm run migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 in your browser. Register an account; if SMTP is
|
||||
not configured (the default), the verification link is printed to the server
|
||||
console — click it to verify.
|
||||
|
||||
---
|
||||
|
||||
## Configuration (`.env`)
|
||||
|
||||
All configuration lives in `.env` at the project root. Use `example.env` as
|
||||
the template. Fields:
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------|--------------------------|------------------------------------------|
|
||||
| `NODE_ENV` | `development` | `development` or `production`. |
|
||||
| `HOST` | `0.0.0.0` | Bind address. |
|
||||
| `PORT` | `3000` | HTTP port. |
|
||||
| `BASE_URL` | `http://localhost:3000` | Public URL used in verification emails. |
|
||||
| `LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`. |
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-----------|-------------------------|-----------------------------------|
|
||||
| `DB_PATH` | `./data/fertig.sqlite` | SQLite file path. Auto-created. |
|
||||
|
||||
### Auth
|
||||
|
||||
| Variable | Default | Description |
|
||||
|------------------------|---------------|--------------------------------------------------------------------------------------------------------------|
|
||||
| `SESSION_SECRET` | *(required)* | Long random string. **Required in production.** A dev fallback is used if empty in development with a warning. |
|
||||
| `SESSION_COOKIE_NAME` | `fcg_sid` | Cookie name. |
|
||||
| `SESSION_TTL_DAYS` | `30` | Session lifetime in days. |
|
||||
| `BCRYPT_ROUNDS` | `12` | bcrypt cost factor. |
|
||||
|
||||
### Uploads (profile pictures)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|------------------------|--------------------------------------|--------------------------------------|
|
||||
| `UPLOAD_DIR` | `./public/uploads` | Directory for avatar files. |
|
||||
| `MAX_UPLOAD_SIZE_MB` | `5` | Max image size in MB. |
|
||||
| `ALLOWED_UPLOAD_MIME` | `image/png,image/jpeg,image/webp` | Comma-separated MIME allowlist. |
|
||||
|
||||
### Email
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---------------------------------|---------|------------------------------------------------------------------------|
|
||||
| `SMTP_HOST` | *(empty)* | If empty, verification links log to the console instead of sending. |
|
||||
| `SMTP_PORT` | `587` | SMTP port. |
|
||||
| `SMTP_SECURE` | `false` | `true` for SMTPS (usually port 465). |
|
||||
| `SMTP_USER` | *(empty)* | SMTP username, if your provider requires auth. |
|
||||
| `SMTP_PASS` | *(empty)* | SMTP password. |
|
||||
| `SMTP_FROM` | *(see example.env)* | `From:` header for outgoing mail. |
|
||||
| `VERIFICATION_TOKEN_TTL_HOURS` | `24` | How long verification links remain valid. |
|
||||
|
||||
### Multiplayer
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------------------|---------------------------|------------------------------------------------------------|
|
||||
| `SOCKET_IO_CORS_ORIGIN` | `http://localhost:3000` | CORS origin for the Socket.IO server. Use `*` only in dev. |
|
||||
|
||||
---
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
npm run dev # node --watch, auto-restart on file changes
|
||||
npm start # plain node, production-style
|
||||
npm run migrate # apply any pending DB migrations
|
||||
```
|
||||
|
||||
The server serves both the API (`/api/*`), static frontend (`/`, `/src/...`,
|
||||
`/uploads/...`), and the Socket.IO endpoint (`/socket.io`) on the same port.
|
||||
|
||||
After it starts you should see:
|
||||
|
||||
```
|
||||
[server] listening on http://0.0.0.0:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
fertig-classic-games/
|
||||
├── example.env Configuration template (commit this)
|
||||
├── .env Your local configuration (gitignored)
|
||||
├── package.json
|
||||
├── README.md
|
||||
│
|
||||
├── server/ Backend (Node.js, ES modules)
|
||||
│ ├── index.js Express + Socket.IO bootstrap
|
||||
│ ├── config.js Loads & validates .env
|
||||
│ ├── db/
|
||||
│ │ ├── index.js better-sqlite3 connection singleton
|
||||
│ │ ├── migrate.js SQL migration runner
|
||||
│ │ └── migrations/
|
||||
│ │ └── 001_init.sql Initial schema
|
||||
│ ├── auth/
|
||||
│ │ ├── routes.js /api/auth/* endpoints
|
||||
│ │ ├── service.js bcrypt, sessions, verification tokens
|
||||
│ │ └── middleware.js loadUser, requireAuth
|
||||
│ ├── profile/
|
||||
│ │ ├── routes.js /api/profile/*, multer upload
|
||||
│ │ └── service.js
|
||||
│ ├── history/
|
||||
│ │ └── routes.js /api/history
|
||||
│ ├── email/
|
||||
│ │ └── mailer.js Nodemailer wrapper with console fallback
|
||||
│ └── multiplayer/
|
||||
│ ├── index.js Socket.IO server + auth handshake
|
||||
│ ├── lobby.js Room manager (create/join/leave)
|
||||
│ └── gameRegistry.js Game definitions
|
||||
│
|
||||
├── public/ Frontend, served as static files
|
||||
│ ├── index.html Loads Phaser via importmap
|
||||
│ ├── styles.css
|
||||
│ ├── uploads/ Avatars (gitignored)
|
||||
│ └── src/
|
||||
│ ├── main.js Phaser.Game + scale config
|
||||
│ ├── config.js UI colors, dimensions, API base
|
||||
│ ├── services/
|
||||
│ │ ├── api.js fetch wrapper
|
||||
│ │ ├── auth.js Client-side auth store
|
||||
│ │ └── socket.js socket.io-client
|
||||
│ ├── ui/
|
||||
│ │ ├── Button.js
|
||||
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale
|
||||
│ │ └── Modal.js
|
||||
│ ├── scenes/
|
||||
│ │ ├── BootScene.js
|
||||
│ │ ├── PreloadScene.js
|
||||
│ │ ├── LandingScene.js
|
||||
│ │ ├── LoginScene.js
|
||||
│ │ ├── RegisterScene.js
|
||||
│ │ ├── VerifyScene.js
|
||||
│ │ ├── ProfileScene.js
|
||||
│ │ ├── GameMenuScene.js
|
||||
│ │ ├── LobbyScene.js
|
||||
│ │ └── GameRoomScene.js
|
||||
│ └── games/
|
||||
│ ├── BaseGame.js
|
||||
│ ├── tabletop/TabletopGame.js
|
||||
│ └── casino/CasinoGame.js
|
||||
│
|
||||
└── data/ SQLite database (gitignored)
|
||||
└── fertig.sqlite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database schema
|
||||
|
||||
Created by `server/db/migrations/001_init.sql`. Run `npm run migrate` to apply
|
||||
any pending migrations.
|
||||
|
||||
- **`users`** — `id, email, username, password_hash, email_verified,
|
||||
verification_token, verification_expires_at, created_at`
|
||||
- **`sessions`** — `id, user_id, expires_at, created_at`
|
||||
- **`profiles`** — `user_id (PK/FK), display_name, avatar_path, bio,
|
||||
updated_at`
|
||||
- **`games`** — `id, slug, name, category ('tabletop'|'casino'),
|
||||
max_players, supports_multiplayer`
|
||||
- **`matches`** — `id, game_id, started_at, ended_at, status`
|
||||
- **`match_players`** — `match_id, user_id, seat, result
|
||||
('win'|'loss'|'draw'|'abandoned'), score`
|
||||
|
||||
Add new migrations as `server/db/migrations/00N_description.sql` — they are
|
||||
applied in lexicographic order and tracked in `schema_migrations`.
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
All endpoints are JSON. Session is carried by the `fcg_sid` cookie
|
||||
automatically; the client uses `credentials: 'same-origin'` in fetch calls.
|
||||
|
||||
### Auth — `/api/auth`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------|------|----------------------------------------------------------------------------------------------|
|
||||
| POST | `/register` | — | Body: `{ email, username, password }`. Creates user, sends verification, sets session. |
|
||||
| POST | `/login` | — | Body: `{ identifier, password }` where `identifier` is email or username. |
|
||||
| POST | `/logout` | — | Destroys session. |
|
||||
| GET | `/me` | — | Returns `{ user }` or `{ user: null }`. |
|
||||
| GET | `/verify` | — | `?token=...`. Marks user as verified. Returns HTML so a user clicking the email link sees text. |
|
||||
|
||||
### Profile — `/api/profile`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------------|-----------|-----------------------------------------------------------------------------------|
|
||||
| GET | `/` | Required | Returns the current user's profile. |
|
||||
| PATCH | `/` | Required | Body: `{ displayName?, bio? }`. Returns the updated profile. |
|
||||
| POST | `/avatar` | Required | Multipart with field `avatar`. Stores the file and updates `avatar_path`. |
|
||||
|
||||
### History — `/api/history`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------|----------|------------------------------------------------------------|
|
||||
| GET | `/` | Required | Last 100 matches for the user, plus `{ wins, losses, draws }`. |
|
||||
|
||||
### Misc
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|----------------|------|------------------------------------------------------|
|
||||
| GET | `/api/health` | — | `{ ok: true }`. |
|
||||
| GET | `/api/games` | — | Lists registered games from `gameRegistry.js`. |
|
||||
|
||||
---
|
||||
|
||||
## Socket.IO events
|
||||
|
||||
Clients connect to the same origin as the page. The server reads the session
|
||||
cookie during the handshake and rejects unauthenticated connections.
|
||||
|
||||
### Server → client
|
||||
|
||||
| Event | Payload | When |
|
||||
|-----------------|----------------------------------------------------|---------------------------------------------------|
|
||||
| `hello` | `{ user, games }` | On successful connection. |
|
||||
| `lobby:update` | `Room[]` | After a room is created, joined, or left. |
|
||||
| `room:update` | `Room` | When the room's players list changes. |
|
||||
| `room:message` | `{ from: { id, username }, payload, at }` | Chat / game messages routed within a room. |
|
||||
|
||||
### Client → server
|
||||
|
||||
| Event | Payload | Ack | Notes |
|
||||
|--------------------|-------------------------------|----------------------------------|----------------------------------------|
|
||||
| `lobby:subscribe` | `gameSlug` | — | Joins the lobby for that game slug. |
|
||||
| `lobby:unsubscribe`| `gameSlug` | — | |
|
||||
| `room:create` | `{ gameSlug, name? }` | `{ ok, room? , error? }` | Creates a room; you join automatically.|
|
||||
| `room:join` | `{ roomId }` | `{ ok, room?, error? }` | |
|
||||
| `room:leave` | `{ roomId }` | `{ ok }` | |
|
||||
| `room:message` | `{ roomId, payload }` | — | Broadcast to other room members. |
|
||||
|
||||
`Room` shape:
|
||||
|
||||
```js
|
||||
{
|
||||
id: string,
|
||||
gameSlug: string,
|
||||
name: string,
|
||||
hostId: number,
|
||||
players: [{ id: number, username: string, seat: number }],
|
||||
maxPlayers: number,
|
||||
status: 'waiting' | 'in_progress' | 'completed'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
- **No bundler.** `public/index.html` uses an `<script type="importmap">` to
|
||||
resolve `phaser` and `socket.io-client` to CDN ES modules. The rest of the
|
||||
app uses relative ES imports.
|
||||
- **Scenes** live in `public/src/scenes/`. The flow is:
|
||||
`Boot → Preload → Landing → (Login | Register | Verify) → Profile |
|
||||
GameMenu → Lobby → GameRoom`.
|
||||
- **`auth` store** (`services/auth.js`) is a tiny pub-sub the scenes
|
||||
subscribe to so they re-render when the signed-in user changes.
|
||||
- **`api`** (`services/api.js`) is a thin fetch wrapper that throws on
|
||||
non-2xx responses with `err.status` and `err.data` attached.
|
||||
- **`socket`** (`services/socket.js`) is a lazily-connected singleton; scenes
|
||||
call `connectSocket()` when entering a multiplayer flow.
|
||||
- **DOM-overlay inputs.** Phaser doesn't have a native text input, so
|
||||
`ui/TextInput.js` positions a real `<input>` element over the canvas and
|
||||
repositions it on scale-resize. The `#dom-layer` div has
|
||||
`pointer-events: none` so the canvas stays interactive, and child elements
|
||||
re-enable pointer events.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new game
|
||||
|
||||
1. **Register the game on the server.** In `server/multiplayer/gameRegistry.js`:
|
||||
|
||||
```js
|
||||
registerGame({
|
||||
slug: 'cribbage',
|
||||
name: 'Cribbage',
|
||||
category: 'tabletop', // or 'casino'
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
supportsMultiplayer: true,
|
||||
});
|
||||
```
|
||||
|
||||
The lobby and game menu will pick it up automatically.
|
||||
|
||||
2. **Add a row in the `games` table** if you want to record matches in
|
||||
history. Either insert in a new migration or via a one-off statement.
|
||||
|
||||
3. **Implement the game scene.** Extend `TabletopGame` or `CasinoGame`:
|
||||
|
||||
```js
|
||||
// public/src/games/tabletop/CribbageGame.js
|
||||
import TabletopGame from './TabletopGame.js';
|
||||
|
||||
export default class CribbageGame extends TabletopGame {
|
||||
constructor() { super('CribbageGame'); }
|
||||
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ }
|
||||
applyBoardState(state) { /* reconcile pieces & turn */ }
|
||||
}
|
||||
```
|
||||
|
||||
4. **Route to it from `GameRoomScene`.** Either switch on `game.slug` and
|
||||
start the right scene, or load the module dynamically:
|
||||
|
||||
```js
|
||||
const mod = await import(`../games/${category}/${ClassName}.js`);
|
||||
this.scene.add(ClassName, mod.default, true, { game, room, socket, user });
|
||||
```
|
||||
|
||||
5. **Send/receive game state.** Use `sendAction({ type, ... })` from the base
|
||||
class. The server should listen for `game:<slug>:action` and respond with
|
||||
`game:<slug>:state` (public) and `game:<slug>:private` (per-seat hidden
|
||||
info, for casino games).
|
||||
|
||||
---
|
||||
|
||||
## Email verification
|
||||
|
||||
When `SMTP_HOST` is set, `nodemailer` is used to send a real email. When it's
|
||||
empty, `server/email/mailer.js` instead prints the verification link to the
|
||||
server console:
|
||||
|
||||
```
|
||||
[mailer:dev] Verification link for user@example.com:
|
||||
http://localhost:3000/api/auth/verify?token=...
|
||||
```
|
||||
|
||||
Click that link (or visit it in the browser) to verify the account. The
|
||||
registration response also includes the dev link as `verification.devLink`,
|
||||
which the frontend shows on the verify screen.
|
||||
|
||||
### Using Gmail / a real provider in development
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASS=your-app-password # NOT your normal password, use an app password
|
||||
SMTP_FROM="Fertig Classic Games <you@gmail.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profile pictures
|
||||
|
||||
- Uploaded via `POST /api/profile/avatar` (multipart form, field name
|
||||
`avatar`).
|
||||
- Stored on the filesystem under `UPLOAD_DIR` (default `./public/uploads`).
|
||||
- The DB stores the **public path** (e.g. `/uploads/u1-1715800000-abcd.png`),
|
||||
not the file contents.
|
||||
- The `public/uploads/` directory is gitignored. Back it up alongside
|
||||
`data/fertig.sqlite` if you want to preserve user content.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`SESSION_SECRET must be set in production`** — set a long random
|
||||
`SESSION_SECRET` in `.env`. In `NODE_ENV=development` the server falls back
|
||||
to an insecure default with a warning.
|
||||
|
||||
**`SqliteError: no such table: ...`** — you haven't run migrations. Run
|
||||
`npm run migrate`.
|
||||
|
||||
**`better-sqlite3` or `bcrypt` build failure on install** — install platform
|
||||
build tools (see [Prerequisites](#prerequisites)) and re-run `npm install`.
|
||||
|
||||
**Verification email never arrives** — if `SMTP_HOST` is empty, by design no
|
||||
email is sent; check the server console for the dev link. If SMTP *is* set,
|
||||
check provider auth (Gmail requires an app password, not your account
|
||||
password) and `SMTP_PORT` / `SMTP_SECURE` for your provider.
|
||||
|
||||
**Socket connection rejected with `Not authenticated`** — sign in first; the
|
||||
Socket.IO handshake reads the session cookie. Make sure
|
||||
`SOCKET_IO_CORS_ORIGIN` matches the origin you're browsing from.
|
||||
|
||||
**Avatar upload fails with `Unsupported image type`** — only the MIME types
|
||||
in `ALLOWED_UPLOAD_MIME` are accepted. Add more if needed.
|
||||
|
||||
**Layout looks wrong / inputs misaligned** — the DOM overlay repositions on
|
||||
the Phaser `resize` event. If you resize the window very fast, give it a
|
||||
beat; if it persists, file an issue.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
The framework is intentionally game-agnostic. Concrete games come next:
|
||||
|
||||
- [ ] **Backgammon** — 2-player turn-based on `TabletopGame`
|
||||
- [ ] **Parchisi** — up to 4 players on `TabletopGame`
|
||||
- [ ] **Blackjack** — 1–6 players on `CasinoGame`
|
||||
- [ ] **Texas Hold 'Em** — 2–8 players on `CasinoGame`
|
||||
- [ ] Server-authoritative game state, per-seat private state for casino games
|
||||
- [ ] Match recording into `matches` / `match_players` for history
|
||||
- [ ] Friends list + invites
|
||||
- [ ] Spectator mode
|
||||
|
||||
Contributions welcome — start by registering a game in `gameRegistry.js` and
|
||||
extending one of the base classes.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Copy this file to `.env` and fill in real values for local development.
|
||||
# `.env` is gitignored; `example.env` is committed as the template.
|
||||
|
||||
# ---- Server ----
|
||||
NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
# Public base URL used in verification emails and links. No trailing slash.
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# ---- Database ----
|
||||
# SQLite file path, relative to the server working directory.
|
||||
DB_PATH=./data/fertig.sqlite
|
||||
|
||||
# ---- Auth ----
|
||||
# Secret for signing session cookies. Generate with:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
SESSION_SECRET=replace-me-with-a-long-random-string
|
||||
SESSION_COOKIE_NAME=fcg_sid
|
||||
SESSION_TTL_DAYS=30
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# ---- Uploads (profile pictures) ----
|
||||
UPLOAD_DIR=./public/uploads
|
||||
MAX_UPLOAD_SIZE_MB=5
|
||||
# Comma-separated list of accepted MIME types.
|
||||
ALLOWED_UPLOAD_MIME=image/png,image/jpeg,image/webp
|
||||
|
||||
# ---- Email verification ----
|
||||
# If SMTP_HOST is empty, the mailer logs verification links to the server
|
||||
# console instead of sending email. Useful for local dev.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM="Fertig Classic Games <no-reply@fertigclassicgames.local>"
|
||||
VERIFICATION_TOKEN_TTL_HOURS=24
|
||||
|
||||
# ---- Multiplayer (Socket.IO) ----
|
||||
# CORS origin for the socket server. Use "*" only in development.
|
||||
SOCKET_IO_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# ---- Logging ----
|
||||
# One of: error, warn, info, debug
|
||||
LOG_LEVEL=info
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Build Guidelines
|
||||
|
||||
Create an HTML Phaser 3 video game. The game should be a similar type of game to the old Hoyle Classic Games type of game.
|
||||
|
||||
## Tools and Organization
|
||||
|
||||
- Phaser version 3.90 HTML game
|
||||
- Use JavaScript
|
||||
- Have JavaScript objects reference each other directly via IMPORT and EXPORT using ES6 standards
|
||||
- Do **NOT** require a web packager.
|
||||
- Create files and classes in a manner that allows future modifications and scaling at a modular level
|
||||
- Integrate SQLite to allow for long term web storage.
|
||||
- allow for account login/password creation, profile pic uploading, profile creation.
|
||||
- Keep history of games, wins, losses dates etc.
|
||||
- Use basic email verification on account creation.
|
||||
- Allow for multiplayer. We'll create a multiplayer lobby for some games, and allow players to simply join some others.
|
||||
- Just create a basic framework to start with and we'll add the games themselves later
|
||||
- Let's create the ability to create accounts and save/edit profiles.
|
||||
- Create a landing page for "Fertig Classic Games" and we'll add the games themselves later.
|
||||
- Prepare a framework to allow tabletop type games such as Backgammon and Parchisi, and also Casino games such as backjack and texas hold em.
|
||||
|
||||
## Basic Framework
|
||||
|
||||
- 1920 x 1080 view
|
||||
- Scale view to user's viewport.
|
||||
- Use basic temporary vector graphics that can later be replaced by sprites
|
||||
|
||||
## Controls
|
||||
|
||||
- Mouse + Keyboard controls for all games.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"description": "Fertig Classic Games — Phaser 3 framework for tabletop and casino games",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js",
|
||||
"migrate": "node server/db/migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cookie": "^0.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^2.0.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"socket.io": "^4.7.5"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Fertig Classic Games</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container"></div>
|
||||
<div id="dom-layer" aria-hidden="true"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js",
|
||||
"socket.io-client": "https://cdn.jsdelivr.net/npm/socket.io-client@4.7.5/+esm"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export const GAME_WIDTH = 1920;
|
||||
export const GAME_HEIGHT = 1080;
|
||||
|
||||
export const COLORS = {
|
||||
bg: 0x0a0e14,
|
||||
bgHex: '#0a0e14',
|
||||
panel: 0x111923,
|
||||
panelHex: '#111923',
|
||||
accent: 0x5aa9e6,
|
||||
accentHex: '#5aa9e6',
|
||||
text: 0xe6edf3,
|
||||
textHex: '#e6edf3',
|
||||
muted: 0x8a94a6,
|
||||
mutedHex: '#8a94a6',
|
||||
danger: 0xe06c75,
|
||||
dangerHex: '#e06c75',
|
||||
};
|
||||
|
||||
export const API_BASE = '/api';
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import Phaser from 'phaser';
|
||||
|
||||
// Common lifecycle and helpers for any game. Concrete games extend
|
||||
// TabletopGame or CasinoGame, which extend this.
|
||||
//
|
||||
// Subclasses should implement:
|
||||
// - createBoard() set up persistent visuals
|
||||
// - onState(state) apply an authoritative state update from the server
|
||||
// - onInput(action) optional, for client-side input handling
|
||||
export default class BaseGame extends Phaser.Scene {
|
||||
constructor(key) {
|
||||
super(key);
|
||||
this.gameDef = null;
|
||||
this.room = null;
|
||||
this.socket = null;
|
||||
this.localUser = null;
|
||||
}
|
||||
|
||||
init(data = {}) {
|
||||
this.gameDef = data.game;
|
||||
this.room = data.room ?? null;
|
||||
this.socket = data.socket ?? null;
|
||||
this.localUser = data.user ?? null;
|
||||
}
|
||||
|
||||
create() {
|
||||
this.createBoard?.();
|
||||
this.bindNetwork();
|
||||
}
|
||||
|
||||
bindNetwork() {
|
||||
if (!this.socket) return;
|
||||
this.socket.on(`game:${this.gameDef.slug}:state`, this.handleState);
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
this.socket.off(`game:${this.gameDef.slug}:state`, this.handleState);
|
||||
});
|
||||
}
|
||||
|
||||
handleState = (state) => {
|
||||
this.onState?.(state);
|
||||
};
|
||||
|
||||
sendAction(action) {
|
||||
if (!this.socket || !this.room) return;
|
||||
this.socket.emit(`game:${this.gameDef.slug}:action`, {
|
||||
roomId: this.room.id,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import BaseGame from '../BaseGame.js';
|
||||
|
||||
// Base for casino games (Blackjack, Texas Hold 'Em, ...).
|
||||
//
|
||||
// Casino games differ from tabletop games in two important ways:
|
||||
// - Hidden information per seat (your hand vs. opponents').
|
||||
// - A betting/round structure separate from board moves.
|
||||
//
|
||||
// Subclasses typically need:
|
||||
// - createTable() layout: felt, seats, chip stacks, dealer area
|
||||
// - onState(state) reconcile public state (community cards, pot, ...)
|
||||
// - onPrivateState(state) reconcile your private hand
|
||||
// - bet(amount) sendAction({ type: 'bet', amount })
|
||||
// - fold/check/call/raise sendAction with matching type
|
||||
export default class CasinoGame extends BaseGame {
|
||||
constructor(key) {
|
||||
super(key);
|
||||
this.pot = 0;
|
||||
this.round = null;
|
||||
this.privateState = null;
|
||||
}
|
||||
|
||||
bindNetwork() {
|
||||
super.bindNetwork();
|
||||
if (!this.socket) return;
|
||||
const slug = this.gameDef.slug;
|
||||
this.socket.on(`game:${slug}:private`, this.handlePrivate);
|
||||
this.events.once('shutdown', () => {
|
||||
this.socket.off(`game:${slug}:private`, this.handlePrivate);
|
||||
});
|
||||
}
|
||||
|
||||
handlePrivate = (state) => {
|
||||
this.privateState = state;
|
||||
this.onPrivateState?.(state);
|
||||
};
|
||||
|
||||
bet(amount) { this.sendAction({ type: 'bet', amount }); }
|
||||
fold() { this.sendAction({ type: 'fold' }); }
|
||||
check() { this.sendAction({ type: 'check' }); }
|
||||
call() { this.sendAction({ type: 'call' }); }
|
||||
raise(amt) { this.sendAction({ type: 'raise', amount: amt }); }
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import BaseGame from '../BaseGame.js';
|
||||
|
||||
// Base for turn-based tabletop games (Backgammon, Parchisi, Chess, ...).
|
||||
//
|
||||
// Subclasses typically need:
|
||||
// - createBoard() lay out the board, pieces, dice (placeholder shapes ok)
|
||||
// - onState(state) reconcile pieces, current turn, dice rolls
|
||||
// - requestMove(move) call sendAction({ type: 'move', ...move })
|
||||
//
|
||||
// The framework here provides the turn loop scaffolding: who's on move, a
|
||||
// pending-move queue, and a helper to lock input when it's not your turn.
|
||||
export default class TabletopGame extends BaseGame {
|
||||
constructor(key) {
|
||||
super(key);
|
||||
this.currentTurnUserId = null;
|
||||
this.pendingMove = null;
|
||||
}
|
||||
|
||||
isMyTurn() {
|
||||
return this.localUser && this.currentTurnUserId === this.localUser.id;
|
||||
}
|
||||
|
||||
onState(state) {
|
||||
this.currentTurnUserId = state.currentTurnUserId ?? null;
|
||||
this.applyBoardState?.(state);
|
||||
}
|
||||
|
||||
requestMove(move) {
|
||||
if (!this.isMyTurn()) return false;
|
||||
this.pendingMove = move;
|
||||
this.sendAction({ type: 'move', move });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from './config.js';
|
||||
import BootScene from './scenes/BootScene.js';
|
||||
import PreloadScene from './scenes/PreloadScene.js';
|
||||
import LandingScene from './scenes/LandingScene.js';
|
||||
import LoginScene from './scenes/LoginScene.js';
|
||||
import RegisterScene from './scenes/RegisterScene.js';
|
||||
import VerifyScene from './scenes/VerifyScene.js';
|
||||
import ProfileScene from './scenes/ProfileScene.js';
|
||||
import GameMenuScene from './scenes/GameMenuScene.js';
|
||||
import LobbyScene from './scenes/LobbyScene.js';
|
||||
import GameRoomScene from './scenes/GameRoomScene.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'game-container',
|
||||
backgroundColor: COLORS.bgHex,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
width: GAME_WIDTH,
|
||||
height: GAME_HEIGHT,
|
||||
},
|
||||
dom: { createContainer: true },
|
||||
scene: [
|
||||
BootScene,
|
||||
PreloadScene,
|
||||
LandingScene,
|
||||
LoginScene,
|
||||
RegisterScene,
|
||||
VerifyScene,
|
||||
ProfileScene,
|
||||
GameMenuScene,
|
||||
LobbyScene,
|
||||
GameRoomScene,
|
||||
],
|
||||
};
|
||||
|
||||
new Phaser.Game(config);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import Phaser from 'phaser';
|
||||
|
||||
export default class BootScene extends Phaser.Scene {
|
||||
constructor() { super('Boot'); }
|
||||
create() { this.scene.start('Preload'); }
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
constructor() { super('GameMenu'); }
|
||||
|
||||
async create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 120, 'Choose a game', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const loadingText = this.add.text(cx, 220, 'Loading game list…', {
|
||||
fontSize: '24px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
let games = [];
|
||||
try {
|
||||
const res = await api.get('/games');
|
||||
games = res.games ?? [];
|
||||
} catch (err) {
|
||||
loadingText.setText(`Failed to load games: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
loadingText.destroy();
|
||||
|
||||
const tabletop = games.filter((g) => g.category === 'tabletop');
|
||||
const casino = games.filter((g) => g.category === 'casino');
|
||||
|
||||
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
|
||||
this.renderColumn('Casino', casino, cx + 420, 260);
|
||||
|
||||
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
renderColumn(title, games, x, y) {
|
||||
this.add.text(x, y, title, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '40px',
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
games.forEach((game, i) => {
|
||||
const btn = new Button(this, x, y + 80 + i * 90, game.name, () => this.openGame(game), {
|
||||
width: 360,
|
||||
});
|
||||
void btn;
|
||||
});
|
||||
}
|
||||
|
||||
openGame(game) {
|
||||
if (!auth.user && game.supportsMultiplayer) {
|
||||
this.scene.start('Login');
|
||||
return;
|
||||
}
|
||||
if (game.supportsMultiplayer) {
|
||||
this.scene.start('Lobby', { game });
|
||||
} else {
|
||||
this.scene.start('GameRoom', { game });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { getSocket } from '../services/socket.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
||||
// Generic room shell. A concrete game (Backgammon, Blackjack, etc.) will
|
||||
// later be instantiated here based on `data.game.slug`, taking over the
|
||||
// rendering and input handling.
|
||||
export default class GameRoomScene extends Phaser.Scene {
|
||||
constructor() { super('GameRoom'); }
|
||||
|
||||
init(data) {
|
||||
this.game = data.game;
|
||||
this.room = data.room ?? null;
|
||||
}
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 80, `${this.game.name}`, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(cx, 150, this.room
|
||||
? `Table: ${this.room.name}`
|
||||
: 'Single-player table',
|
||||
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
|
||||
|
||||
// Placeholder table felt
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2 + 40, 1400, 700, 0x14532d).setStrokeStyle(4, COLORS.accent);
|
||||
this.add.text(cx, GAME_HEIGHT / 2 + 40, `${this.game.name} board placeholder\n(game logic plugs in here)`, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '28px',
|
||||
color: COLORS.mutedHex,
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.seatsText = this.add.text(cx, GAME_HEIGHT - 220, this.formatSeats(this.room), {
|
||||
fontSize: '22px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.leave());
|
||||
|
||||
if (this.room) {
|
||||
const socket = getSocket();
|
||||
socket.on('room:update', this.onRoomUpdate = (room) => {
|
||||
if (room.id !== this.room.id) return;
|
||||
this.room = room;
|
||||
this.seatsText.setText(this.formatSeats(room));
|
||||
});
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
socket.off('room:update', this.onRoomUpdate);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatSeats(room) {
|
||||
if (!room) return 'Solo session';
|
||||
const seats = room.players.map((p) => `Seat ${p.seat + 1}: ${p.username}`).join(' · ');
|
||||
return `${room.players.length}/${room.maxPlayers} players\n${seats}`;
|
||||
}
|
||||
|
||||
leave() {
|
||||
const socket = getSocket();
|
||||
if (this.room) socket.emit('room:leave', { roomId: this.room.id }, () => {});
|
||||
this.scene.start('GameMenu');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
||||
export default class LandingScene extends Phaser.Scene {
|
||||
constructor() { super('Landing'); }
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg);
|
||||
|
||||
this.add.text(cx, 220, 'Fertig Classic Games', {
|
||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||
fontSize: '96px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(cx, 320, 'Cards, dice, and classic tables.', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '32px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.renderButtons();
|
||||
|
||||
auth.subscribe(() => {
|
||||
if (!this.scene.isActive('Landing')) return;
|
||||
this.children.removeAll();
|
||||
this.create();
|
||||
});
|
||||
}
|
||||
|
||||
renderButtons() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const user = auth.user;
|
||||
|
||||
if (user) {
|
||||
this.add.text(cx, 480, `Welcome back, ${user.username}`, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '36px',
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
if (!user.emailVerified) {
|
||||
this.add.text(cx, 540, 'Email not yet verified — check the server console for the link in dev.', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '22px',
|
||||
color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
new Button(this, cx, 660, 'Play', () => this.scene.start('GameMenu'));
|
||||
new Button(this, cx, 740, 'Profile', () => this.scene.start('Profile'));
|
||||
new Button(this, cx, 820, 'Sign out', async () => {
|
||||
await auth.logout();
|
||||
}, { variant: 'ghost' });
|
||||
} else {
|
||||
new Button(this, cx, 540, 'Sign in', () => this.scene.start('Login'));
|
||||
new Button(this, cx, 620, 'Create account', () => this.scene.start('Register'));
|
||||
new Button(this, cx, 700, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { connectSocket, getSocket } from '../services/socket.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Modal } from '../ui/Modal.js';
|
||||
|
||||
export default class LobbyScene extends Phaser.Scene {
|
||||
constructor() { super('Lobby'); }
|
||||
|
||||
init(data) { this.game = data.game; }
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 100, `${this.game.name} — Lobby`, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.listText = this.add.text(cx, 220, 'Connecting…', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '24px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.roomContainer = this.add.container(0, 280);
|
||||
|
||||
new Button(this, cx - 220, GAME_HEIGHT - 120, 'Create table', () => this.createRoom());
|
||||
new Button(this, cx + 220, GAME_HEIGHT - 120, 'Back', () => this.exitLobby(), { variant: 'ghost' });
|
||||
|
||||
const socket = connectSocket();
|
||||
socket.on('connect_error', (err) => this.listText.setText(`Socket error: ${err.message}`));
|
||||
socket.on('connect', () => {
|
||||
this.listText.setText('No tables yet. Create one to start.');
|
||||
socket.emit('lobby:subscribe', this.game.slug);
|
||||
});
|
||||
socket.on('lobby:update', (rooms) => this.renderRooms(rooms));
|
||||
}
|
||||
|
||||
exitLobby() {
|
||||
const socket = getSocket();
|
||||
socket.emit('lobby:unsubscribe', this.game.slug);
|
||||
this.scene.start('GameMenu');
|
||||
}
|
||||
|
||||
renderRooms(rooms) {
|
||||
this.roomContainer.removeAll(true);
|
||||
if (!rooms.length) {
|
||||
this.listText.setText('No tables yet. Create one to start.');
|
||||
return;
|
||||
}
|
||||
this.listText.setText(`${rooms.length} table(s) open`);
|
||||
const cx = GAME_WIDTH / 2;
|
||||
rooms.forEach((room, i) => {
|
||||
const y = i * 90;
|
||||
const bg = this.add.rectangle(cx, y, 1200, 70, COLORS.panel).setStrokeStyle(1, COLORS.accent);
|
||||
const label = this.add.text(cx - 580, y, `${room.name} · ${room.players.length}/${room.maxPlayers}`, {
|
||||
fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
const joinBtn = new Button(this, cx + 500, y, 'Join', () => this.joinRoom(room.id), { width: 140, height: 52, fontSize: 22 });
|
||||
this.roomContainer.add([bg, label, joinBtn]);
|
||||
});
|
||||
}
|
||||
|
||||
createRoom() {
|
||||
const socket = getSocket();
|
||||
socket.emit('room:create', { gameSlug: this.game.slug }, (resp) => {
|
||||
if (!resp?.ok) {
|
||||
new Modal(this, resp?.error ?? 'Could not create table.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
return;
|
||||
}
|
||||
this.scene.start('GameRoom', { game: this.game, room: resp.room });
|
||||
});
|
||||
}
|
||||
|
||||
joinRoom(roomId) {
|
||||
const socket = getSocket();
|
||||
socket.emit('room:join', { roomId }, (resp) => {
|
||||
if (!resp?.ok) {
|
||||
new Modal(this, resp?.error ?? 'Could not join.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
return;
|
||||
}
|
||||
this.scene.start('GameRoom', { game: this.game, room: resp.room });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { TextInput } from '../ui/TextInput.js';
|
||||
import { Modal } from '../ui/Modal.js';
|
||||
|
||||
export default class LoginScene extends Phaser.Scene {
|
||||
constructor() { super('Login'); }
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 200, 'Sign in', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(cx - 180, 340, 'Email or username', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const identifierInput = new TextInput(this, cx, 380, { width: 480, placeholder: 'you@example.com', autocomplete: 'username' });
|
||||
|
||||
this.add.text(cx - 180, 460, 'Password', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const passwordInput = new TextInput(this, cx, 500, { width: 480, type: 'password', autocomplete: 'current-password' });
|
||||
|
||||
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit));
|
||||
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost' });
|
||||
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
|
||||
this.input.keyboard.on('keydown-ENTER', () => this.attemptLogin(identifierInput, passwordInput, submit));
|
||||
}
|
||||
|
||||
async attemptLogin(identifierInput, passwordInput, button) {
|
||||
const identifier = identifierInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
if (!identifier || !password) {
|
||||
new Modal(this, 'Enter your username/email and password.', { color: COLORS.dangerHex, autoCloseMs: 2000 });
|
||||
return;
|
||||
}
|
||||
button.setEnabled(false);
|
||||
try {
|
||||
await auth.login(identifier, password);
|
||||
this.scene.start('Landing');
|
||||
} catch (err) {
|
||||
new Modal(this, err.message ?? 'Sign in failed.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
} finally {
|
||||
button.setEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
|
||||
export default class PreloadScene extends Phaser.Scene {
|
||||
constructor() { super('Preload'); }
|
||||
|
||||
preload() {
|
||||
const w = GAME_WIDTH;
|
||||
const h = GAME_HEIGHT;
|
||||
const barWidth = 600;
|
||||
const bg = this.add.rectangle(w / 2, h / 2, barWidth + 8, 28, COLORS.panel)
|
||||
.setStrokeStyle(2, COLORS.accent);
|
||||
const bar = this.add.rectangle(w / 2 - barWidth / 2, h / 2, 0, 20, COLORS.accent)
|
||||
.setOrigin(0, 0.5);
|
||||
this.add.text(w / 2, h / 2 - 60, 'Loading…', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '32px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.load.on('progress', (p) => bar.width = barWidth * p);
|
||||
this.load.on('complete', () => { bg.destroy(); bar.destroy(); });
|
||||
|
||||
// Placeholder asset slot — drop sprite files here later, no asset is
|
||||
// required for the framework to boot.
|
||||
}
|
||||
|
||||
async create() {
|
||||
await auth.refresh();
|
||||
this.scene.start('Landing');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { TextInput } from '../ui/TextInput.js';
|
||||
import { Modal } from '../ui/Modal.js';
|
||||
|
||||
export default class ProfileScene extends Phaser.Scene {
|
||||
constructor() { super('Profile'); }
|
||||
|
||||
async create() {
|
||||
if (!auth.user) {
|
||||
this.scene.start('Login');
|
||||
return;
|
||||
}
|
||||
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 140, 'Profile', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.statusText = this.add.text(cx, 220, 'Loading profile…', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '24px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
let profile;
|
||||
try {
|
||||
const { profile: p } = await api.get('/profile');
|
||||
profile = p;
|
||||
} catch (err) {
|
||||
this.statusText.setText(err.message);
|
||||
return;
|
||||
}
|
||||
this.statusText.destroy();
|
||||
|
||||
// Avatar slot (placeholder vector)
|
||||
const avatarX = cx - 480;
|
||||
const avatarY = 380;
|
||||
const avatarBg = this.add.circle(avatarX, avatarY, 96, COLORS.panel).setStrokeStyle(3, COLORS.accent);
|
||||
const initial = (profile.displayName ?? profile.username ?? '?').charAt(0).toUpperCase();
|
||||
this.add.text(avatarX, avatarY, initial, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '72px',
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5);
|
||||
void avatarBg;
|
||||
|
||||
if (profile.avatarPath) {
|
||||
this.load.image(`avatar-${profile.id}`, profile.avatarPath);
|
||||
this.load.once('complete', () => {
|
||||
const img = this.add.image(avatarX, avatarY, `avatar-${profile.id}`);
|
||||
img.setDisplaySize(180, 180);
|
||||
});
|
||||
this.load.start();
|
||||
}
|
||||
|
||||
this.add.text(cx - 320, 300, profile.username, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '40px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
|
||||
this.add.text(cx - 320, 350, profile.email, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '24px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
|
||||
if (!profile.emailVerified) {
|
||||
this.add.text(cx - 320, 390, '(Email not verified)', {
|
||||
fontSize: '22px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
}
|
||||
|
||||
// Editable fields
|
||||
this.add.text(cx - 320, 480, 'Display name', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const displayNameInput = new TextInput(this, cx + 80, 520, { width: 600, value: profile.displayName ?? '', maxLength: 60 });
|
||||
|
||||
this.add.text(cx - 320, 600, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const bioInput = new TextInput(this, cx + 80, 660, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
|
||||
|
||||
new Button(this, cx - 200, 820, 'Save profile', async () => {
|
||||
try {
|
||||
const { profile: updated } = await api.patch('/profile', {
|
||||
displayName: displayNameInput.value,
|
||||
bio: bioInput.value,
|
||||
});
|
||||
new Modal(this, 'Profile saved.', { autoCloseMs: 1500 });
|
||||
profile = updated;
|
||||
} catch (err) {
|
||||
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
}
|
||||
});
|
||||
|
||||
new Button(this, cx + 100, 820, 'Upload avatar', () => this.pickAvatar());
|
||||
new Button(this, cx + 400, 820, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
pickAvatar() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/png,image/jpeg,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('avatar', file);
|
||||
try {
|
||||
await api.upload('/profile/avatar', fd);
|
||||
new Modal(this, 'Avatar uploaded. Reopening…', { autoCloseMs: 1200 });
|
||||
this.time.delayedCall(1200, () => this.scene.restart());
|
||||
} catch (err) {
|
||||
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { TextInput } from '../ui/TextInput.js';
|
||||
import { Modal } from '../ui/Modal.js';
|
||||
|
||||
export default class RegisterScene extends Phaser.Scene {
|
||||
constructor() { super('Register'); }
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 180, 'Create account', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(cx - 180, 300, 'Email', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const emailInput = new TextInput(this, cx, 340, { width: 480, type: 'email', autocomplete: 'email' });
|
||||
|
||||
this.add.text(cx - 180, 420, 'Username', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const usernameInput = new TextInput(this, cx, 460, { width: 480, autocomplete: 'username' });
|
||||
|
||||
this.add.text(cx - 180, 540, 'Password (min 8 chars)', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const passwordInput = new TextInput(this, cx, 580, { width: 480, type: 'password', autocomplete: 'new-password' });
|
||||
|
||||
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
|
||||
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost' });
|
||||
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
|
||||
this.input.keyboard.on('keydown-ENTER', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
|
||||
}
|
||||
|
||||
async attempt(emailInput, usernameInput, passwordInput, button) {
|
||||
button.setEnabled(false);
|
||||
try {
|
||||
const result = await auth.register({
|
||||
email: emailInput.value.trim(),
|
||||
username: usernameInput.value.trim(),
|
||||
password: passwordInput.value,
|
||||
});
|
||||
this.scene.start('Verify', { verification: result.verification });
|
||||
} catch (err) {
|
||||
const messages = err.data?.errors
|
||||
? Object.values(err.data.errors).join('\n')
|
||||
: err.message ?? 'Sign up failed.';
|
||||
new Modal(this, messages, { color: COLORS.dangerHex, autoCloseMs: 3000 });
|
||||
} finally {
|
||||
button.setEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
||||
export default class VerifyScene extends Phaser.Scene {
|
||||
constructor() { super('Verify'); }
|
||||
|
||||
init(data) {
|
||||
this.verification = data?.verification ?? { sent: false, devLink: null };
|
||||
}
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 220, 'Verify your email', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const body = this.verification.sent
|
||||
? 'We sent a verification link to your email. Click the link to confirm your account.'
|
||||
: 'SMTP is not configured. Use the dev link below to verify your email.';
|
||||
|
||||
this.add.text(cx, 360, body, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '26px',
|
||||
color: COLORS.mutedHex,
|
||||
wordWrap: { width: 1200 },
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
if (this.verification.devLink) {
|
||||
this.add.text(cx, 470, this.verification.devLink, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '22px',
|
||||
color: COLORS.accentHex,
|
||||
wordWrap: { width: 1400 },
|
||||
align: 'center',
|
||||
}).setOrigin(0.5).setInteractive({ useHandCursor: true })
|
||||
.on('pointerup', () => window.open(this.verification.devLink, '_blank'));
|
||||
}
|
||||
|
||||
new Button(this, cx, 640, 'I have verified — refresh', async () => {
|
||||
await auth.refresh();
|
||||
this.scene.start('Landing');
|
||||
});
|
||||
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { API_BASE } from '../config.js';
|
||||
|
||||
async function request(method, path, { body, formData } = {}) {
|
||||
const init = { method, credentials: 'same-origin', headers: {} };
|
||||
if (formData) {
|
||||
init.body = formData;
|
||||
} else if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${path}`, init);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error ?? `HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, { body }),
|
||||
patch: (path, body) => request('PATCH', path, { body }),
|
||||
delete: (path) => request('DELETE', path),
|
||||
upload: (path, formData) => request('POST', path, { formData }),
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { api } from './api.js';
|
||||
|
||||
class AuthStore {
|
||||
constructor() {
|
||||
this.user = null;
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
subscribe(fn) {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
emit() {
|
||||
for (const fn of this.listeners) fn(this.user);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const { user } = await api.get('/auth/me');
|
||||
this.user = user;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
this.emit();
|
||||
return this.user;
|
||||
}
|
||||
|
||||
async login(identifier, password) {
|
||||
const { user } = await api.post('/auth/login', { identifier, password });
|
||||
this.user = user;
|
||||
this.emit();
|
||||
return user;
|
||||
}
|
||||
|
||||
async register({ email, username, password }) {
|
||||
const result = await api.post('/auth/register', { email, username, password });
|
||||
this.user = result.user;
|
||||
this.emit();
|
||||
return result;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try { await api.post('/auth/logout'); } catch { /* ignore */ }
|
||||
this.user = null;
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { io } from 'socket.io-client';
|
||||
|
||||
let socket = null;
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
socket = io({ withCredentials: true, autoConnect: false });
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
const s = getSocket();
|
||||
if (!s.connected) s.connect();
|
||||
return s;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket?.connected) socket.disconnect();
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import Phaser from 'phaser';
|
||||
import { COLORS } from '../config.js';
|
||||
|
||||
export class Button extends Phaser.GameObjects.Container {
|
||||
constructor(scene, x, y, label, onClick, options = {}) {
|
||||
super(scene, x, y);
|
||||
const {
|
||||
width = 280,
|
||||
height = 64,
|
||||
bg = COLORS.panel,
|
||||
bgHover = COLORS.accent,
|
||||
textColor = COLORS.textHex,
|
||||
fontSize = 28,
|
||||
variant = 'solid',
|
||||
} = options;
|
||||
|
||||
this.options = { width, height, bg, bgHover, textColor, fontSize, variant };
|
||||
|
||||
this.bgRect = scene.add.rectangle(0, 0, width, height, bg, variant === 'ghost' ? 0 : 1);
|
||||
this.bgRect.setStrokeStyle(2, COLORS.accent, 1);
|
||||
|
||||
this.text = scene.add.text(0, 0, label, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: `${fontSize}px`,
|
||||
color: textColor,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add([this.bgRect, this.text]);
|
||||
|
||||
this.setSize(width, height);
|
||||
this.setInteractive({ useHandCursor: true });
|
||||
this.on('pointerover', () => this.bgRect.setFillStyle(bgHover, 1));
|
||||
this.on('pointerout', () => this.bgRect.setFillStyle(bg, variant === 'ghost' ? 0 : 1));
|
||||
this.on('pointerdown', () => this.bgRect.setScale(0.97));
|
||||
this.on('pointerup', () => this.bgRect.setScale(1));
|
||||
this.on('pointerupoutside', () => this.bgRect.setScale(1));
|
||||
if (onClick) this.on('pointerup', onClick);
|
||||
|
||||
scene.add.existing(this);
|
||||
}
|
||||
|
||||
setLabel(label) {
|
||||
this.text.setText(label);
|
||||
return this;
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
this.setAlpha(enabled ? 1 : 0.5);
|
||||
if (enabled) this.setInteractive({ useHandCursor: true });
|
||||
else this.disableInteractive();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
|
||||
export class Modal extends Phaser.GameObjects.Container {
|
||||
constructor(scene, message, options = {}) {
|
||||
super(scene, 0, 0);
|
||||
const overlay = scene.add.rectangle(
|
||||
GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6,
|
||||
).setInteractive();
|
||||
const panel = scene.add.rectangle(
|
||||
GAME_WIDTH / 2, GAME_HEIGHT / 2, 720, 280, COLORS.panel, 1,
|
||||
).setStrokeStyle(2, COLORS.accent);
|
||||
const text = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 30, message, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '28px',
|
||||
color: options.color ?? COLORS.textHex,
|
||||
wordWrap: { width: 660 },
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
this.add([overlay, panel, text]);
|
||||
scene.add.existing(this);
|
||||
|
||||
if (options.autoCloseMs) {
|
||||
scene.time.delayedCall(options.autoCloseMs, () => this.destroy());
|
||||
} else {
|
||||
overlay.on('pointerdown', () => this.destroy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import Phaser from 'phaser';
|
||||
|
||||
// DOM-overlay text input. Positions a real <input> element above the canvas
|
||||
// using the scene's scale so it lines up with where you'd draw it in Phaser.
|
||||
export class TextInput {
|
||||
constructor(scene, x, y, options = {}) {
|
||||
this.scene = scene;
|
||||
this.gameX = x;
|
||||
this.gameY = y;
|
||||
this.width = options.width ?? 360;
|
||||
this.height = options.height ?? 48;
|
||||
this.layer = document.getElementById('dom-layer');
|
||||
|
||||
const isTextarea = options.multiline === true;
|
||||
this.el = document.createElement(isTextarea ? 'textarea' : 'input');
|
||||
if (!isTextarea) this.el.type = options.type ?? 'text';
|
||||
if (options.placeholder) this.el.placeholder = options.placeholder;
|
||||
if (options.value !== undefined) this.el.value = options.value;
|
||||
if (options.maxLength) this.el.maxLength = options.maxLength;
|
||||
if (options.autocomplete) this.el.autocomplete = options.autocomplete;
|
||||
this.el.style.position = 'absolute';
|
||||
this.el.style.boxSizing = 'border-box';
|
||||
this.layer.appendChild(this.el);
|
||||
|
||||
this.reposition = this.reposition.bind(this);
|
||||
this.scene.scale.on('resize', this.reposition);
|
||||
this.reposition();
|
||||
|
||||
this.scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.destroy());
|
||||
this.scene.events.once(Phaser.Scenes.Events.DESTROY, () => this.destroy());
|
||||
}
|
||||
|
||||
reposition() {
|
||||
const cam = this.scene.cameras.main;
|
||||
const zoom = this.scene.scale.displayScale; // {x, y} canvas-to-css scale
|
||||
const canvas = this.scene.scale.canvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cssX = rect.left + (this.gameX - this.width / 2) / zoom.x;
|
||||
const cssY = rect.top + (this.gameY - this.height / 2) / zoom.y;
|
||||
const cssW = this.width / zoom.x;
|
||||
const cssH = this.height / zoom.y;
|
||||
this.el.style.left = `${cssX}px`;
|
||||
this.el.style.top = `${cssY}px`;
|
||||
this.el.style.width = `${cssW}px`;
|
||||
this.el.style.height = `${cssH}px`;
|
||||
this.el.style.fontSize = `${20 / zoom.y}px`;
|
||||
// Avoid unused-variable lint complaints
|
||||
void cam;
|
||||
}
|
||||
|
||||
get value() { return this.el.value; }
|
||||
set value(v) { this.el.value = v; }
|
||||
|
||||
focus() { this.el.focus(); }
|
||||
|
||||
on(event, handler) {
|
||||
this.el.addEventListener(event, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.scene.scale.off('resize', this.reposition);
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #0a0e14;
|
||||
color: #e6edf3;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#game-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#game-container canvas {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* DOM overlay used by UI components (text inputs, file pickers) positioned
|
||||
absolutely above the canvas. Pointer events are enabled per-element. */
|
||||
#dom-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#dom-layer * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#dom-layer input[type="text"],
|
||||
#dom-layer input[type="email"],
|
||||
#dom-layer input[type="password"],
|
||||
#dom-layer textarea {
|
||||
font: inherit;
|
||||
background: rgba(15, 23, 36, 0.95);
|
||||
color: #e6edf3;
|
||||
border: 1px solid #2c3a4d;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#dom-layer input:focus,
|
||||
#dom-layer textarea:focus {
|
||||
border-color: #5aa9e6;
|
||||
box-shadow: 0 0 0 2px rgba(90, 169, 230, 0.3);
|
||||
}
|
||||
|
||||
#dom-layer button {
|
||||
font: inherit;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import config from '../config.js';
|
||||
import { findSessionUser } from './service.js';
|
||||
|
||||
export function loadUser(req, _res, next) {
|
||||
const sid = req.cookies?.[config.auth.cookieName];
|
||||
req.user = findSessionUser(sid);
|
||||
req.sessionId = sid ?? null;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
next();
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Router } from 'express';
|
||||
import config from '../config.js';
|
||||
import { sendVerificationEmail } from '../email/mailer.js';
|
||||
import {
|
||||
consumeVerificationToken,
|
||||
createSession,
|
||||
createUser,
|
||||
destroySession,
|
||||
validateRegistration,
|
||||
verifyPassword,
|
||||
} from './service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function setSessionCookie(res, session) {
|
||||
res.cookie(config.auth.cookieName, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: config.env === 'production',
|
||||
expires: new Date(session.expiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
const { email, username, password } = req.body ?? {};
|
||||
const errors = validateRegistration({ email, username, password });
|
||||
if (Object.keys(errors).length) return res.status(400).json({ errors });
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await createUser({ email, username, password });
|
||||
} catch (err) {
|
||||
if (err.code === 'EMAIL_TAKEN') return res.status(409).json({ errors: { email: err.message } });
|
||||
if (err.code === 'USERNAME_TAKEN') return res.status(409).json({ errors: { username: err.message } });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const link = `${config.baseUrl}/api/auth/verify?token=${user.verificationToken}`;
|
||||
const mailResult = await sendVerificationEmail(user.email, link);
|
||||
|
||||
const session = createSession(user.id);
|
||||
setSessionCookie(res, session);
|
||||
|
||||
res.status(201).json({
|
||||
user: { id: user.id, email: user.email, username: user.username, emailVerified: false },
|
||||
verification: { sent: mailResult.delivered, devLink: mailResult.devLogged ? link : null },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { identifier, password } = req.body ?? {};
|
||||
if (!identifier || !password) return res.status(400).json({ error: 'Missing credentials.' });
|
||||
|
||||
const user = await verifyPassword(identifier, password);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials.' });
|
||||
|
||||
const session = createSession(user.id);
|
||||
setSessionCookie(res, session);
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
emailVerified: !!user.email_verified,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
destroySession(req.sessionId);
|
||||
res.clearCookie(config.auth.cookieName);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
if (!req.user) return res.json({ user: null });
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
router.get('/verify', (req, res) => {
|
||||
const token = String(req.query.token ?? '');
|
||||
const userId = consumeVerificationToken(token);
|
||||
if (!userId) {
|
||||
return res.status(400).send('<h1>Verification failed</h1><p>Link invalid or expired.</p>');
|
||||
}
|
||||
res.send('<h1>Email verified</h1><p>You can close this tab and return to the game.</p>');
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import crypto from 'node:crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import db from '../db/index.js';
|
||||
import config from '../config.js';
|
||||
|
||||
const EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const USERNAME_RX = /^[a-zA-Z0-9_]{3,24}$/;
|
||||
|
||||
export function validateRegistration({ email, username, password }) {
|
||||
const errors = {};
|
||||
if (!email || !EMAIL_RX.test(email)) errors.email = 'Invalid email address.';
|
||||
if (!username || !USERNAME_RX.test(username)) {
|
||||
errors.username = 'Username must be 3-24 chars (letters, digits, underscore).';
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters.';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function createUser({ email, username, password }) {
|
||||
const lowerEmail = email.toLowerCase();
|
||||
const existing = db
|
||||
.prepare('SELECT id FROM users WHERE email = ? OR username = ?')
|
||||
.get(lowerEmail, username);
|
||||
if (existing) {
|
||||
const conflict = db
|
||||
.prepare('SELECT email, username FROM users WHERE id = ?')
|
||||
.get(existing.id);
|
||||
if (conflict.email === lowerEmail) {
|
||||
throw Object.assign(new Error('Email already in use.'), { code: 'EMAIL_TAKEN' });
|
||||
}
|
||||
throw Object.assign(new Error('Username already in use.'), { code: 'USERNAME_TAKEN' });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(
|
||||
Date.now() + config.email.verificationTtlHours * 3600 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO users (email, username, password_hash, verification_token, verification_expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(lowerEmail, username, hash, token, expires);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO profiles (user_id, display_name) VALUES (?, ?)',
|
||||
).run(info.lastInsertRowid, username);
|
||||
|
||||
return {
|
||||
id: info.lastInsertRowid,
|
||||
email: lowerEmail,
|
||||
username,
|
||||
verificationToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(identifier, password) {
|
||||
const ident = identifier.toLowerCase();
|
||||
const user = db
|
||||
.prepare(
|
||||
'SELECT id, email, username, password_hash, email_verified FROM users WHERE email = ? OR LOWER(username) = ?',
|
||||
)
|
||||
.get(ident, ident);
|
||||
if (!user) return null;
|
||||
const ok = await bcrypt.compare(password, user.password_hash);
|
||||
if (!ok) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createSession(userId) {
|
||||
const id = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(
|
||||
Date.now() + config.auth.sessionTtlDays * 86400 * 1000,
|
||||
).toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)',
|
||||
).run(id, userId, expires);
|
||||
return { id, expiresAt: expires };
|
||||
}
|
||||
|
||||
export function destroySession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
||||
}
|
||||
|
||||
export function findSessionUser(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.email, u.username, u.email_verified, s.expires_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ?`,
|
||||
)
|
||||
.get(sessionId);
|
||||
if (!row) return null;
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||
destroySession(sessionId);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
username: row.username,
|
||||
emailVerified: !!row.email_verified,
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeVerificationToken(token) {
|
||||
const user = db
|
||||
.prepare(
|
||||
`SELECT id, verification_expires_at FROM users
|
||||
WHERE verification_token = ? AND email_verified = 0`,
|
||||
)
|
||||
.get(token);
|
||||
if (!user) return null;
|
||||
if (new Date(user.verification_expires_at).getTime() < Date.now()) return null;
|
||||
db.prepare(
|
||||
`UPDATE users SET email_verified = 1, verification_token = NULL,
|
||||
verification_expires_at = NULL WHERE id = ?`,
|
||||
).run(user.id);
|
||||
return user.id;
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function bool(v, fallback = false) {
|
||||
if (v === undefined || v === '') return fallback;
|
||||
return /^(1|true|yes|on)$/i.test(v);
|
||||
}
|
||||
|
||||
function int(v, fallback) {
|
||||
const n = Number.parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function resolveFromRoot(p) {
|
||||
return path.isAbsolute(p) ? p : path.resolve(ROOT, p);
|
||||
}
|
||||
|
||||
const config = {
|
||||
root: ROOT,
|
||||
env: process.env.NODE_ENV ?? 'development',
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
port: int(process.env.PORT, 3000),
|
||||
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
|
||||
logLevel: process.env.LOG_LEVEL ?? 'info',
|
||||
|
||||
db: {
|
||||
path: resolveFromRoot(process.env.DB_PATH ?? './data/fertig.sqlite'),
|
||||
migrationsDir: path.join(__dirname, 'db', 'migrations'),
|
||||
},
|
||||
|
||||
auth: {
|
||||
sessionSecret: process.env.SESSION_SECRET ?? '',
|
||||
cookieName: process.env.SESSION_COOKIE_NAME ?? 'fcg_sid',
|
||||
sessionTtlDays: int(process.env.SESSION_TTL_DAYS, 30),
|
||||
bcryptRounds: int(process.env.BCRYPT_ROUNDS, 12),
|
||||
},
|
||||
|
||||
uploads: {
|
||||
dir: resolveFromRoot(process.env.UPLOAD_DIR ?? './public/uploads'),
|
||||
maxSizeMb: int(process.env.MAX_UPLOAD_SIZE_MB, 5),
|
||||
allowedMime: (process.env.ALLOWED_UPLOAD_MIME ?? 'image/png,image/jpeg,image/webp')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
|
||||
email: {
|
||||
host: process.env.SMTP_HOST ?? '',
|
||||
port: int(process.env.SMTP_PORT, 587),
|
||||
secure: bool(process.env.SMTP_SECURE, false),
|
||||
user: process.env.SMTP_USER ?? '',
|
||||
pass: process.env.SMTP_PASS ?? '',
|
||||
from: process.env.SMTP_FROM ?? 'Fertig Classic Games <no-reply@localhost>',
|
||||
verificationTtlHours: int(process.env.VERIFICATION_TOKEN_TTL_HOURS, 24),
|
||||
},
|
||||
|
||||
socket: {
|
||||
corsOrigin: process.env.SOCKET_IO_CORS_ORIGIN ?? 'http://localhost:3000',
|
||||
},
|
||||
|
||||
publicDir: path.join(ROOT, 'public'),
|
||||
};
|
||||
|
||||
if (!config.auth.sessionSecret) {
|
||||
if (config.env === 'production') {
|
||||
throw new Error('SESSION_SECRET must be set in production');
|
||||
}
|
||||
console.warn('[config] SESSION_SECRET is empty — using an insecure dev default');
|
||||
config.auth.sessionSecret = 'dev-insecure-secret-do-not-use-in-production';
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import config from '../config.js';
|
||||
|
||||
fs.mkdirSync(path.dirname(config.db.path), { recursive: true });
|
||||
|
||||
const db = new Database(config.db.path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
export default db;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import db from './index.js';
|
||||
import config from '../config.js';
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const applied = new Set(
|
||||
db.prepare('SELECT name FROM schema_migrations').all().map((r) => r.name),
|
||||
);
|
||||
|
||||
const files = fs
|
||||
.readdirSync(config.db.migrationsDir)
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
let ran = 0;
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = fs.readFileSync(path.join(config.db.migrationsDir, file), 'utf8');
|
||||
const tx = db.transaction(() => {
|
||||
db.exec(sql);
|
||||
db.prepare('INSERT INTO schema_migrations (name) VALUES (?)').run(file);
|
||||
});
|
||||
tx();
|
||||
console.log(`[migrate] applied ${file}`);
|
||||
ran += 1;
|
||||
}
|
||||
|
||||
if (ran === 0) console.log('[migrate] nothing to do');
|
||||
else console.log(`[migrate] applied ${ran} migration(s)`);
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE profiles (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
display_name TEXT,
|
||||
avatar_path TEXT,
|
||||
bio TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('tabletop', 'casino')),
|
||||
max_players INTEGER NOT NULL DEFAULT 2,
|
||||
supports_multiplayer INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id INTEGER NOT NULL REFERENCES games(id),
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
ended_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress', 'completed', 'abandoned'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_matches_game ON matches(game_id);
|
||||
CREATE INDEX idx_matches_status ON matches(status);
|
||||
|
||||
CREATE TABLE match_players (
|
||||
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
seat INTEGER NOT NULL,
|
||||
result TEXT CHECK (result IN ('win', 'loss', 'draw', 'abandoned')),
|
||||
score INTEGER,
|
||||
PRIMARY KEY (match_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_match_players_user ON match_players(user_id);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import config from '../config.js';
|
||||
|
||||
let transporter = null;
|
||||
|
||||
if (config.email.host) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
secure: config.email.secure,
|
||||
auth: config.email.user
|
||||
? { user: config.email.user, pass: config.email.pass }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(to, link) {
|
||||
const subject = 'Verify your Fertig Classic Games account';
|
||||
const text = `Welcome! Confirm your email by visiting:\n\n${link}\n\nThis link expires in ${config.email.verificationTtlHours} hour(s).`;
|
||||
const html = `<p>Welcome!</p><p>Confirm your email by clicking the link below:</p><p><a href="${link}">${link}</a></p><p>This link expires in ${config.email.verificationTtlHours} hour(s).</p>`;
|
||||
|
||||
if (!transporter) {
|
||||
console.log(`\n[mailer:dev] Verification link for ${to}:\n ${link}\n`);
|
||||
return { delivered: false, devLogged: true };
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
return { delivered: true, devLogged: false };
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT m.id AS match_id, g.slug, g.name, g.category,
|
||||
m.started_at, m.ended_at, m.status,
|
||||
mp.seat, mp.result, mp.score
|
||||
FROM match_players mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
JOIN games g ON g.id = m.game_id
|
||||
WHERE mp.user_id = ?
|
||||
ORDER BY m.started_at DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
.all(req.user.id);
|
||||
|
||||
const summary = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END) AS wins,
|
||||
SUM(CASE WHEN result = 'loss' THEN 1 ELSE 0 END) AS losses,
|
||||
SUM(CASE WHEN result = 'draw' THEN 1 ELSE 0 END) AS draws
|
||||
FROM match_players WHERE user_id = ?`,
|
||||
)
|
||||
.get(req.user.id) ?? { wins: 0, losses: 0, draws: 0 };
|
||||
|
||||
res.json({ matches: rows, summary });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import config from './config.js';
|
||||
import './db/index.js';
|
||||
import { loadUser } from './auth/middleware.js';
|
||||
import authRoutes from './auth/routes.js';
|
||||
import profileRoutes from './profile/routes.js';
|
||||
import historyRoutes from './history/routes.js';
|
||||
import { listGames } from './multiplayer/gameRegistry.js';
|
||||
import { attachMultiplayer } from './multiplayer/index.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(loadUser);
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
app.get('/api/games', (_req, res) => res.json({ games: listGames() }));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/profile', profileRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
app.use(express.static(config.publicDir, { extensions: ['html'] }));
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(config.publicDir, 'index.html'));
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[server]', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
attachMultiplayer(server);
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`[server] listening on http://${config.host}:${config.port}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
const registry = new Map();
|
||||
|
||||
export function registerGame(definition) {
|
||||
if (!definition?.slug) throw new Error('Game definition needs a slug.');
|
||||
registry.set(definition.slug, {
|
||||
slug: definition.slug,
|
||||
name: definition.name ?? definition.slug,
|
||||
category: definition.category ?? 'tabletop',
|
||||
minPlayers: definition.minPlayers ?? 2,
|
||||
maxPlayers: definition.maxPlayers ?? 2,
|
||||
supportsMultiplayer: definition.supportsMultiplayer ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listGames() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
|
||||
export function getGame(slug) {
|
||||
return registry.get(slug) ?? null;
|
||||
}
|
||||
|
||||
// Built-in placeholders so the menu has something to show.
|
||||
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2 });
|
||||
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4 });
|
||||
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', minPlayers: 1, maxPlayers: 6 });
|
||||
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', minPlayers: 2, maxPlayers: 8 });
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import cookie from 'cookie';
|
||||
import config from '../config.js';
|
||||
import { findSessionUser } from '../auth/service.js';
|
||||
import { lobby } from './lobby.js';
|
||||
import { getGame, listGames } from './gameRegistry.js';
|
||||
|
||||
function broadcastRoom(io, room) {
|
||||
io.to(`room:${room.id}`).emit('room:update', room);
|
||||
}
|
||||
|
||||
function broadcastLobby(io, gameSlug) {
|
||||
io.to(`lobby:${gameSlug}`).emit('lobby:update', lobby.list(gameSlug));
|
||||
}
|
||||
|
||||
export function attachMultiplayer(httpServer) {
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: config.socket.corsOrigin, credentials: true },
|
||||
});
|
||||
|
||||
io.use((socket, next) => {
|
||||
const raw = socket.handshake.headers.cookie ?? '';
|
||||
const cookies = cookie.parse(raw || '');
|
||||
const sid = cookies[config.auth.cookieName];
|
||||
const user = findSessionUser(sid);
|
||||
if (!user) return next(new Error('Not authenticated.'));
|
||||
socket.data.user = user;
|
||||
next();
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const { user } = socket.data;
|
||||
|
||||
socket.emit('hello', { user, games: listGames() });
|
||||
|
||||
socket.on('lobby:subscribe', (gameSlug) => {
|
||||
if (!getGame(gameSlug)) return;
|
||||
socket.join(`lobby:${gameSlug}`);
|
||||
socket.emit('lobby:update', lobby.list(gameSlug));
|
||||
});
|
||||
|
||||
socket.on('lobby:unsubscribe', (gameSlug) => {
|
||||
socket.leave(`lobby:${gameSlug}`);
|
||||
});
|
||||
|
||||
socket.on('room:create', ({ gameSlug, name }, ack) => {
|
||||
try {
|
||||
const room = lobby.create({ gameSlug, hostUser: user, name });
|
||||
socket.join(`room:${room.id}`);
|
||||
broadcastLobby(io, gameSlug);
|
||||
ack?.({ ok: true, room });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('room:join', ({ roomId }, ack) => {
|
||||
try {
|
||||
const room = lobby.join(roomId, user);
|
||||
socket.join(`room:${room.id}`);
|
||||
broadcastRoom(io, room);
|
||||
broadcastLobby(io, room.gameSlug);
|
||||
ack?.({ ok: true, room });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('room:leave', ({ roomId }, ack) => {
|
||||
const before = lobby.get(roomId);
|
||||
const room = lobby.leave(roomId, user.id);
|
||||
socket.leave(`room:${roomId}`);
|
||||
if (room) broadcastRoom(io, room);
|
||||
if (before) broadcastLobby(io, before.gameSlug);
|
||||
ack?.({ ok: true });
|
||||
});
|
||||
|
||||
socket.on('room:message', ({ roomId, payload }) => {
|
||||
const room = lobby.get(roomId);
|
||||
if (!room || !room.players.some((p) => p.userId === user.id)) return;
|
||||
io.to(`room:${roomId}`).emit('room:message', {
|
||||
from: { id: user.id, username: user.username },
|
||||
payload,
|
||||
at: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
// Drop the user from any rooms they were in.
|
||||
for (const room of [...lobby.rooms.values()]) {
|
||||
if (room.players.some((p) => p.userId === user.id)) {
|
||||
const updated = lobby.leave(room.id, user.id);
|
||||
if (updated) broadcastRoom(io, updated);
|
||||
broadcastLobby(io, room.gameSlug);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import crypto from 'node:crypto';
|
||||
import { getGame } from './gameRegistry.js';
|
||||
|
||||
export class LobbyManager {
|
||||
constructor() {
|
||||
this.rooms = new Map();
|
||||
}
|
||||
|
||||
list(gameSlug) {
|
||||
return [...this.rooms.values()]
|
||||
.filter((r) => !gameSlug || r.gameSlug === gameSlug)
|
||||
.map((r) => this.summary(r));
|
||||
}
|
||||
|
||||
summary(room) {
|
||||
return {
|
||||
id: room.id,
|
||||
gameSlug: room.gameSlug,
|
||||
name: room.name,
|
||||
hostId: room.hostId,
|
||||
players: room.players.map((p) => ({ id: p.userId, username: p.username, seat: p.seat })),
|
||||
maxPlayers: room.maxPlayers,
|
||||
status: room.status,
|
||||
};
|
||||
}
|
||||
|
||||
create({ gameSlug, hostUser, name }) {
|
||||
const game = getGame(gameSlug);
|
||||
if (!game) throw new Error(`Unknown game: ${gameSlug}`);
|
||||
if (!game.supportsMultiplayer) throw new Error(`${game.name} is not multiplayer.`);
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
const room = {
|
||||
id,
|
||||
gameSlug,
|
||||
name: name ?? `${hostUser.username}'s table`,
|
||||
hostId: hostUser.id,
|
||||
players: [{ userId: hostUser.id, username: hostUser.username, seat: 0 }],
|
||||
maxPlayers: game.maxPlayers,
|
||||
status: 'waiting',
|
||||
};
|
||||
this.rooms.set(id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
join(roomId, user) {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) throw new Error('Room not found.');
|
||||
if (room.players.some((p) => p.userId === user.id)) return room;
|
||||
if (room.players.length >= room.maxPlayers) throw new Error('Room is full.');
|
||||
const usedSeats = new Set(room.players.map((p) => p.seat));
|
||||
let seat = 0;
|
||||
while (usedSeats.has(seat)) seat += 1;
|
||||
room.players.push({ userId: user.id, username: user.username, seat });
|
||||
return room;
|
||||
}
|
||||
|
||||
leave(roomId, userId) {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
room.players = room.players.filter((p) => p.userId !== userId);
|
||||
if (!room.players.length) {
|
||||
this.rooms.delete(roomId);
|
||||
return null;
|
||||
}
|
||||
if (room.hostId === userId) room.hostId = room.players[0].userId;
|
||||
return room;
|
||||
}
|
||||
|
||||
get(roomId) {
|
||||
return this.rooms.get(roomId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export const lobby = new LobbyManager();
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import config from '../config.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { getProfile, setAvatarPath, updateProfile } from './service.js';
|
||||
|
||||
fs.mkdirSync(config.uploads.dir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(_req, _file, cb) {
|
||||
cb(null, config.uploads.dir);
|
||||
},
|
||||
filename(req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase().slice(0, 8) || '';
|
||||
const random = crypto.randomBytes(8).toString('hex');
|
||||
cb(null, `u${req.user.id}-${Date.now()}-${random}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: config.uploads.maxSizeMb * 1024 * 1024 },
|
||||
fileFilter(_req, file, cb) {
|
||||
if (!config.uploads.allowedMime.includes(file.mimetype)) {
|
||||
return cb(new Error('Unsupported image type.'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
res.json({ profile: getProfile(req.user.id) });
|
||||
});
|
||||
|
||||
router.patch('/', requireAuth, (req, res) => {
|
||||
const { displayName, bio } = req.body ?? {};
|
||||
const profile = updateProfile(req.user.id, { displayName, bio });
|
||||
res.json({ profile });
|
||||
});
|
||||
|
||||
router.post('/avatar', requireAuth, (req, res, next) => {
|
||||
upload.single('avatar')(req, res, (err) => {
|
||||
if (err) return res.status(400).json({ error: err.message });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
|
||||
const publicPath = `/uploads/${path.basename(req.file.path)}`;
|
||||
const profile = setAvatarPath(req.user.id, publicPath);
|
||||
res.json({ profile });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import db from '../db/index.js';
|
||||
|
||||
export function getProfile(userId) {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.email, u.username, u.email_verified,
|
||||
p.display_name, p.avatar_path, p.bio, p.updated_at
|
||||
FROM users u LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.id = ?`,
|
||||
)
|
||||
.get(userId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
username: row.username,
|
||||
emailVerified: !!row.email_verified,
|
||||
displayName: row.display_name,
|
||||
avatarPath: row.avatar_path,
|
||||
bio: row.bio,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateProfile(userId, { displayName, bio }) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (displayName !== undefined) {
|
||||
fields.push('display_name = ?');
|
||||
values.push(String(displayName).slice(0, 60));
|
||||
}
|
||||
if (bio !== undefined) {
|
||||
fields.push('bio = ?');
|
||||
values.push(String(bio).slice(0, 500));
|
||||
}
|
||||
if (!fields.length) return getProfile(userId);
|
||||
fields.push("updated_at = datetime('now')");
|
||||
values.push(userId);
|
||||
db.prepare(`UPDATE profiles SET ${fields.join(', ')} WHERE user_id = ?`).run(...values);
|
||||
return getProfile(userId);
|
||||
}
|
||||
|
||||
export function setAvatarPath(userId, avatarPath) {
|
||||
db.prepare(
|
||||
`UPDATE profiles SET avatar_path = ?, updated_at = datetime('now') WHERE user_id = ?`,
|
||||
).run(avatarPath, userId);
|
||||
return getProfile(userId);
|
||||
}
|
||||
Loading…
Reference in New Issue