Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig cc6544f44e feat(gofish): add matches panel and refill animations
- Add a "Matches so far" panel to the HUD that tracks paired ranks and remaining matches.
- Introduce `animateRefill` to visually animate cards flying from the pool to an empty hand.
- Refactor turn flow to use `playRefillsThenFinish` for sequential refill animations after pairing or empty-hand draws.
- Update `GoFishLogic` to capture refill events in `lastAsk.refills` for the animation system.
2026-05-20 19:27:11 -06:00
Brian Fertig fcee81a9d6 feat: enhance GoFish and Parchisi game UX with initial pair animations and pawn movement hints
- GoFish: Display animated banners when players are dealt starting pairs at the beginning of the game.
- Parchisi: Add pulsing glow and expanding ripple visual cues to indicate movable pawns.
- Parchisi: Implement click-outside dismissal for pawn selection highlights to improve interaction clarity.
2026-05-20 18:57:58 -06:00
Brian Fertig 9c3774fb1f refactor: remove multiplayer and Socket.IO in favor of single-player AI games
- Remove Socket.IO dependency, server multiplayer logic (lobby, room management), and client-side socket service
- Replace multiplayer-only flow with single-player AI gameplay; add `OpponentSelectScene` for AI configuration
- Simplify game registry to use `minOpponents`/`maxOpponents` instead of `supportsMultiplayer`/`multiplayerOnly`
- Update game registration to remove multiplayer flags and adjust `games` table schema to hardcode `supports_multiplayer = 0`
- Remove `SOCKET_IO_CORS_ORIGIN` from environment configuration
- Update documentation to reflect single-player architecture and simplified game creation process
- Fix minor UI issues: add width to buttons in auth scenes, fix auth listener cleanup in LandingScene, restore DOM layer visibility in Modal
2026-05-20 18:57:41 -06:00
33 changed files with 357 additions and 909 deletions

157
README.md
View File

@ -2,10 +2,10 @@
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.
and match history. Games are single-player against AI opponents.
The frontend uses **native browser ES modules** — no bundler, no build step.
The backend is Node.js + Express + Socket.IO with SQLite for persistence.
The backend is Node.js + Express with SQLite for persistence.
---
@ -19,7 +19,6 @@ The backend is Node.js + Express + Socket.IO with SQLite for persistence.
- [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)
@ -36,11 +35,9 @@ The backend is Node.js + Express + Socket.IO with SQLite for persistence.
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
- Match history (wins / losses / draws) recorded for single-player games
- 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
- Single-player games against configurable AI opponents
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
- Vector-only placeholder graphics — drop sprites in later without
refactoring scenes
@ -137,12 +134,6 @@ the template. Fields:
| `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
@ -153,8 +144,8 @@ 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.
The server serves both the API (`/api/*`) and the static frontend (`/`,
`/src/...`, `/uploads/...`) on the same port.
After it starts you should see:
@ -174,7 +165,7 @@ fertig-classic-games/
├── README.md
│
├── server/ Backend (Node.js, ES modules)
│ ├── index.js Express + Socket.IO bootstrap
│ ├── index.js Express bootstrap
│ ├── config.js Loads & validates .env
│ ├── db/
│ │ ├── index.js better-sqlite3 connection singleton
@ -192,10 +183,8 @@ fertig-classic-games/
│ │ └── 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
│ └── games/
│ └── registry.js Game definitions
│
├── public/ Frontend, served as static files
│ ├── index.html Loads Phaser via importmap
@ -206,8 +195,7 @@ fertig-classic-games/
│ ├── config.js UI colors, dimensions, API base
│ ├── services/
│ │ ├── api.js fetch wrapper
│ │ ├── auth.js Client-side auth store
│ │ └── socket.js socket.io-client
│ │ └── auth.js Client-side auth store
│ ├── ui/
│ │ ├── Button.js
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale
@ -221,12 +209,9 @@ fertig-classic-games/
│ │ ├── VerifyScene.js
│ │ ├── ProfileScene.js
│ │ ├── GameMenuScene.js
│ │ ├── LobbyScene.js
│ │ ├── OpponentSelectScene.js
│ │ └── GameRoomScene.js
│ └── games/
│ ├── BaseGame.js
│ ├── tabletop/TabletopGame.js
│ └── casino/CasinoGame.js
│ └── games/ One subdirectory per game (uno/, blackjack/, ...)
│
└── data/ SQLite database (gitignored)
└── fertig.sqlite
@ -289,65 +274,22 @@ automatically; the client uses `credentials: 'same-origin'` in fetch calls.
| 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'
}
```
| GET | `/api/games` | — | Lists registered games from `games/registry.js`. |
---
## 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.
resolve `phaser` to a CDN ES module. 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`.
GameMenu → OpponentSelect → 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
@ -358,49 +300,47 @@ cookie during the handshake and rejects unauthenticated connections.
## Adding a new game
1. **Register the game on the server.** In `server/multiplayer/gameRegistry.js`:
1. **Register the game on the server.** In `server/games/registry.js`:
```js
registerGame({
slug: 'cribbage',
name: 'Cribbage',
category: 'tabletop', // or 'casino'
category: 'tabletop', // or 'casino' or 'cards'
minPlayers: 2,
maxPlayers: 4,
supportsMultiplayer: true,
minOpponents: 1,
maxOpponents: 3,
});
```
The lobby and game menu will pick it up automatically.
The game menu picks 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`:
2. **Implement the game scene.** Each game is a `Phaser.Scene` that reads its
setup from the data passed by `GameRoomScene`:
```js
// public/src/games/tabletop/CribbageGame.js
import TabletopGame from './TabletopGame.js';
// public/src/games/cribbage/CribbageGame.js
import * as Phaser from 'phaser';
export default class CribbageGame extends TabletopGame {
export default class CribbageGame extends Phaser.Scene {
constructor() { super('CribbageGame'); }
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ }
applyBoardState(state) { /* reconcile pieces & turn */ }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents; // selected AI opponents
this.playfield = data.playfield;
this.cardBack = data.cardBack;
}
create() { /* render board, run the game + AI locally */ }
}
```
4. **Route to it from `GameRoomScene`.** Either switch on `game.slug` and
start the right scene, or load the module dynamically:
3. **Register the scene and route to it.** Add the scene to the `scene` array
in `public/src/main.js`, then add its slug → scene-key mapping to the
`slugDispatch` object in `public/src/scenes/GameRoomScene.js`.
```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).
4. **Record results (optional).** When a match finishes, `POST` to
`/api/history/single-player` to record the win / loss / draw for history.
---
@ -490,10 +430,6 @@ 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.
@ -505,16 +441,9 @@ beat; if it persists, file an issue.
## Roadmap
The framework is intentionally game-agnostic. Concrete games come next:
- [ ] Smarter AI opponents per game
- [ ] Richer match history views and per-game stats
- [ ] Replace placeholder vector graphics with sprites
- [ ] **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.
Contributions welcome — start by registering a game in `games/registry.js`
(see [Adding a new game](#adding-a-new-game)).

View File

@ -37,10 +37,6 @@ 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

248
package-lock.json generated
View File

@ -10,13 +10,11 @@
"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"
"nodemailer": "^6.9.14"
},
"engines": {
"node": ">=20"
@ -41,35 +39,6 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -179,14 +148,6 @@
}
]
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -393,14 +354,6 @@
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
@ -426,22 +379,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -552,63 +489,6 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -1666,107 +1546,6 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -1928,11 +1707,6 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -1989,26 +1763,6 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -15,12 +15,10 @@
"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"
"nodemailer": "^6.9.14"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,8 +13,7 @@
<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"
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js"
}
}
</script>

View File

@ -1,50 +0,0 @@
import * as 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,
});
}
}

View File

@ -1,43 +0,0 @@
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 }); }
}

View File

@ -148,6 +148,7 @@ export default class GoFishGame extends Phaser.Scene {
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.buildMatchesPanel();
this.startNewMatch();
}
@ -275,6 +276,65 @@ export default class GoFishGame extends Phaser.Scene {
}).setDepth(D.ui);
}
// ── Matches panel ─────────────────────────────────────────────────────────
buildMatchesPanel() {
const RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
const px = 10, py = 10;
const padX = 9, padY = 8;
const titleH = 18;
const rowH = 16;
const panelW = 188;
const panelH = padY + titleH + 5 + RANK_ORDER.length * rowH + 5 + rowH + padY;
const bg = this.add.graphics().setDepth(D.ui - 0.5);
bg.fillStyle(0x000000, 0.70);
bg.fillRoundedRect(px, py, panelW, panelH, 7);
bg.lineStyle(1, COLORS.accent, 0.35);
bg.strokeRoundedRect(px, py, panelW, panelH, 7);
this.add.text(px + padX, py + padY, 'Matches so far', {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex,
}).setDepth(D.ui);
this._matchPanelRows = RANK_ORDER.map((rank, i) => {
const label = rank === 'T' ? '10' : rank;
const t = this.add.text(
px + padX,
py + padY + titleH + 5 + i * rowH,
`${label.padEnd(2)} = 0 / 2`,
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.textHex }
).setDepth(D.ui);
return { rank, text: t };
});
this._matchRemainingText = this.add.text(
px + padX,
py + padY + titleH + 5 + RANK_ORDER.length * rowH + 5,
'Remaining Matches: 26',
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.goldHex }
).setDepth(D.ui);
}
updateMatchesPanel() {
if (!this.gs || !this._matchPanelRows) return;
const tally = new Map(this._matchPanelRows.map(({ rank }) => [rank, 0]));
for (const player of this.gs.players) {
for (const rank of player.pairedRanks) {
tally.set(rank, (tally.get(rank) ?? 0) + 1);
}
}
let totalScored = 0;
for (const { rank, text } of this._matchPanelRows) {
const scored = tally.get(rank) ?? 0;
totalScored += scored;
const label = rank === 'T' ? '10' : rank;
text.setText(`${label.padEnd(2)} = ${scored} / 2`);
text.setColor(scored === 2 ? COLORS.mutedHex : COLORS.textHex);
}
this._matchRemainingText.setText(`Remaining Matches: ${26 - totalScored}`);
}
// ── Match lifecycle ────────────────────────────────────────────────────────
startNewMatch() {
@ -356,10 +416,34 @@ export default class GoFishGame extends Phaser.Scene {
this.time.delayedCall(doneAt, () => {
this.scatterPool(finalState.pool);
this.gs = finalState;
this.animating = false;
this.renderAll();
this.updateStatus();
this.maybeStartAITurn();
const initialPairs = (finalState.initialDealPairs ?? []).filter((e) => e.pairedCards.length >= 2);
if (initialPairs.length > 0) {
this.playInitialDealPairs(initialPairs, 0, () => {
this.animating = false;
this.maybeStartAITurn();
});
} else {
this.animating = false;
this.maybeStartAITurn();
}
});
}
playInitialDealPairs(pairs, idx, onComplete) {
if (idx >= pairs.length) { onComplete(); return; }
const { seat, pairedCards } = pairs[idx];
const name = this.opponentName(seat);
const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank;
const pairCount = pairedCards.length / 2;
const label = pairCount === 1
? `${name} dealt a starting pair of ${rank}s!`
: `${name} dealt ${pairCount} starting pairs!`;
this.showBanner(label);
this.animatePairedCards(seat, pairedCards, () => {
this.hideBanner();
this.time.delayedCall(250, () => this.playInitialDealPairs(pairs, idx + 1, onComplete));
});
}
@ -432,6 +516,7 @@ export default class GoFishGame extends Phaser.Scene {
}
this.renderSeatChips();
this.renderTurnIndicator();
this.updateMatchesPanel();
}
renderScatteredPool() {
@ -680,23 +765,23 @@ export default class GoFishGame extends Phaser.Scene {
if (last.newPairs > 0 && last.pairedCards?.length >= 2) {
// Cards from animateAsk are already visible in position.
// Do NOT call renderAll() here — it would destroy the liveSprites.
// renderAll() is deferred to after the pair animation completes.
// renderAll() is deferred to after the pair + refill animations complete.
this.hideBanner();
this.animatePairedCards(askerSeat, last.pairedCards, () => {
this.renderAll();
this.updateStatus();
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
}, liveSprites ?? null);
} else {
this.time.delayedCall(900, () => {
this.renderAll();
this.updateStatus();
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.hideBanner();
this.maybeStartAITurn();
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
});
}
});
@ -941,6 +1026,84 @@ export default class GoFishGame extends Phaser.Scene {
this.time.delayedCall(2200, onComplete);
}
// ── Refill animation ──────────────────────────────────────────────────────
animateRefill(seat, drawnCards, onComplete) {
if (drawnCards.length === 0) { onComplete(); return; }
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
const finalHand = this.gs.players[seat].hand;
const STAGGER = 130;
const FLY = 340;
const targetRot = (layout.rotateCards * Math.PI) / 180;
const name = this.opponentName(seat);
this.showBanner(`${name}'s hand was empty — drawing new cards…`);
drawnCards.forEach((card, i) => {
this.time.delayedCall(i * STAGGER, () => {
// Re-use the existing face-down pool sprite if still in cardObjs.
const poolKey = `pool-${card.id}`;
const existing = this.cardObjs.get(poolKey);
let sprite;
if (existing) {
this.cardObjs.delete(poolKey);
this.transientObjs.push(existing);
sprite = existing;
} else {
const pos = this.poolCardPositions.get(card.id) ?? POOL_POS;
sprite = this.makeCardSprite(card, pos.x, pos.y, { faceUp: false });
this.transientObjs.push(sprite);
}
sprite.setDepth(D.card + 5);
// Destination: the card's actual position in the final hand.
const cardInHand = finalHand.find(c => c.id === card.id);
let destX = layout.handCenter.x;
let destY = layout.handCenter.y;
if (cardInHand) {
const n = finalHand.length;
const idx = finalHand.indexOf(cardInHand);
const offset = idx - (n - 1) / 2;
if (layout.handAxis === 'x') destX = layout.handCenter.x + offset * HAND_SPREAD;
else destY = layout.handCenter.y + offset * HAND_SPREAD;
}
if (i % 2 === 0) playSound(this, SFX.CARD_DEAL);
this.tweens.add({
targets: sprite, x: destX, y: destY, rotation: targetRot,
duration: FLY, ease: 'Cubic.easeOut',
onComplete: () => {
if (seat === 0 && cardInHand) this.flipCardFaceUp(sprite, card);
},
});
});
});
const total = (drawnCards.length - 1) * STAGGER + FLY + (seat === 0 ? 380 : 160);
this.time.delayedCall(total, () => {
this.hideBanner();
onComplete();
});
}
// Run each refill entry sequentially, then call renderAll + updateStatus + onDone.
playRefillsThenFinish(onDone) {
const refills = (this.gs.lastAsk?.refills ?? []).filter(r => r.cards.length > 0);
const next = (idx) => {
if (idx >= refills.length) {
this.renderAll();
this.updateStatus();
onDone();
return;
}
const { seat, cards } = refills[idx];
this.animateRefill(seat, cards, () => next(idx + 1));
};
next(0);
}
spawnFireworks(cx, cy) {
const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b];
const COUNT = 20;
@ -1002,22 +1165,22 @@ export default class GoFishGame extends Phaser.Scene {
if (last.newPairs > 0 && last.pairedCards?.length >= 2) {
this.time.delayedCall(600, () => {
this.hideBanner();
this.renderAll();
this.updateStatus();
this.animatePairedCards(askerSeat, last.pairedCards, () => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
});
});
} else {
this.time.delayedCall(900, () => {
this.renderAll();
this.updateStatus();
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.hideBanner();
this.maybeStartAITurn();
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
});
}
};

View File

@ -103,9 +103,13 @@ export function createInitialState({ playerCount = 4, seed } = {}) {
winnerSeats: [],
seed: seed ?? null,
turnCount: 0,
initialDealPairs: [],
};
// Any starting pairs are scored immediately.
for (const p of state.players) collectPairs(p, state);
for (const p of state.players) {
const { count, pairedCards } = collectPairs(p, state);
if (count > 0) state.initialDealPairs.push({ seat: p.seat, pairedCards });
}
// Edge: if a player was dealt all duplicates and now has 0 cards, refill.
for (const p of state.players) ensureHasCards(state, p.seat);
checkGameOver(state);
@ -156,10 +160,12 @@ export function applyAsk(state, targetSeat, rank) {
pairedCards,
};
next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'catch', count: matches.length });
const refillLog = [];
// Asker may have emptied target's hand — refill them now (before asker's next ask).
ensureHasCards(next, targetSeat);
ensureHasCards(next, targetSeat, refillLog);
// Asker may have emptied own hand by pairing — refill them too.
ensureHasCards(next, askerSeat);
ensureHasCards(next, askerSeat, refillLog);
next.lastAsk.refills = refillLog;
// If asker still has no cards or can't ask, advance turn.
if (!canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
@ -192,7 +198,9 @@ export function applyAsk(state, targetSeat, rank) {
newPairs: 0,
pairedCards: [],
};
ensureHasCards(next, askerSeat);
const emptyPoolRefills = [];
ensureHasCards(next, askerSeat, emptyPoolRefills);
next.lastAsk.refills = emptyPoolRefills;
advanceTurn(next);
checkGameOver(next);
return next;
@ -223,7 +231,9 @@ export function applyFishPick(state, cardId) {
};
if (lucky) next.log.push({ kind: 'lucky', askerSeat, rank });
next.phase = 'play';
ensureHasCards(next, askerSeat);
const pickRefills = [];
ensureHasCards(next, askerSeat, pickRefills);
next.lastAsk.refills = pickRefills;
if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
return next;
@ -263,15 +273,19 @@ function collectPairs(player, state) {
* If the player's hand is empty, draw up to REFILL_TARGET from the pool.
* If still empty afterward, mark them as sitting out.
*/
function ensureHasCards(state, seat) {
function ensureHasCards(state, seat, refillLog) {
const player = state.players[seat];
if (player.sittingOut) return;
if (player.hand.length > 0) return;
const drawn = [];
while (player.hand.length < REFILL_TARGET && state.pool.length > 0) {
player.hand.push(state.pool.pop());
const card = state.pool.pop();
player.hand.push(card);
drawn.push(cloneCard(card));
}
if (player.hand.length > 0) {
state.log.push({ kind: 'refill', seat, count: player.hand.length });
if (refillLog && drawn.length > 0) refillLog.push({ seat, cards: drawn });
collectPairs(player, state);
}
if (player.hand.length === 0) {

View File

@ -382,7 +382,7 @@ export default class HoldemGame extends Phaser.Scene {
const modalItems = [overlay, panel, title, balanceTxt, buyInTxt];
if (this.globalChips <= 0) {
this.add.text(CX, CY + 40, 'You have no chips! Return to the lobby.', {
this.add.text(CX, CY + 40, 'You have no chips! Return to the menu.', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.modal);
return;

View File

@ -164,6 +164,7 @@ export default class ParchisiGame extends Phaser.Scene {
this.buildTurnIndicator();
this.buildPawns();
this.initGame();
this.buildDismissHandler();
}
buildPlayfield() {
@ -594,6 +595,20 @@ export default class ParchisiGame extends Phaser.Scene {
return this.add.container(x, y, [g]);
}
buildDismissHandler() {
this.input.on('pointerdown', (_ptr, gameObjects) => {
if (this.selectedPawnIdx === null || this.animating) return;
const hitRelevant = gameObjects.some((o) =>
this.highlightObjs.includes(o) ||
PCOLORS.some((c) => this.pawnObjs[c]?.includes(o))
);
if (!hitRelevant) {
this.clearHighlights();
this.showMovablePawnHints();
}
});
}
// ── Game flow ─────────────────────────────────────────────────────────────
initGame() {
this.clearHighlights();
@ -695,6 +710,7 @@ export default class ParchisiGame extends Phaser.Scene {
this.time.delayedCall(600, () => this.runAITurnMoves());
} else {
this.setStatus('Choose a pawn to move');
this.showMovablePawnHints();
}
});
}
@ -794,6 +810,7 @@ export default class ParchisiGame extends Phaser.Scene {
}
this.setStatus('Choose a pawn');
this.updateButtons();
this.showMovablePawnHints();
return;
}
if (this.gs.phase === 'roll') {
@ -962,6 +979,31 @@ export default class ParchisiGame extends Phaser.Scene {
this.selectedPawnIdx = null;
}
showMovablePawnHints() {
const moves = getValidMoves(this.gs);
const movable = [...new Set(moves.map((m) => m.pawnIdx))];
for (const pawnIdx of movable) {
const obj = this.pawnObjs['red'][pawnIdx];
const px = obj.x;
const py = obj.y;
// Static glow ring — pulses alpha
const glow = this.add.graphics().setDepth(DEPTH.highlight);
glow.lineStyle(2.5, 0x00e5b0, 0.9);
glow.strokeCircle(px, py, PAWN_R + 6);
this.tweens.add({ targets: glow, alpha: { from: 0.9, to: 0.25 }, duration: 550, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
// Expanding ripple ring
const ripple = this.add.graphics().setDepth(DEPTH.highlight);
ripple.setPosition(px, py);
ripple.lineStyle(2, 0x00e5b0, 0.75);
ripple.strokeCircle(0, 0, PAWN_R + 6);
this.tweens.add({ targets: ripple, scaleX: 2.0, scaleY: 2.0, alpha: 0, duration: 950, repeat: -1, ease: 'Cubic.easeOut' });
this.highlightObjs.push(glow, ripple);
}
}
// ── UI updates ────────────────────────────────────────────────────────────
updateButtons() {
const canRoll = !this.animating

View File

@ -1,34 +0,0 @@
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;
}
}

View File

@ -9,7 +9,6 @@ import VerifyScene from './scenes/VerifyScene.js';
import ProfileScene from './scenes/ProfileScene.js';
import GameMenuScene from './scenes/GameMenuScene.js';
import OpponentSelectScene from './scenes/OpponentSelectScene.js';
import LobbyScene from './scenes/LobbyScene.js';
import GameRoomScene from './scenes/GameRoomScene.js';
import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js';
@ -43,7 +42,6 @@ const config = {
ProfileScene,
GameMenuScene,
OpponentSelectScene,
LobbyScene,
GameRoomScene,
BackgammonGame,
HoldemGame,

View File

@ -1,7 +1,6 @@
import * as 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 { playMenuMusic } from '../ui/MenuMusic.js';
@ -64,11 +63,6 @@ export default class GameMenuScene extends Phaser.Scene {
}
openGame(game) {
if (game.multiplayerOnly) {
if (!auth.user) { this.scene.start('Login'); return; }
this.scene.start('Lobby', { game });
} else {
this.scene.start('OpponentSelect', { game });
}
this.scene.start('OpponentSelect', { game });
}
}

View File

@ -1,17 +1,14 @@
import * as 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.
// Generic room shell. Dispatches to the concrete game scene
// (Backgammon, Blackjack, etc.) based on `data.game.slug`.
export default class GameRoomScene extends Phaser.Scene {
constructor() { super('GameRoom'); }
init(data) {
this.game = data.game;
this.room = data.room ?? null;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
@ -36,9 +33,7 @@ export default class GameRoomScene extends Phaser.Scene {
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx, 150, this.room
? `Table: ${this.room.name}`
: 'Single-player table',
this.add.text(cx, 150, 'Single-player table',
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
// Placeholder table felt
@ -50,34 +45,6 @@ export default class GameRoomScene extends Phaser.Scene {
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');
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.scene.start('GameMenu'));
}
}

View File

@ -37,11 +37,15 @@ export default class LandingScene extends Phaser.Scene {
this.renderButtons();
auth.subscribe(() => {
if (this._authUnsub) this._authUnsub();
this._authUnsub = auth.subscribe(() => {
if (!this.scene.isActive('Landing')) return;
this.children.removeAll();
this.create();
});
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
if (this._authUnsub) { this._authUnsub(); this._authUnsub = null; }
});
}
renderButtons() {
@ -100,15 +104,15 @@ export default class LandingScene extends Phaser.Scene {
}).setOrigin(0.5);
}
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'));
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'));
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
new Button(this, cx, 970, 'Sign out', async () => {
await auth.logout();
}, { variant: 'ghost' });
}, { variant: 'ghost', width: 480 });
} else {
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'));
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register'));
new Button(this, cx, 850, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost' });
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'), { width: 480 });
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register'), { width: 480 });
new Button(this, cx, 850, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 480 });
}
}
}

View File

@ -1,87 +0,0 @@
import * as 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: 'Righteous',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.listText = this.add.text(cx, 220, 'Connecting…', {
fontFamily: '"Julius Sans One"',
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 });
});
}
}

View File

@ -23,9 +23,9 @@ export default class LoginScene extends Phaser.Scene {
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' });
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit), { width: 480 });
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost', width: 480 });
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
this.input.keyboard.on('keydown-ENTER', () => this.attemptLogin(identifierInput, passwordInput, submit));
}

View File

@ -26,9 +26,9 @@ export default class RegisterScene extends Phaser.Scene {
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' });
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit), { width: 480 });
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost', width: 480 });
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
this.input.keyboard.on('keydown-ENTER', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
}

View File

@ -45,7 +45,7 @@ export default class VerifyScene extends Phaser.Scene {
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' });
}, { width: 480 });
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
}
}

View File

@ -12,7 +12,7 @@ class AuthStore {
}
emit() {
for (const fn of this.listeners) fn(this.user);
for (const fn of [...this.listeners]) fn(this.user);
}
async refresh() {

View File

@ -1,20 +0,0 @@
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();
}

View File

@ -20,10 +20,15 @@ export class Modal extends Phaser.GameObjects.Container {
this.add([overlay, panel, text]);
scene.add.existing(this);
const domLayer = document.getElementById('dom-layer');
if (domLayer) domLayer.style.visibility = 'hidden';
const restore = () => { if (domLayer) domLayer.style.visibility = ''; };
if (options.autoCloseMs) {
scene.time.delayedCall(options.autoCloseMs, () => this.destroy());
scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); });
} else {
overlay.on('pointerdown', () => this.destroy());
overlay.on('pointerdown', () => { restore(); this.destroy(); });
}
this.once('destroy', restore);
}
}

View File

@ -58,10 +58,6 @@ const config = {
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'),
};

View File

@ -24,12 +24,17 @@ export async function sendVerificationEmail(to, link) {
return { delivered: false, devLogged: true };
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html,
});
return { delivered: true, devLogged: false };
try {
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html,
});
return { delivered: true, devLogged: false };
} catch (err) {
console.error(`[mailer] Failed to send verification email to ${to}:`, err);
return { delivered: false, devLogged: false, error: err.message };
}
}

View File

@ -11,8 +11,6 @@ export function registerGame(definition) {
maxPlayers: definition.maxPlayers ?? 2,
minOpponents: definition.minOpponents ?? 1,
maxOpponents: definition.maxOpponents ?? 1,
supportsMultiplayer: definition.supportsMultiplayer ?? true,
multiplayerOnly: definition.multiplayerOnly ?? false,
});
}
@ -24,14 +22,14 @@ 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, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
// Built-in catalog so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4 });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5 });
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });

View File

@ -1,7 +1,7 @@
import { Router } from 'express';
import db from '../db/index.js';
import { requireAuth } from '../auth/middleware.js';
import { getGame } from '../multiplayer/gameRegistry.js';
import { getGame } from '../games/registry.js';
const router = Router();
@ -34,9 +34,9 @@ router.post('/single-player', requireAuth, (req, res) => {
const tx = db.transaction(() => {
db.prepare(
`INSERT INTO games (slug, name, category, max_players, supports_multiplayer)
VALUES (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 0)
ON CONFLICT(slug) DO NOTHING`,
).run(def.slug, def.name, def.category, def.maxPlayers, def.supportsMultiplayer ? 1 : 0);
).run(def.slug, def.name, def.category, def.maxPlayers);
const gameRow = db.prepare('SELECT id FROM games WHERE slug = ?').get(def.slug);

View File

@ -1,4 +1,3 @@
import http from 'node:http';
import path from 'node:path';
import express from 'express';
import cookieParser from 'cookie-parser';
@ -9,8 +8,7 @@ import authRoutes from './auth/routes.js';
import profileRoutes from './profile/routes.js';
import historyRoutes from './history/routes.js';
import historyRecordRoutes from './history/recordRoutes.js';
import { listGames } from './multiplayer/gameRegistry.js';
import { attachMultiplayer } from './multiplayer/index.js';
import { listGames } from './games/registry.js';
const app = express();
@ -37,9 +35,6 @@ app.use((err, _req, res, _next) => {
res.status(500).json({ error: 'Internal server error.' });
});
const server = http.createServer(app);
attachMultiplayer(server);
server.listen(config.port, config.host, () => {
app.listen(config.port, config.host, () => {
console.log(`[server] listening on http://${config.host}:${config.port}`);
});

View File

@ -1,101 +0,0 @@
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;
}

View File

@ -1,74 +0,0 @@
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();