Initial commit

This commit is contained in:
Feror 2026-02-03 13:48:56 +01:00
commit c0190b59ad
71 changed files with 20073 additions and 0 deletions

34
.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

27
.well-known/README.md Normal file
View file

@ -0,0 +1,27 @@
# Deep Link Association Placeholders
Fill the placeholders in the `.well-known` JSON files before deploying.
## iOS (Universal Links)
File: `.well-known/apple-app-site-association`
- `YOUR_IOS_TEAM_ID`
- Where: Apple Developer account → Membership details (Team ID).
- `YOUR_IOS_BUNDLE_ID`
- Where: Expo config `mobile/app.json``expo.ios.bundleIdentifier`.
Format must be: `TEAMID.BUNDLEID` (example: `ABCD1234EF.com.example.app`).
## Android (App Links)
File: `.well-known/assetlinks.json`
- `YOUR_ANDROID_PACKAGE_NAME`
- Where: Expo config `mobile/app.json``expo.android.package`.
- `YOUR_SHA256_CERT_FINGERPRINT`
- Where:
- Debug: `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey`
(password is usually `android`).
- Release/Play App Signing: Play Console → App Integrity → App signing key certificate.

View file

@ -0,0 +1,13 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "VD9WQ6BYX2.fr.negopoly.app",
"paths": [
"/play/*"
]
}
]
}
}

View file

@ -0,0 +1,10 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "fr.negopoly.app",
"sha256_cert_fingerprints": ["YOUR_SHA256_CERT_FINGERPRINT"]
}
}
]

51
AGENTS.md Normal file
View file

@ -0,0 +1,51 @@
# Repository Guidelines
This is a Companion App for an IRL Board Game. The board game is called Negopoly, a Monopoly parody that runs in a city called NegoCity. The app is built using Bun runtime and TypeScript. The App serves as a fake banking system for players to manage their in-game finances.
When a user reaches the app, they are greeted with a home page about the game. They can then go to /play, where they can create or join a game session. Each game session has a unique code that players can use to join. When a player creates a game session, they become the banker (sort of the session admin) and can start the game once all players have joined. When the banker is on the lobby screen, it also displays a QR code that other players can scan to join the game session easily (/play/:sessionID).
Using the app, players can see their current balance, transaction history, and transfer money to other players. They can also chat with other players in the session, and create chat groups for private conversations with multiple players.
The Banker has complete control over the game session. They can force transfers between players, add or remove money from players, see all chats, and end the game. They can also block all functionality for a set time (there are special cards in the game that cause a complete blackout).
The Banker can also create "dummy" players for IRL participants who don't have access to the app, allowing them to manage their finances through the Banker. If a user disconnects, their profile becomes a dummy player that the Banker can control. If a user connects mid-game, they can take over a dummy player (the Banker needs to approve).
The frontend is built with react and TSX files, while the backend uses Bun's built-in server capabilities. The play part of the frontend needs to look like a banking app, with a clean and simple UI, and needs to be mobile-first (for the players, the Banker will probably use a computer most of the time). The home page can be more playful and colorful to match the game's theme. The app will store game sessions in memory, so no database is required. The app should be optimized for performance and low latency, as multiple players will be interacting with it simultaneously. It will need to handle updates in real-time, so consider using WebSockets (using Bun's built in WebSocket server) or similar technologies for communication between the frontend and backend.
## Project Structure & Module Organization
- `index.ts` is the current entry point (Bun runtime).
- `front/` exists but is currently empty; place frontend assets or HTML/TSX here if you add a UI.
- `README.md` documents basic Bun usage.
- `node_modules/` is generated by `bun install` and should not be edited directly.
## Build, Test, and Development Commands
- `bun install` — install dependencies.
- `bun run index.ts` — run the app (from `README.md`).
- `bun --hot ./index.ts` — optional hot-reload workflow if you add server routes or frontend assets.
- `bun test` — run tests when they exist (see testing section).
## Coding Style & Naming Conventions
- Use TypeScript in ESM mode (`"type": "module"`).
- Prefer Bun APIs from `CLAUDE.md` (e.g., `Bun.serve`, `Bun.file`, `bun:sqlite`) instead of Node/Express equivalents.
- Indentation: 2 spaces in JSON; use your editor defaults for TS but keep it consistent.
- Name files by role (e.g., `index.ts`, `frontend.tsx`, `*.test.ts`).
## Testing Guidelines
- Testing framework: `bun:test` (see `CLAUDE.md` example).
- Test file naming: `*.test.ts` alongside the code or under a future `tests/` directory.
- Run tests with `bun test`.
## Commit & Pull Request Guidelines
- This directory is not a Git repository, so commit conventions are not defined yet.
- If you initialize Git, prefer concise, present-tense messages (e.g., `Add api route for users`).
- PRs (if used): include a short description, steps to verify, and screenshots for UI changes.
## Configuration & Security Notes
- Bun auto-loads `.env`; do not add dotenv loaders unless required.
- Keep secrets out of the repo; use `.env.local` or CI secrets where available.

106
CLAUDE.md Normal file
View file

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

27
README.md Normal file
View file

@ -0,0 +1,27 @@
# créditmabligopapp
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
Hot reload during development:
```bash
bun --hot ./index.ts
```
Run tests:
```bash
bun test
```
This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

107
bun.lock Normal file
View file

@ -0,0 +1,107 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "créditmabligopapp",
"dependencies": {
"qrcode": "^1.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",
},
"devDependencies": {
"@types/bun": "latest",
"@types/qrcode": "^1.5.5",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
}
}

281
front/home.css Normal file
View file

@ -0,0 +1,281 @@
@import url("https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;600;700&family=Unbounded:wght@400;600;800&display=swap");
:root {
color-scheme: light;
--cream: #f7f2e8;
--ink: #1b1b1f;
--tangerine: #ff8a3d;
--teal: #1d9aa3;
--berry: #f25287;
--sun: #ffd166;
--sky: #c7f0ff;
--shadow: rgba(12, 24, 38, 0.18);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #fff5d6 0%, #f7f2e8 45%, #e8f6f8 100%);
color: var(--ink);
font-family: "Recursive", "Segoe UI", sans-serif;
position: relative;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'%3E%3Crect width='140' height='140' fill='none'/%3E%3Ccircle cx='4' cy='4' r='1.2' fill='%23000000' opacity='0.08'/%3E%3Ccircle cx='74' cy='60' r='1.3' fill='%23000000' opacity='0.08'/%3E%3Ccircle cx='120' cy='96' r='1' fill='%23000000' opacity='0.06'/%3E%3C/svg%3E");
opacity: 0.25;
mix-blend-mode: multiply;
pointer-events: none;
z-index: 0;
}
.page-home #root {
position: relative;
z-index: 1;
}
.home {
display: flex;
flex-direction: column;
gap: 3rem;
padding: 3.5rem 1.5rem 4rem;
max-width: 1100px;
margin: 0 auto;
}
.hero {
display: grid;
gap: 1.8rem;
padding: 2.5rem;
border-radius: 28px;
background: linear-gradient(130deg, #fff4dd 10%, #fff 45%, #e0fbff 100%);
box-shadow: 0 20px 50px var(--shadow);
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
right: -30px;
top: -40px;
width: 240px;
height: 240px;
background: conic-gradient(from 120deg, var(--tangerine), var(--berry), var(--sun));
border-radius: 50%;
opacity: 0.2;
}
.hero__badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.9rem;
border-radius: 999px;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #fff;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
width: fit-content;
}
.hero h1 {
font-family: "Unbounded", "Trebuchet MS", sans-serif;
font-size: clamp(2.4rem, 3vw + 1rem, 4rem);
margin: 0;
letter-spacing: -0.02em;
}
.hero p {
margin: 0;
font-size: 1.1rem;
max-width: 36rem;
line-height: 1.6;
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.btn {
border: none;
border-radius: 999px;
padding: 0.9rem 1.7rem;
font-weight: 600;
font-size: 0.95rem;
text-decoration: none;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.btn.primary {
background: var(--tangerine);
color: #fff;
box-shadow: 0 16px 30px rgba(255, 138, 61, 0.35);
}
.btn.ghost {
background: rgba(0, 0, 0, 0.08);
color: var(--ink);
}
.btn:hover {
transform: translateY(-2px);
}
.grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card {
background: #fff;
border-radius: 20px;
padding: 1.6rem;
box-shadow: 0 16px 40px rgba(14, 30, 40, 0.08);
display: grid;
gap: 0.6rem;
}
.card h3 {
font-family: "Unbounded", sans-serif;
margin: 0;
font-size: 1.1rem;
}
.card p {
margin: 0;
color: rgba(20, 20, 20, 0.8);
line-height: 1.5;
}
.section-title {
font-family: "Unbounded", sans-serif;
font-size: 1.5rem;
margin: 0;
}
.map {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
align-items: center;
}
.map__panel {
background: #111827;
color: #fff;
border-radius: 24px;
padding: 2rem;
position: relative;
overflow: hidden;
min-height: 220px;
}
.map__panel::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(120deg, rgba(29, 154, 163, 0.35), rgba(255, 138, 61, 0.35));
opacity: 0.7;
}
.map__panel-content {
position: relative;
display: grid;
gap: 0.8rem;
}
.map__panel h2 {
margin: 0;
font-family: "Unbounded", sans-serif;
}
.map__panel span {
font-size: 0.95rem;
opacity: 0.8;
}
.map__legend {
display: grid;
gap: 0.8rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.7rem;
font-weight: 600;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 0 4px rgba(29, 154, 163, 0.2);
}
.legend-dot.orange {
background: var(--tangerine);
box-shadow: 0 0 0 4px rgba(255, 138, 61, 0.2);
}
.legend-dot.sun {
background: var(--sun);
box-shadow: 0 0 0 4px rgba(255, 209, 102, 0.25);
}
.footer {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
justify-content: space-between;
padding: 2rem;
border-radius: 24px;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 10px 25px rgba(12, 24, 38, 0.1);
}
.footer strong {
font-family: "Unbounded", sans-serif;
}
@media (min-width: 960px) {
.home {
padding: 5rem 2.5rem 6rem;
}
.hero {
grid-template-columns: 1.2fr 0.8fr;
align-items: center;
}
}
.reveal {
opacity: 0;
transform: translateY(24px);
animation: rise 0.8s ease forwards;
animation-delay: var(--delay, 0s);
}
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}

119
front/home.tsx Normal file
View file

@ -0,0 +1,119 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./home.css";
const features = [
{
title: "Instant Sessions",
text: "Spin up a banker-led lobby, invite players with a code or QR, and launch the game in seconds.",
},
{
title: "Real-Time Banking",
text: "Track balances, history, and transfers with live updates designed for fast tabletop play.",
},
{
title: "Control the Chaos",
text: "Blackouts, forced transfers, and dummy players give the Banker full city authority.",
},
];
const steps = [
{
label: "Banker opens the vault",
detail: "Create a session, name the banker, and get the invite code instantly.",
},
{
label: "Citizens join NegoCity",
detail: "Players join from their phones and the lobby updates in real time.",
},
{
label: "Let the deals fly",
detail: "Transfers, chats, and group deals all flow through the app.",
},
];
function Home() {
return (
<div className="home">
<header className="hero reveal" style={{ "--delay": "0.05s" } as React.CSSProperties}>
<div className="hero__badge">Negopoly companion bank</div>
<div>
<h1>Welcome to NegoCity.</h1>
<p>
Negopoly is a Monopoly parody where every deal goes through the Bank of NegoCity. This
companion app keeps the money moving, the chats flowing, and the Banker in total
control.
</p>
<div className="hero__actions">
<a className="btn primary" href="/play">
Enter the bank
</a>
<a className="btn ghost" href="#how-it-works">
How it works
</a>
</div>
</div>
</header>
<section className="grid">
{features.map((feature, index) => (
<article
key={feature.title}
className="card reveal"
style={{ "--delay": `${0.1 + index * 0.1}s` } as React.CSSProperties}
>
<h3>{feature.title}</h3>
<p>{feature.text}</p>
</article>
))}
</section>
<section id="how-it-works" className="map">
<div
className="map__panel reveal"
style={{ "--delay": "0.2s" } as React.CSSProperties}
>
<div className="map__panel-content">
<span>Route map</span>
<h2>NegoCity money flow</h2>
<p>
Banker-driven sessions, private group chats, and live balance sync. Everything you
need for fast-paced tabletop finance.
</p>
</div>
</div>
<div className="map__legend">
<h2 className="section-title reveal" style={{ "--delay": "0.25s" } as React.CSSProperties}>
The game loop
</h2>
{steps.map((step, index) => (
<div
key={step.label}
className="legend-item reveal"
style={{ "--delay": `${0.3 + index * 0.1}s` } as React.CSSProperties}
>
<span className={`legend-dot ${index === 1 ? "orange" : index === 2 ? "sun" : ""}`} />
<div>
<div>{step.label}</div>
<span>{step.detail}</span>
</div>
</div>
))}
</div>
</section>
<footer className="footer reveal" style={{ "--delay": "0.35s" } as React.CSSProperties}>
<div>
<strong>Ready for NegoCity?</strong>
<p>Make every deal official. Let the Banker run the show.</p>
</div>
<a className="btn primary" href="/play">
Start a session
</a>
</footer>
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<Home />);

16
front/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Negopoly | NegoCity Bank</title>
<link rel="stylesheet" href="./home.css" />
</head>
<body class="page-home">
<div id="root"></div>
<script type="module" src="./home.tsx"></script>
</body>
</html>

1086
front/play.css Normal file

File diff suppressed because it is too large Load diff

16
front/play.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Negopoly | Play</title>
<link rel="stylesheet" href="./play.css" />
</head>
<body class="page-play">
<div id="root"></div>
<script type="module" src="./play.tsx"></script>
</body>
</html>

7
front/play.tsx Normal file
View file

@ -0,0 +1,7 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./play.css";
import PlayApp from "./play/app";
const root = createRoot(document.getElementById("root")!);
root.render(<PlayApp />);

2265
front/play/app.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,61 @@
import React from "react";
import { Link } from "react-router-dom";
import type { ChatThread } from "./types";
import { useI18n } from "../i18n";
function formatTime(value?: number | null) {
if (!value) return "";
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export default function ChatList({
threads,
basePath,
unreadIds,
}: {
threads: ChatThread[];
basePath: string;
unreadIds?: Set<string>;
}) {
const { t } = useI18n();
return (
<div className="chat-list">
{threads.map((thread) => {
const last = thread.lastMessage;
const preview = last?.body ?? t("chat.noMessages");
const path = `${basePath}/${thread.id}`;
const isUnread = unreadIds?.has(thread.id);
return (
<Link
key={thread.id}
to={path}
className={`chat-list-item ${isUnread ? "unread" : ""}`}
>
<div className={`chat-avatar chat-avatar--${thread.kind}`}>
{thread.kind === "global" ? "#" : thread.name.charAt(0).toUpperCase()}
</div>
<div className="chat-list-body">
<div className="chat-list-top">
<strong>{thread.name}</strong>
<div className="chat-list-meta">
<span>{formatTime(last?.createdAt)}</span>
{isUnread && <span className="chat-unread-dot" aria-hidden="true" />}
</div>
</div>
<div className="chat-list-bottom">
<p>{preview}</p>
<span className={`chat-pill chat-pill--${thread.kind}`}>
{thread.kind === "global"
? t("chat.global")
: thread.kind === "direct"
? t("chat.direct")
: t("chat.group")}
</span>
</div>
</div>
</Link>
);
})}
</div>
);
}

View file

@ -0,0 +1,68 @@
import React, { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { SessionSnapshot } from "../../../shared/types";
import ChatList from "./ChatList";
import { buildThreads } from "./utils";
import { useI18n } from "../i18n";
export default function ChatListScreen({
session,
meId,
isBanker,
sessionId,
backHref,
unreadIds,
}: {
session: SessionSnapshot;
meId: string;
isBanker: boolean;
sessionId: string;
backHref: string;
unreadIds: Set<string>;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const threads = useMemo(() => buildThreads(session, meId, isBanker), [session, meId, isBanker]);
const filtered = threads.filter((thread) =>
thread.name.toLowerCase().includes(query.trim().toLowerCase()),
);
const list = query.trim() ? filtered : threads;
const conversationLabel =
threads.length === 1
? t("chat.conversationCountOne")
: t("chat.conversationCount", { count: threads.length });
return (
<div className="chat-shell">
<div className="chat-screen">
<header className="chat-screen-header">
<Link className="chat-back" to={backHref}>
{t("chat.back")}
</Link>
<div className="chat-screen-title">
<h1>{t("chat.title")}</h1>
<span>{conversationLabel}</span>
</div>
</header>
<div className="chat-search">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("chat.searchPlaceholder")}
/>
</div>
<ChatList threads={list} basePath={`/play/${sessionId}/chat`} unreadIds={unreadIds} />
<Link
className="chat-fab"
to={`/play/${sessionId}/chat/new`}
aria-label={t("chat.newTitle")}
>
+
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,152 @@
import React, { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { SessionSnapshot } from "../../../shared/types";
import { useI18n } from "../i18n";
export default function ChatNewScreen({
session,
meId,
sessionId,
onCreate,
}: {
session: SessionSnapshot;
meId: string;
sessionId: string;
onCreate: (name: string, memberIds: string[]) => void;
}) {
const { t } = useI18n();
const [mode, setMode] = useState<"direct" | "group">("direct");
const [groupName, setGroupName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const options = useMemo(
() => session.players.filter((player) => player.id !== meId),
[session.players, meId],
);
function resetSelection(nextMode: "direct" | "group") {
setMode(nextMode);
setSelected([]);
setError(null);
if (nextMode === "direct") {
setGroupName("");
}
}
function toggleMember(id: string) {
if (mode === "direct") {
setSelected([id]);
return;
}
setSelected((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
}
function handleCreate() {
if (mode === "direct") {
if (selected.length !== 1) {
setError(t("chat.error.direct"));
return;
}
onCreate(t("chat.direct"), selected);
return;
}
if (!groupName.trim()) {
setError(t("chat.error.groupName"));
return;
}
if (selected.length === 0) {
setError(t("chat.error.member"));
return;
}
onCreate(groupName.trim(), selected);
}
return (
<div className="play-shell chat-shell">
<div className="chat-screen chat-screen--new">
<header className="chat-screen-header">
<Link className="chat-back" to={`/play/${sessionId}/chat`}>
{t("chat.backChats")}
</Link>
<div className="chat-screen-title">
<h1>{t("chat.newTitle")}</h1>
<span>{t("chat.newSubtitle")}</span>
</div>
</header>
<section className="chat-new">
<div className="chat-toggle">
<button
type="button"
className={mode === "direct" ? "active" : ""}
onClick={() => resetSelection("direct")}
>
{t("chat.direct")}
</button>
<button
type="button"
className={mode === "group" ? "active" : ""}
onClick={() => resetSelection("group")}
>
{t("chat.group")}
</button>
</div>
{mode === "group" && (
<label className="chat-field">
<span>{t("chat.groupName")}</span>
<input
value={groupName}
onChange={(event) => setGroupName(event.target.value)}
placeholder={t("chat.groupPlaceholder")}
/>
</label>
)}
<div className="chat-members">
<h2>{t("chat.choosePlayers")}</h2>
{options.length === 0 ? (
<p className="helper">{t("chat.noPlayers")}</p>
) : (
<div className="chat-members-list">
{options.map((player) => {
const selectedNow = selected.includes(player.id);
return (
<label
key={player.id}
className={`chat-member ${selectedNow ? "selected" : ""}`}
>
<input
type={mode === "direct" ? "radio" : "checkbox"}
name="chat-member"
checked={selectedNow}
onChange={() => toggleMember(player.id)}
/>
<span className="chat-member-name">{player.name}</span>
<span className="chat-member-meta">
{player.role === "banker"
? t("common.banker")
: player.isDummy
? t("common.dummy")
: t("common.player")}
</span>
</label>
);
})}
</div>
)}
</div>
{error && <p className="chat-error">{error}</p>}
<button className="button" type="button" onClick={handleCreate}>
{t("chat.startChat")}
</button>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
import React, { useEffect, useRef } from "react";
import type { ChatMessage } from "../../../shared/types";
import type { ChatThread } from "./types";
import { useI18n } from "../i18n";
function formatTime(value: number) {
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export default function ChatThread({
thread,
messages,
meId,
onSend,
readOnly,
nameById,
}: {
thread: ChatThread;
messages: ChatMessage[];
meId: string | null;
onSend?: (body: string) => void;
readOnly?: boolean;
nameById: Record<string, string>;
}) {
const { t } = useI18n();
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages]);
const showSender = thread.kind === "group" || thread.kind === "global";
return (
<div className="chat-thread">
<div className="chat-thread-body" ref={listRef}>
{messages.length === 0 && (
<div className="chat-empty">
<p>{t("chat.noMessages")}</p>
<span>{t("chat.startConversation")}</span>
</div>
)}
{messages.map((message) => {
const isMe = message.fromId === meId;
return (
<div key={message.id} className={`chat-message-row ${isMe ? "me" : ""}`}>
<div className={`chat-bubble ${isMe ? "me" : ""}`}>
{showSender && !isMe && (
<span className="chat-sender">
{nameById[message.fromId] ?? t("common.player")}
</span>
)}
<p>{message.body}</p>
<span className="chat-time">{formatTime(message.createdAt)}</span>
</div>
</div>
);
})}
</div>
{!readOnly && (
<ChatComposer
placeholder={t("chat.messagePlaceholder")}
sendLabel={t("common.send")}
onSend={(body) => {
if (!body.trim()) return;
onSend?.(body);
}}
/>
)}
</div>
);
}
function ChatComposer({
onSend,
placeholder,
sendLabel,
}: {
onSend: (body: string) => void;
placeholder: string;
sendLabel: string;
}) {
const [value, setValue] = React.useState("");
return (
<div className="chat-composer">
<input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={placeholder}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (!value.trim()) return;
onSend(value);
setValue("");
}
}}
/>
<button
className="chat-send"
type="button"
onClick={() => {
if (!value.trim()) return;
onSend(value);
setValue("");
}}
>
{sendLabel}
</button>
</div>
);
}

View file

@ -0,0 +1,82 @@
import React, { useMemo } from "react";
import { Link, Navigate } from "react-router-dom";
import type { SessionSnapshot } from "../../../shared/types";
import ChatThread from "./ChatThread";
import { buildThreads, getThreadMessages } from "./utils";
import { useI18n } from "../i18n";
export default function ChatThreadScreen({
session,
meId,
isBanker,
sessionId,
chatId,
onSend,
}: {
session: SessionSnapshot;
meId: string;
isBanker: boolean;
sessionId: string;
chatId: string;
onSend: (body: string, groupId: string | null) => void;
}) {
const { t } = useI18n();
const threads = useMemo(() => buildThreads(session, meId, isBanker), [session, meId, isBanker]);
const thread = threads.find((item) => item.id === chatId) ?? null;
if (!thread) {
return <Navigate to={`/play/${sessionId}/chat`} replace />;
}
const messages = getThreadMessages(session, thread.id);
const memberCount = thread.members.length;
const headerSub =
thread.kind === "global"
? t("chat.everyone")
: thread.kind === "direct"
? t("chat.directMessage")
: memberCount === 1
? t("chat.memberCountOne")
: t("chat.memberCount", { count: memberCount });
const nameById = useMemo(() => {
const lookup: Record<string, string> = {};
session.players.forEach((player) => {
lookup[player.id] = player.name;
});
return lookup;
}, [session.players]);
return (
<div className="chat-shell">
<div className="chat-screen chat-screen--thread">
<header className="chat-thread-header">
<div className="chat-thread-leading">
<Link className="chat-back" to={`/play/${sessionId}/chat`}>
{t("chat.backChats")}
</Link>
<div>
<h1>{thread.name}</h1>
<span>{headerSub}</span>
</div>
</div>
<div className="chat-thread-tag">
{thread.kind === "global"
? t("chat.global")
: thread.kind === "direct"
? t("chat.direct")
: t("chat.group")}
</div>
</header>
<ChatThread
thread={thread}
messages={messages}
meId={meId}
nameById={nameById}
onSend={(body) => onSend(body, thread.id === "global" ? null : thread.id)}
/>
</div>
</div>
);
}

9
front/play/chat/types.ts Normal file
View file

@ -0,0 +1,9 @@
import type { ChatMessage } from "../../../shared/types";
export type ChatThread = {
id: string;
name: string;
kind: "global" | "group" | "direct";
members: string[];
lastMessage: ChatMessage | null;
};

110
front/play/chat/utils.ts Normal file
View file

@ -0,0 +1,110 @@
import type { ChatMessage, SessionSnapshot } from "../../../shared/types";
import type { ChatThread } from "./types";
import { tStatic } from "../i18n";
function getLastMessage(messages: ChatMessage[]): ChatMessage | null {
if (messages.length === 0) return null;
return messages.reduce((latest, current) =>
current.createdAt > latest.createdAt ? current : latest,
);
}
export function buildThreads(
session: SessionSnapshot,
meId: string | null,
isBanker: boolean,
): ChatThread[] {
const threads: ChatThread[] = [];
const globalMessages = session.chats.filter((message) => message.groupId === null);
threads.push({
id: "global",
name: tStatic("chat.global"),
kind: "global",
members: [],
lastMessage: getLastMessage(globalMessages),
});
session.groups.forEach((group) => {
if (!isBanker && meId && !group.memberIds.includes(meId)) {
return;
}
const groupMessages = session.chats.filter((message) => message.groupId === group.id);
let name = group.name;
let kind: ChatThread["kind"] = "group";
if (group.memberIds.length === 2) {
const [first, second] = group.memberIds;
if (isBanker || !meId) {
const firstName =
session.players.find((player) => player.id === first)?.name ??
tStatic("common.player");
const secondName =
session.players.find((player) => player.id === second)?.name ??
tStatic("common.player");
name = `${firstName} & ${secondName}`;
kind = "direct";
} else {
const otherId = first === meId ? second : first;
const otherName =
session.players.find((player) => player.id === otherId)?.name ??
tStatic("common.player");
name = otherName;
kind = "direct";
}
}
threads.push({
id: group.id,
name,
kind,
members: group.memberIds,
lastMessage: getLastMessage(groupMessages),
});
});
return threads.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? 0;
const bTime = b.lastMessage?.createdAt ?? 0;
return bTime - aTime;
});
}
export function getThreadMessages(session: SessionSnapshot, threadId: string): ChatMessage[] {
if (threadId === "global") {
return session.chats.filter((message) => message.groupId === null).slice().reverse();
}
return session.chats.filter((message) => message.groupId === threadId).slice().reverse();
}
export function getLatestThreadTimestamp(session: SessionSnapshot, threadId: string): number | null {
const messages =
threadId === "global"
? session.chats.filter((message) => message.groupId === null)
: session.chats.filter((message) => message.groupId === threadId);
if (messages.length === 0) return null;
return messages.reduce(
(latest, message) => (message.createdAt > latest ? message.createdAt : latest),
0,
);
}
export function getUnreadThreadIds(
session: SessionSnapshot,
meId: string | null,
isBanker: boolean,
readState: Record<string, number>,
): Set<string> {
const unread = new Set<string>();
if (!meId) return unread;
const allowed = new Set(
buildThreads(session, meId, isBanker).map((thread) => thread.id),
);
session.chats.forEach((message) => {
if (message.fromId === meId) return;
const threadId = message.groupId ?? "global";
if (!allowed.has(threadId)) return;
const lastRead = readState[threadId] ?? 0;
if (message.createdAt > lastRead) {
unread.add(threadId);
}
});
return unread;
}

421
front/play/i18n.ts Normal file
View file

@ -0,0 +1,421 @@
import { useCallback, useMemo } from "react";
type Locale = "en" | "fr";
const translations = {
en: {
"app.name": "Negopoly Bank",
"common.loading": "Loading...",
"common.notice": "Notice:",
"common.reset": "Reset",
"common.guest": "Guest",
"common.dummy": "Dummy",
"common.player": "Player",
"common.banker": "Banker",
"common.bank": "Bank",
"common.online": "online",
"common.offline": "Offline",
"common.name": "Name",
"common.startingBalance": "Starting balance",
"common.selectPlayer": "Select player",
"common.reason": "Reason",
"common.amount": "Amount",
"common.note": "Note",
"common.noReason": "No reason provided",
"common.from": "From",
"common.to": "To",
"common.apply": "Apply",
"common.force": "Force",
"common.trigger": "Trigger",
"common.send": "Send",
"common.download": "Download",
"common.load": "Load",
"common.save": "Save",
"common.transactions": "Transactions",
"common.noActivity": "No activity yet.",
"common.connecting": "Connecting to session {id}...",
"common.sessionLive": "Session {code} · live",
"common.continue": "Continue",
"tabs.dashboard": "Dashboard",
"tabs.tools": "Tools",
"tabs.home": "Home",
"tabs.transfers": "Transfers",
"tabs.chat": "Chat",
"entry.tagline": "Open a lobby or join the city.",
"entry.liveSessions": "Live sessions",
"entry.bankerControlled": "Banker controlled",
"entry.createTitle": "Create a session",
"entry.createSubtitle": "Become the banker and control the flow of money.",
"entry.bankerName": "Banker name",
"entry.openVault": "Open the vault",
"entry.joinTitle": "Join a session",
"entry.joinSubtitle": "Enter a session code to continue.",
"entry.sessionCode": "Session code",
"entry.codePlaceholder": "Enter code",
"entry.newPlayerLabel": "Create a new player",
"entry.playerName": "Player name",
"entry.joinAsNew": "Join as new player",
"entry.takeoverTitle": "Take over a dummy",
"entry.alreadyConnected": "You are already connected to this session.",
"entry.selectDummy": "Select dummy",
"entry.yourNameOptional": "Your name (optional)",
"entry.requestTakeover": "Request takeover",
"entry.noDummies": "No dummies available to take over yet.",
"entry.changeCode": "Change code",
"entry.alert.enterCode": "Enter a session code",
"entry.alert.sessionNotFound": "Session not found",
"entry.alert.selectDummy": "Select a dummy player",
"lobby.title": "Negopoly Lobby",
"lobby.sessionLabel": "Session {id}",
"lobby.joinTitle": "Join this lobby",
"lobby.loadingInfo": "Loading session info...",
"lobby.waitingState": "Waiting for the lobby state.",
"lobby.header": "Lobby · Session {code}",
"lobby.statusLine": "Status: {status} · {count} players",
"lobby.roster": "Lobby roster",
"lobby.startGame": "Start the game",
"lobby.waitingBanker": "Waiting for the banker to start the game.",
"lobby.sessionClosed": "Session closed.",
"lobby.inviteQr": "Invite QR",
"lobby.scanToJoin": "Scan to join this lobby instantly.",
"lobby.addDummyTitle": "Add dummy player",
"lobby.addDummySubtitle":
"Create a player for someone without the app. Dummies can be taken over later.",
"lobby.enterDummyName": "Enter a dummy name",
"lobby.addDummyButton": "Add dummy",
"lobby.errorLoadInfo": "Unable to load session info",
"banker.consoleTitle": "Banker Console",
"banker.controlsTitle": "Banker controls",
"banker.tools.playersTab": "Players",
"banker.tools.adminTab": "Admin",
"banker.playersTitle": "Players",
"banker.playerOverview": "Player overview",
"banker.noPlayers": "No players yet.",
"banker.adminControls": "Session controls",
"banker.adjustBalance": "Adjust balance",
"banker.adjustAmountPlaceholder": "+/- amount",
"banker.forceTransfer": "Force transfer",
"banker.createDummy": "Create dummy",
"banker.dummyName": "Dummy name",
"banker.addDummy": "Add dummy",
"banker.blackout": "EMP",
"banker.blackoutToggle": "Toggle EMP",
"banker.blackoutEnable": "Enable EMP",
"banker.blackoutDisable": "Disable EMP",
"banker.blackoutReason": "EMP reason",
"banker.endSession": "End session",
"banker.takeoverApprovals": "Takeover approvals",
"banker.wants": "Wants {name}",
"banker.approve": "Approve",
"banker.stateTitle": "GameState",
"banker.stateSubtitle": "Export, import, or resume a session from a saved snapshot.",
"banker.downloadState": "Download current GameState",
"banker.loadFromFile": "Load GameState from file",
"banker.loadFromStorage": "Load from browser storage",
"banker.stateDownloaded": "GameState downloaded.",
"banker.stateDownloadError": "Unable to download GameState.",
"banker.stateLoaded": "GameState loaded.",
"banker.stateLoadError": "Unable to load GameState.",
"banker.stateLoadInvalid": "Invalid GameState file.",
"banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Keep rolling backups in this browser.",
"banker.autosaveToggle": "Enable AutoSave",
"banker.autosaveEnabled": "AutoSave is enabled",
"banker.autosaveInterval": "Minutes between saves",
"banker.autosaveMinutes": "e.g. 3",
"banker.autosaveKeep": "Snapshots to keep",
"banker.autosaveCount": "e.g. 5",
"banker.autosaveNow": "Save now",
"banker.autosaveSaved": "AutoSave captured.",
"banker.autosaveFailed": "AutoSave failed.",
"banker.noAutosaves": "No autosaves yet.",
"banker.savedAt": "Saved {time}",
"player.deskTitle": "Player Desk",
"player.quickTransfer": "Quick transfer",
"player.sendTo": "Send to",
"player.noteOptional": "Note (optional)",
"player.notePlaceholder": "For what?",
"player.sendFunds": "Send funds",
"transfers.error": "Choose a player and a valid amount.",
"player.lastUpdated": "Last updated {time}",
"home.balance": "Balance",
"blackout.title": "EMP",
"blackout.defaultReason": "EMP in effect",
"blackout.active": "EMP active",
"chat.title": "Chats",
"chat.global": "Global chat",
"chat.conversationCount": "{count} conversations",
"chat.conversationCountOne": "1 conversation",
"chat.searchPlaceholder": "Search chats",
"chat.newTitle": "New chat",
"chat.newSubtitle": "Start a direct or group conversation",
"chat.direct": "Direct",
"chat.group": "Group",
"chat.groupName": "Group name",
"chat.groupPlaceholder": "e.g. Negotiators",
"chat.choosePlayers": "Choose players",
"chat.noPlayers": "No other players available yet.",
"chat.back": "Back",
"chat.backChats": "Chats",
"chat.noMessages": "No messages yet.",
"chat.startConversation": "Start the conversation.",
"chat.messagePlaceholder": "Message",
"chat.startChat": "Start chat",
"chat.everyone": "Everyone in the session",
"chat.directMessage": "Direct message",
"chat.memberCount": "{count} members",
"chat.memberCountOne": "1 member",
"chat.error.direct": "Choose one person to start a direct chat.",
"chat.error.groupName": "Give the group a name.",
"chat.error.member": "Select at least one member.",
"transaction.transfer": "Transfer",
"transaction.banker_adjust": "Banker adjustment",
"transaction.banker_force_transfer": "Forced transfer",
"status.lobby": "Lobby",
"status.active": "Active",
"status.ended": "Ended",
"connection.idle": "idle",
"connection.connecting": "connecting",
"connection.open": "connected",
"connection.error": "error",
"error.parseResponse": "Unable to parse server response",
"error.createSession": "Unable to create session",
"error.joinSession": "Unable to join session",
"error.connectionNotReady": "Connection not ready",
},
fr: {
"app.name": "Banque Negopoly",
"common.loading": "Chargement...",
"common.notice": "Info :",
"common.reset": "Réinitialiser",
"common.guest": "Invité",
"common.dummy": "Dummy",
"common.player": "Joueur",
"common.banker": "Banquier",
"common.bank": "Banque",
"common.online": "en ligne",
"common.offline": "Hors ligne",
"common.name": "Nom",
"common.startingBalance": "Solde de départ",
"common.selectPlayer": "Choisir un joueur",
"common.reason": "Raison",
"common.amount": "Montant",
"common.note": "Note",
"common.noReason": "Aucune raison fournie",
"common.from": "De",
"common.to": "À",
"common.apply": "Appliquer",
"common.force": "Forcer",
"common.trigger": "Déclencher",
"common.send": "Envoyer",
"common.download": "Télécharger",
"common.load": "Charger",
"common.save": "Enregistrer",
"common.transactions": "Transactions",
"common.noActivity": "Aucune activité.",
"common.connecting": "Connexion à la session {id}...",
"common.sessionLive": "Session {code} · en direct",
"common.continue": "Continuer",
"tabs.dashboard": "Tableau",
"tabs.tools": "Outils",
"tabs.home": "Accueil",
"tabs.transfers": "Transferts",
"tabs.chat": "Chat",
"entry.tagline": "Ouvrez un lobby ou rejoignez la ville.",
"entry.liveSessions": "Sessions en direct",
"entry.bankerControlled": "Contrôlé par le banquier",
"entry.createTitle": "Créer une session",
"entry.createSubtitle": "Devenez banquier et contrôlez le flux d'argent.",
"entry.bankerName": "Nom du banquier",
"entry.openVault": "Ouvrir le coffre",
"entry.joinTitle": "Rejoindre une session",
"entry.joinSubtitle": "Entrez un code pour continuer.",
"entry.sessionCode": "Code de session",
"entry.codePlaceholder": "Entrez le code",
"entry.newPlayerLabel": "Créer un nouveau joueur",
"entry.playerName": "Nom du joueur",
"entry.joinAsNew": "Rejoindre comme nouveau joueur",
"entry.takeoverTitle": "Reprendre un dummy",
"entry.alreadyConnected": "Vous êtes déjà connecté à cette session.",
"entry.selectDummy": "Choisir un dummy",
"entry.yourNameOptional": "Votre nom (optionnel)",
"entry.requestTakeover": "Demander la reprise",
"entry.noDummies": "Aucun dummy disponible pour le moment.",
"entry.changeCode": "Changer de code",
"entry.alert.enterCode": "Entrez un code de session",
"entry.alert.sessionNotFound": "Session introuvable",
"entry.alert.selectDummy": "Sélectionnez un dummy",
"lobby.title": "Lobby Negopoly",
"lobby.sessionLabel": "Session {id}",
"lobby.joinTitle": "Rejoindre ce lobby",
"lobby.loadingInfo": "Chargement des infos de session...",
"lobby.waitingState": "En attente de l'état du lobby.",
"lobby.header": "Lobby · Session {code}",
"lobby.statusLine": "Statut : {status} · {count} joueurs",
"lobby.roster": "Liste des joueurs",
"lobby.startGame": "Démarrer la partie",
"lobby.waitingBanker": "En attente du banquier pour démarrer.",
"lobby.sessionClosed": "Session terminée.",
"lobby.inviteQr": "QR d'invitation",
"lobby.scanToJoin": "Scannez pour rejoindre instantanément.",
"lobby.addDummyTitle": "Ajouter un dummy",
"lobby.addDummySubtitle":
"Créez un joueur pour quelqu'un sans l'application. Les dummies peuvent être repris.",
"lobby.enterDummyName": "Entrez un nom de dummy",
"lobby.addDummyButton": "Ajouter un dummy",
"lobby.errorLoadInfo": "Impossible de charger les infos de session",
"banker.consoleTitle": "Console banquier",
"banker.controlsTitle": "Contrôles banquier",
"banker.tools.playersTab": "Joueurs",
"banker.tools.adminTab": "Admin",
"banker.playersTitle": "Joueurs",
"banker.playerOverview": "Vue joueur",
"banker.noPlayers": "Pas encore de joueurs.",
"banker.adminControls": "Contrôles de session",
"banker.adjustBalance": "Ajuster le solde",
"banker.adjustAmountPlaceholder": "Montant +/-",
"banker.forceTransfer": "Forcer un transfert",
"banker.createDummy": "Créer un dummy",
"banker.dummyName": "Nom du dummy",
"banker.addDummy": "Ajouter un dummy",
"banker.blackout": "EMP",
"banker.blackoutToggle": "Basculer l'EMP",
"banker.blackoutEnable": "Activer l'EMP",
"banker.blackoutDisable": "Désactiver l'EMP",
"banker.blackoutReason": "Raison de l'EMP",
"banker.endSession": "Terminer la session",
"banker.takeoverApprovals": "Approbations de reprise",
"banker.wants": "Veut {name}",
"banker.approve": "Approuver",
"banker.stateTitle": "État de partie",
"banker.stateSubtitle": "Exportez, importez ou reprenez une partie depuis une sauvegarde.",
"banker.downloadState": "Télécharger l'état actuel",
"banker.loadFromFile": "Charger un état depuis un fichier",
"banker.loadFromStorage": "Charger depuis le navigateur",
"banker.stateDownloaded": "État téléchargé.",
"banker.stateDownloadError": "Impossible de télécharger l'état.",
"banker.stateLoaded": "État chargé.",
"banker.stateLoadError": "Impossible de charger l'état.",
"banker.stateLoadInvalid": "Fichier d'état invalide.",
"banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Conservez des sauvegardes dans ce navigateur.",
"banker.autosaveToggle": "Activer AutoSave",
"banker.autosaveEnabled": "AutoSave activé",
"banker.autosaveInterval": "Minutes entre sauvegardes",
"banker.autosaveMinutes": "ex. 3",
"banker.autosaveKeep": "Sauvegardes à conserver",
"banker.autosaveCount": "ex. 5",
"banker.autosaveNow": "Sauvegarder maintenant",
"banker.autosaveSaved": "Sauvegarde effectuée.",
"banker.autosaveFailed": "Échec de la sauvegarde.",
"banker.noAutosaves": "Aucune sauvegarde.",
"banker.savedAt": "Sauvé {time}",
"player.deskTitle": "Bureau joueur",
"player.quickTransfer": "Transfert rapide",
"player.sendTo": "Envoyer à",
"player.noteOptional": "Note (optionnel)",
"player.notePlaceholder": "Pour quoi ?",
"player.sendFunds": "Envoyer les fonds",
"transfers.error": "Choisissez un joueur et un montant valide.",
"player.lastUpdated": "Mis à jour {time}",
"home.balance": "Solde",
"blackout.title": "EMP",
"blackout.defaultReason": "EMP en cours",
"blackout.active": "EMP actif",
"chat.title": "Chats",
"chat.global": "Chat global",
"chat.conversationCount": "{count} conversations",
"chat.conversationCountOne": "1 conversation",
"chat.searchPlaceholder": "Rechercher un chat",
"chat.newTitle": "Nouveau chat",
"chat.newSubtitle": "Démarrer une conversation directe ou de groupe",
"chat.direct": "Direct",
"chat.group": "Groupe",
"chat.groupName": "Nom du groupe",
"chat.groupPlaceholder": "ex. Négociateurs",
"chat.choosePlayers": "Choisir des joueurs",
"chat.noPlayers": "Aucun autre joueur disponible.",
"chat.back": "Retour",
"chat.backChats": "Chats",
"chat.noMessages": "Aucun message.",
"chat.startConversation": "Démarrez la conversation.",
"chat.messagePlaceholder": "Message",
"chat.startChat": "Démarrer le chat",
"chat.everyone": "Tout le monde dans la session",
"chat.directMessage": "Message direct",
"chat.memberCount": "{count} membres",
"chat.memberCountOne": "1 membre",
"chat.error.direct": "Choisissez une personne pour un chat direct.",
"chat.error.groupName": "Donnez un nom au groupe.",
"chat.error.member": "Sélectionnez au moins un membre.",
"transaction.transfer": "Transfert",
"transaction.banker_adjust": "Ajustement banquier",
"transaction.banker_force_transfer": "Transfert forcé",
"status.lobby": "Lobby",
"status.active": "Active",
"status.ended": "Terminée",
"connection.idle": "inactif",
"connection.connecting": "connexion",
"connection.open": "connecté",
"connection.error": "erreur",
"error.parseResponse": "Impossible de lire la réponse du serveur",
"error.createSession": "Impossible de créer la session",
"error.joinSession": "Impossible de rejoindre la session",
"error.connectionNotReady": "Connexion non prête",
},
} as const;
type I18nKey = keyof typeof translations.en;
export function getLocale(): Locale {
if (typeof navigator !== "undefined") {
const raw = (navigator.languages?.[0] ?? navigator.language ?? "en").toLowerCase();
return raw.startsWith("fr") ? "fr" : "en";
}
return "en";
}
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
const table = translations[locale] ?? translations.en;
let template = table[key] ?? translations.en[key] ?? key;
if (vars) {
Object.entries(vars).forEach(([name, value]) => {
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
});
}
return template;
}
export function useI18n() {
const locale = useMemo(getLocale, []);
const t = useCallback(
(key: I18nKey, vars?: Record<string, string | number>) => translate(locale, key, vars),
[locale],
);
return { t, locale };
}
export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
return translate(getLocale(), key, vars);
}
export function formatTransactionKind(
kind: "transfer" | "banker_adjust" | "banker_force_transfer",
t: (key: I18nKey) => string,
) {
return t(`transaction.${kind}` as I18nKey);
}
export function formatStatus(
status: "lobby" | "active" | "ended",
t: (key: I18nKey) => string,
) {
return t(`status.${status}` as I18nKey);
}
export function formatConnectionState(
state: "idle" | "connecting" | "open" | "error",
t: (key: I18nKey) => string,
) {
return t(`connection.${state}` as I18nKey);
}

70
index.ts Normal file
View file

@ -0,0 +1,70 @@
import home from "./front/index.html";
import play from "./front/play.html";
import { apiRoutes } from "./server/api";
import { handleSocketMessage, registerSocket, unregisterSocket } from "./server/websocket";
const appleAppSiteAssociation = Bun.file("./.well-known/apple-app-site-association");
const assetLinks = Bun.file("./.well-known/assetlinks.json");
const server = Bun.serve({
routes: {
"/.well-known/apple-app-site-association": new Response(appleAppSiteAssociation, {
headers: { "Content-Type": "application/json" },
}),
"/.well-known/assetlinks.json": new Response(assetLinks, {
headers: { "Content-Type": "application/json" },
}),
"/": home,
"/play": play,
"/play/:sessionId": play,
"/play/:sessionId/lobby": play,
"/play/:sessionId/banker": play,
"/play/:sessionId/banker/:tab": play,
"/play/:sessionId/player": play,
"/play/:sessionId/player/:tab": play,
"/play/:sessionId/chat": play,
"/play/:sessionId/chat/new": play,
"/play/:sessionId/chat/:chatId": play,
...apiRoutes,
},
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const sessionId = url.searchParams.get("sessionId");
const playerId = url.searchParams.get("playerId");
if (!sessionId || !playerId) {
return new Response("Missing sessionId or playerId", { status: 400 });
}
const upgraded = server.upgrade(req, {
data: { sessionId, playerId },
});
if (upgraded) {
return;
}
return new Response("Unable to upgrade", { status: 400 });
}
return new Response("Not found", { status: 404 });
},
websocket: {
open(ws) {
const { sessionId, playerId } = ws.data as {
sessionId: string;
playerId: string;
};
registerSocket(ws, sessionId, playerId);
},
message(ws, message) {
handleSocketMessage(ws, message);
},
close(ws) {
unregisterSocket(ws);
},
},
development: {
hmr: true,
console: true,
},
});
console.log(`Listening on ${server.url}`);

41
mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

1
mobile/App.tsx Normal file
View file

@ -0,0 +1 @@
export { default } from "./src/App";

17
mobile/README.md Normal file
View file

@ -0,0 +1,17 @@
# Negopoly Mobile (React Native)
## Development server
Set the dev API base URL to your machine IP so the app can reach the Bun server.
Create a `.env` file in `mobile/`:
```
EXPO_PUBLIC_DEV_API_BASE_URL=http://<this-computer's-ip>:3000
```
The app will use `https://negopoly.fr` automatically in production builds.
## Run
```
npm run dev
```

46
mobile/app.json Normal file
View file

@ -0,0 +1,46 @@
{
"expo": {
"name": "Negopoly Companion",
"slug": "negopoly-companion",
"version": "1.0.0",
"platforms": ["ios", "android"],
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "negopoly",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"bundleIdentifier": "fr.negopoly.app",
"supportsTablet": true,
"appleTeamId": "VD9WQ6BYX2",
"associatedDomains": ["applinks:negopoly.fr"]
},
"android": {
"package": "fr.negopoly.app",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"intentFilters": [
{
"action": "VIEW",
"data": [
{
"scheme": "https",
"host": "negopoly.fr",
"pathPrefix": "/play"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

BIN
mobile/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

9
mobile/index.ts Normal file
View file

@ -0,0 +1,9 @@
import "react-native-gesture-handler";
import { registerRootComponent } from "expo";
import App from "./App";
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

8341
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
mobile/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"dev": "sh ./scripts/start-dev.sh",
"android": "expo run:android",
"ios": "expo run:ios"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.12.0",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.12.0",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "^6.0.20",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View file

@ -0,0 +1,16 @@
#!/bin/sh
set -e
IP="$(ipconfig getifaddr en0 2>/dev/null || true)"
if [ -z "$IP" ]; then
IP="$(ipconfig getifaddr en1 2>/dev/null || true)"
fi
if [ -z "$IP" ]; then
echo "Could not determine LAN IP (tried en0/en1)."
exit 1
fi
export EXPO_PUBLIC_DEV_API_BASE_URL="http://$IP:3000"
echo "Using EXPO_PUBLIC_DEV_API_BASE_URL=$EXPO_PUBLIC_DEV_API_BASE_URL"
exec npx expo start

141
mobile/src/App.tsx Normal file
View file

@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Linking } from "react-native";
import {
NavigationContainer,
type LinkingOptions,
useNavigationContainerRef,
} from "@react-navigation/native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import AppNavigator from "./navigation/AppNavigator";
import type { RootStackParamList } from "./navigation/types";
import { SessionProvider, useSession } from "./state/session-context";
import { getNavigationTheme, useTheme } from "./theme";
function extractGameId(url: string): string | null {
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
const match = path.match(/^\/play\/?([^/]+)?/);
const id = match?.[1]?.trim();
return id ? id : null;
}
if (parsed.host === "play") {
const id = path.replace(/^\//, "").trim();
return id ? id : null;
}
const fallbackMatch = path.match(/^\/play\/?([^/]+)?/);
const fallbackId = fallbackMatch?.[1]?.trim();
return fallbackId ? fallbackId : null;
} catch {
return null;
}
}
function logDeepLink(url: string) {
if (!__DEV__) return;
const gameId = extractGameId(url);
console.log(`[deep-link] url=${url} gameId=${gameId ?? "invalid"}`);
}
function RootNavigationGate() {
const manager = useSession();
const navigationRef = useNavigationContainerRef<RootStackParamList>();
const [navReady, setNavReady] = useState(false);
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null);
const theme = useTheme();
const navigationTheme = getNavigationTheme(theme);
const linking = useMemo<LinkingOptions<RootStackParamList>>(
() => ({
prefixes: ["negopoly://", "https://negopoly.fr"],
config: {
screens: {
Entry: "play/:gameId",
},
},
getInitialURL: async () => {
const url = await Linking.getInitialURL();
if (url) {
lastLinkRef.current = url;
logDeepLink(url);
}
return url;
},
subscribe: (listener) => {
const onReceiveURL = ({ url }: { url: string }) => {
if (!url) return;
if (lastLinkRef.current === url) return;
lastLinkRef.current = url;
logDeepLink(url);
listener(url);
};
const subscription = Linking.addEventListener("url", onReceiveURL);
return () => subscription.remove();
},
}),
[],
);
useEffect(() => {
if (!navReady || !navigationRef.isReady()) return;
let target: keyof RootStackParamList;
if (!manager.sessionId) {
target = "Entry";
} else if (!manager.session) {
target = manager.connectionState === "error" ? "Entry" : "Lobby";
} else if (manager.session.status === "lobby") {
target = "Lobby";
} else if (manager.isBanker) {
target = "BankerTabs";
} else {
target = "PlayerTabs";
}
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute?.name === target || lastTargetRef.current === target) {
return;
}
navigationRef.reset({
index: 0,
routes: [{ name: target }],
});
lastTargetRef.current = target;
}, [
manager.sessionId,
manager.session,
manager.isBanker,
manager.connectionState,
navReady,
navigationRef,
]);
return (
<NavigationContainer
ref={navigationRef}
onReady={() => setNavReady(true)}
linking={linking}
theme={navigationTheme}
>
<AppNavigator />
</NavigationContainer>
);
}
export default function App() {
const theme = useTheme();
return (
<SafeAreaProvider>
<StatusBar
style={theme.dark ? "light" : "dark"}
backgroundColor={theme.colors.background}
/>
<SessionProvider>
<RootNavigationGate />
</SessionProvider>
</SafeAreaProvider>
);
}

View file

@ -0,0 +1,51 @@
import React, { useMemo } from "react";
import { StyleSheet, Text, View } from "react-native";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
type EmpOverlayProps = {
visible: boolean;
reason?: string | null;
};
export default function EmpOverlay({ visible, reason }: EmpOverlayProps) {
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
if (!visible) return null;
const reasonText = reason?.trim() || t("blackout.defaultReason");
return (
<View style={styles.overlay}>
<Text style={styles.title}>{t("blackout.title")}</Text>
<Text style={styles.subtitle}>
{t("blackout.active")} · {reasonText}
</Text>
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(12, 24, 36, 0.88)",
alignItems: "center",
justifyContent: "center",
padding: 24,
zIndex: 10,
},
title: {
fontSize: 24,
fontWeight: "700",
color: "#ffffff",
marginBottom: 8,
},
subtitle: {
color: "rgba(255, 255, 255, 0.8)",
textAlign: "center",
},
});

View file

@ -0,0 +1,79 @@
import React, { useCallback, useMemo } from "react";
import { Alert, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useI18n } from "../i18n";
import { useSession } from "../state/session-context";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
type ExitGameButtonProps = {
mode?: "header" | "full";
};
export default function ExitGameButton({ mode = "header" }: ExitGameButtonProps) {
const { t } = useI18n();
const manager = useSession();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const handlePress = useCallback(() => {
Alert.alert(t("session.exitPrompt"), t("session.exitMessage"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.exit"),
style: "destructive",
onPress: () => manager.leaveSession(),
},
]);
}, [manager, t]);
if (!manager.sessionId) return null;
if (mode === "full") {
return (
<TouchableOpacity style={styles.fullButton} onPress={handlePress}>
<Text style={styles.fullButtonText}>{t("session.exit")}</Text>
</TouchableOpacity>
);
}
return (
<TouchableOpacity style={styles.headerButton} onPress={handlePress}>
<View style={styles.headerPill}>
<Text style={styles.headerText}>{t("session.exit")}</Text>
</View>
</TouchableOpacity>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
headerButton: {
paddingHorizontal: 8,
paddingVertical: 6,
},
headerPill: {
borderRadius: 999,
borderWidth: 1,
borderColor: theme.colors.danger,
paddingHorizontal: 10,
paddingVertical: 4,
},
headerText: {
color: theme.colors.danger,
fontSize: 12,
fontWeight: "600",
},
fullButton: {
borderRadius: 999,
borderWidth: 1,
borderColor: theme.colors.danger,
paddingVertical: 12,
alignItems: "center",
backgroundColor: theme.colors.surface,
marginTop: 8,
},
fullButtonText: {
color: theme.colors.danger,
fontWeight: "600",
},
});

21
mobile/src/config/api.ts Normal file
View file

@ -0,0 +1,21 @@
import Constants from "expo-constants";
const PROD_BASE_URL = "https://negopoly.fr";
const DEV_BASE_URL =
process.env.EXPO_PUBLIC_DEV_API_BASE_URL ||
(Constants.expoConfig?.extra as { devApiBaseUrl?: string } | undefined)?.devApiBaseUrl ||
"http://<this-computer's-ip>:3000";
function normalizeBaseUrl(value: string) {
return value.replace(/\/$/, "");
}
export function getApiBaseUrl() {
return normalizeBaseUrl(__DEV__ ? DEV_BASE_URL : PROD_BASE_URL);
}
export function getWsUrl(sessionId: string, playerId: string) {
const base = getApiBaseUrl().replace(/^http/, "ws");
const params = new URLSearchParams({ sessionId, playerId });
return `${base}/ws?${params.toString()}`;
}

346
mobile/src/i18n.ts Normal file
View file

@ -0,0 +1,346 @@
import { useCallback, useMemo } from "react";
type Locale = "en" | "fr";
const translations = {
en: {
"app.name": "Negopoly Companion",
"common.loading": "Loading...",
"common.loadingChats": "Loading chats...",
"common.loadingChat": "Loading chat...",
"common.loadingLobby": "Joining lobby...",
"common.notice": "Notice:",
"common.online": "online",
"common.offline": "offline",
"common.dummy": "Dummy",
"common.player": "Player",
"common.banker": "Banker",
"common.bank": "Bank",
"common.from": "From",
"common.to": "To",
"common.guest": "Guest",
"common.you": "You",
"common.join": "Join",
"common.continue": "Continue",
"common.send": "Send",
"common.reset": "Reset",
"common.cancel": "Cancel",
"common.done": "Done",
"common.exit": "Exit",
"common.save": "Save",
"common.load": "Load",
"common.noReason": "No reason provided",
"entry.subtitle": "Create or join a session.",
"entry.joinTitle": "Join a session",
"entry.sessionCode": "Session code",
"entry.newPlayer": "New player",
"entry.playerName": "Player name",
"entry.takeoverTitle": "Take over dummy",
"entry.alreadyConnected": "You are already connected.",
"entry.dummyId": "Dummy ID (select later)",
"entry.selectDummy": "Select a dummy",
"entry.yourNameOptional": "Your name (optional)",
"entry.requestTakeover": "Request takeover",
"entry.noDummies": "No dummies available yet.",
"entry.createTitle": "Create a session",
"entry.bankerName": "Banker name",
"entry.openVault": "Open the vault",
"entry.alert.enterCode": "Enter a session code",
"entry.alert.sessionNotFound": "Session not found",
"entry.alert.selectDummy": "Select a dummy player",
"lobby.title": "Lobby",
"lobby.code": "Code: {code}",
"lobby.startGame": "Start game",
"lobby.addDummyTitle": "Add dummy player",
"lobby.addDummySubtitle": "Create a player for someone without the app.",
"lobby.enterDummyName": "Enter a dummy name",
"lobby.addDummyButton": "Add dummy",
"session.exit": "Exit game",
"session.exitPrompt": "Leave this game?",
"session.exitMessage": "You can rejoin later with the session code.",
"transfers.title": "Make a transfer",
"transfers.subtitle": "Move funds instantly between players.",
"transfers.from": "From",
"transfers.to": "To",
"transfers.availableBalance": "Available balance",
"transfers.noPlayers": "No other players available yet.",
"transfers.dummy": "Dummy player",
"transfers.player": "Player",
"transfers.amount": "Amount",
"transfers.note": "Note",
"transfers.notePlaceholder": "What is this for?",
"transfers.sending": "Sending",
"transfers.summary": "₦{amount} to {name}",
"transfers.selectPlayer": "Select a player",
"transfers.send": "Send transfer",
"transfers.error": "Choose a player and a valid amount.",
"home.balance": "Balance",
"home.recent": "Recent activity",
"home.noActivity": "No activity yet.",
"blackout.title": "EMP",
"blackout.defaultReason": "EMP in effect",
"blackout.active": "EMP active",
"banker.dashboard.title": "Session activity",
"banker.tools.title": "Banker tools",
"banker.tools.playersTab": "Players",
"banker.tools.adminTab": "Admin",
"banker.tools.playerOverview": "Player overview",
"banker.tools.noPlayers": "No players yet.",
"banker.tools.adjust": "Adjust balance",
"banker.tools.apply": "Apply",
"banker.tools.forceTransfer": "Force transfer",
"banker.tools.force": "Force",
"banker.tools.createDummy": "Create dummy",
"banker.tools.addDummy": "Add dummy",
"banker.tools.blackout": "EMP",
"banker.tools.blackoutActive": "EMP active",
"banker.tools.blackoutReason": "EMP reason",
"banker.tools.blackoutEnable": "Enable EMP",
"banker.tools.blackoutDisable": "Disable EMP",
"banker.tools.trigger": "Trigger",
"banker.tools.endSession": "End session",
"banker.tools.playerId": "Player ID",
"banker.tools.amountAdjust": "Amount (+/-)",
"banker.tools.reason": "Reason",
"banker.tools.fromPlayer": "From player ID",
"banker.tools.toPlayer": "To player ID",
"banker.tools.amount": "Amount",
"banker.tools.note": "Note",
"banker.tools.dummyName": "Dummy name",
"banker.tools.startingBalance": "Starting balance",
"banker.stateTitle": "GameState",
"banker.stateSubtitle": "Export or restore the current session.",
"banker.downloadState": "Export GameState",
"banker.loadFromFile": "Load GameState",
"banker.importPlaceholder": "Paste GameState JSON here",
"banker.loadFromStorage": "Load from saved snapshots",
"banker.stateDownloaded": "GameState exported.",
"banker.stateDownloadError": "Unable to export GameState.",
"banker.stateLoaded": "GameState loaded.",
"banker.stateLoadError": "Unable to load GameState.",
"banker.stateLoadInvalid": "Invalid GameState JSON.",
"banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Save rolling snapshots on this device.",
"banker.autosaveEnabled": "Enable AutoSave",
"banker.autosaveInterval": "Minutes between saves",
"banker.autosaveKeep": "Snapshots to keep",
"banker.autosaveNow": "Save now",
"banker.autosaveSaved": "AutoSave captured.",
"banker.autosaveFailed": "AutoSave failed.",
"banker.noAutosaves": "No autosaves yet.",
"banker.savedAt": "Saved {time}",
"chat.title": "Chats",
"chat.noMessages": "No messages yet",
"chat.global": "Global chat",
"chat.newTitle": "New chat",
"chat.direct": "Direct",
"chat.group": "Group",
"chat.groupName": "Group name",
"chat.choosePlayers": "Choose players",
"chat.startChat": "Start chat",
"chat.notFound": "Chat not found.",
"chat.messagePlaceholder": "Message",
"tabs.home": "Home",
"tabs.transfers": "Transfers",
"tabs.chat": "Chat",
"tabs.dashboard": "Dashboard",
"tabs.tools": "Tools",
"transaction.transfer": "Transfer",
"transaction.banker_adjust": "Banker adjustment",
"transaction.banker_force_transfer": "Forced transfer",
"error.parseResponse": "Unable to parse server response",
"error.createSession": "Unable to create session",
"error.joinSession": "Unable to join session",
"error.loadSessionInfo": "Unable to load session info",
"error.connectionNotReady": "Connection not ready",
},
fr: {
"app.name": "Negopoly Companion",
"common.loading": "Chargement...",
"common.loadingChats": "Chargement des chats...",
"common.loadingChat": "Chargement du chat...",
"common.loadingLobby": "Connexion au lobby...",
"common.notice": "Info :",
"common.online": "en ligne",
"common.offline": "hors ligne",
"common.dummy": "Dummy",
"common.player": "Joueur",
"common.banker": "Banquier",
"common.bank": "Banque",
"common.from": "De",
"common.to": "Vers",
"common.guest": "Invité",
"common.you": "Vous",
"common.join": "Rejoindre",
"common.continue": "Continuer",
"common.send": "Envoyer",
"common.reset": "Réinitialiser",
"common.cancel": "Annuler",
"common.done": "OK",
"common.exit": "Quitter",
"common.save": "Enregistrer",
"common.load": "Charger",
"common.noReason": "Aucune raison fournie",
"entry.subtitle": "Créez ou rejoignez une session.",
"entry.joinTitle": "Rejoindre une session",
"entry.sessionCode": "Code de session",
"entry.newPlayer": "Nouveau joueur",
"entry.playerName": "Nom du joueur",
"entry.takeoverTitle": "Reprendre un dummy",
"entry.alreadyConnected": "Vous êtes déjà connecté.",
"entry.dummyId": "ID du dummy (plus tard)",
"entry.selectDummy": "Sélectionnez un dummy",
"entry.yourNameOptional": "Votre nom (optionnel)",
"entry.requestTakeover": "Demander la reprise",
"entry.noDummies": "Aucun dummy disponible pour le moment.",
"entry.createTitle": "Créer une session",
"entry.bankerName": "Nom du banquier",
"entry.openVault": "Ouvrir le coffre",
"entry.alert.enterCode": "Entrez un code de session",
"entry.alert.sessionNotFound": "Session introuvable",
"entry.alert.selectDummy": "Sélectionnez un dummy",
"lobby.title": "Lobby",
"lobby.code": "Code : {code}",
"lobby.startGame": "Démarrer la partie",
"lobby.addDummyTitle": "Ajouter un dummy",
"lobby.addDummySubtitle": "Créez un joueur pour quelqu'un sans l'application.",
"lobby.enterDummyName": "Entrez un nom de dummy",
"lobby.addDummyButton": "Ajouter un dummy",
"session.exit": "Quitter la partie",
"session.exitPrompt": "Quitter cette session ?",
"session.exitMessage": "Vous pourrez rejoindre plus tard avec le code.",
"transfers.title": "Faire un transfert",
"transfers.subtitle": "Transférez des fonds instantanément entre joueurs.",
"transfers.from": "De",
"transfers.to": "Vers",
"transfers.availableBalance": "Solde disponible",
"transfers.noPlayers": "Aucun autre joueur disponible.",
"transfers.dummy": "Dummy",
"transfers.player": "Joueur",
"transfers.amount": "Montant",
"transfers.note": "Note",
"transfers.notePlaceholder": "Pour quoi ?",
"transfers.sending": "Envoi",
"transfers.summary": "₦{amount} à {name}",
"transfers.selectPlayer": "Choisissez un joueur",
"transfers.send": "Envoyer le transfert",
"transfers.error": "Choisissez un joueur et un montant valide.",
"home.balance": "Solde",
"home.recent": "Activité récente",
"home.noActivity": "Aucune activité.",
"blackout.title": "EMP",
"blackout.defaultReason": "EMP en cours",
"blackout.active": "EMP actif",
"banker.dashboard.title": "Activité de la session",
"banker.tools.title": "Outils banquier",
"banker.tools.playersTab": "Joueurs",
"banker.tools.adminTab": "Admin",
"banker.tools.playerOverview": "Vue joueur",
"banker.tools.noPlayers": "Pas encore de joueurs.",
"banker.tools.adjust": "Ajuster le solde",
"banker.tools.apply": "Appliquer",
"banker.tools.forceTransfer": "Forcer un transfert",
"banker.tools.force": "Forcer",
"banker.tools.createDummy": "Créer un dummy",
"banker.tools.addDummy": "Ajouter un dummy",
"banker.tools.blackout": "EMP",
"banker.tools.blackoutActive": "EMP actif",
"banker.tools.blackoutReason": "Raison de l'EMP",
"banker.tools.blackoutEnable": "Activer l'EMP",
"banker.tools.blackoutDisable": "Désactiver l'EMP",
"banker.tools.trigger": "Déclencher",
"banker.tools.endSession": "Terminer la session",
"banker.tools.playerId": "ID joueur",
"banker.tools.amountAdjust": "Montant (+/-)",
"banker.tools.reason": "Raison",
"banker.tools.fromPlayer": "ID joueur source",
"banker.tools.toPlayer": "ID joueur cible",
"banker.tools.amount": "Montant",
"banker.tools.note": "Note",
"banker.tools.dummyName": "Nom du dummy",
"banker.tools.startingBalance": "Solde de départ",
"banker.stateTitle": "État de partie",
"banker.stateSubtitle": "Exportez ou restaurez la session.",
"banker.downloadState": "Exporter l'état",
"banker.loadFromFile": "Charger l'état",
"banker.importPlaceholder": "Collez le JSON d'état ici",
"banker.loadFromStorage": "Charger depuis les sauvegardes",
"banker.stateDownloaded": "État exporté.",
"banker.stateDownloadError": "Impossible d'exporter l'état.",
"banker.stateLoaded": "État chargé.",
"banker.stateLoadError": "Impossible de charger l'état.",
"banker.stateLoadInvalid": "JSON d'état invalide.",
"banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.",
"banker.autosaveEnabled": "Activer AutoSave",
"banker.autosaveInterval": "Minutes entre sauvegardes",
"banker.autosaveKeep": "Sauvegardes à conserver",
"banker.autosaveNow": "Sauvegarder maintenant",
"banker.autosaveSaved": "Sauvegarde effectuée.",
"banker.autosaveFailed": "Échec de la sauvegarde.",
"banker.noAutosaves": "Aucune sauvegarde.",
"banker.savedAt": "Sauvé {time}",
"chat.title": "Chats",
"chat.noMessages": "Aucun message",
"chat.global": "Chat global",
"chat.newTitle": "Nouveau chat",
"chat.direct": "Direct",
"chat.group": "Groupe",
"chat.groupName": "Nom du groupe",
"chat.choosePlayers": "Choisir des joueurs",
"chat.startChat": "Démarrer le chat",
"chat.notFound": "Chat introuvable.",
"chat.messagePlaceholder": "Message",
"tabs.home": "Accueil",
"tabs.transfers": "Transferts",
"tabs.chat": "Chat",
"tabs.dashboard": "Tableau",
"tabs.tools": "Outils",
"transaction.transfer": "Transfert",
"transaction.banker_adjust": "Ajustement banquier",
"transaction.banker_force_transfer": "Transfert forcé",
"error.parseResponse": "Impossible de lire la réponse du serveur",
"error.createSession": "Impossible de créer la session",
"error.joinSession": "Impossible de rejoindre la session",
"error.loadSessionInfo": "Impossible de charger les infos de session",
"error.connectionNotReady": "Connexion non prête",
},
} as const;
type I18nKey = keyof typeof translations.en;
export function getLocale(): Locale {
const raw = Intl.DateTimeFormat().resolvedOptions().locale?.toLowerCase() ?? "en";
return raw.startsWith("fr") ? "fr" : "en";
}
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
const table = translations[locale] ?? translations.en;
let template = table[key] ?? translations.en[key] ?? key;
if (vars) {
Object.entries(vars).forEach(([name, value]) => {
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
});
}
return template;
}
export function useI18n() {
const locale = useMemo(getLocale, []);
const t = useCallback(
(key: I18nKey, vars?: Record<string, string | number>) => translate(locale, key, vars),
[locale],
);
return { t, locale };
}
export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
return translate(getLocale(), key, vars);
}
export function formatTransactionKind(
kind: "transfer" | "banker_adjust" | "banker_force_transfer",
t: (key: I18nKey) => string,
) {
return t(`transaction.${kind}` as I18nKey);
}

View file

@ -0,0 +1,199 @@
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import EntryScreen from "../screens/EntryScreen";
import LobbyScreen from "../screens/LobbyScreen";
import PlayerHomeScreen from "../screens/PlayerHomeScreen";
import PlayerTransfersScreen from "../screens/PlayerTransfersScreen";
import BankerDashboardScreen from "../screens/BankerDashboardScreen";
import BankerToolsScreen from "../screens/BankerToolsScreen";
import ChatListScreen from "../screens/chat/ChatListScreen";
import ChatThreadScreen from "../screens/chat/ChatThreadScreen";
import ChatNewScreen from "../screens/chat/ChatNewScreen";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import ExitGameButton from "../components/ExitGameButton";
import type {
BankerTabsParamList,
ChatStackParamList,
PlayerTabsParamList,
RootStackParamList,
} from "./types";
const RootStack = createNativeStackNavigator<RootStackParamList>();
const PlayerTabs = createBottomTabNavigator<PlayerTabsParamList>();
const BankerTabs = createBottomTabNavigator<BankerTabsParamList>();
const ChatStack = createNativeStackNavigator<ChatStackParamList>();
function ChatStackNavigator() {
const { t } = useI18n();
const theme = useTheme();
return (
<ChatStack.Navigator
screenOptions={{
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerShadowVisible: false,
contentStyle: { backgroundColor: theme.colors.background },
headerRight: () => <ExitGameButton />,
}}
>
<ChatStack.Screen
name="ChatList"
component={ChatListScreen}
options={{ title: t("chat.title") }}
/>
<ChatStack.Screen
name="ChatThread"
component={ChatThreadScreen}
options={{ title: t("tabs.chat") }}
/>
<ChatStack.Screen
name="ChatNew"
component={ChatNewScreen}
options={{ title: t("chat.newTitle") }}
/>
</ChatStack.Navigator>
);
}
export function PlayerTabsNavigator() {
const { t } = useI18n();
const theme = useTheme();
return (
<PlayerTabs.Navigator
screenOptions={{
tabBarStyle: {
paddingBottom: 6,
height: 60,
backgroundColor: theme.colors.surface,
borderTopColor: theme.colors.border,
},
tabBarActiveTintColor: theme.colors.tabActive,
tabBarInactiveTintColor: theme.colors.tabInactive,
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerRight: () => <ExitGameButton />,
}}
>
<PlayerTabs.Screen
name="PlayerHome"
component={PlayerHomeScreen}
options={{
title: t("tabs.home"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="wallet-outline" size={size} color={color} />
),
}}
/>
<PlayerTabs.Screen
name="PlayerTransfers"
component={PlayerTransfersScreen}
options={{
title: t("tabs.transfers"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="swap-horizontal-outline" size={size} color={color} />
),
}}
/>
<PlayerTabs.Screen
name="PlayerChat"
component={ChatStackNavigator}
options={{
headerShown: false,
title: t("tabs.chat"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="chatbubble-ellipses-outline" size={size} color={color} />
),
}}
/>
</PlayerTabs.Navigator>
);
}
export function BankerTabsNavigator() {
const { t } = useI18n();
const theme = useTheme();
return (
<BankerTabs.Navigator
screenOptions={{
tabBarStyle: {
paddingBottom: 6,
height: 60,
backgroundColor: theme.colors.surface,
borderTopColor: theme.colors.border,
},
tabBarActiveTintColor: theme.colors.tabActive,
tabBarInactiveTintColor: theme.colors.tabInactive,
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerRight: () => <ExitGameButton />,
}}
>
<BankerTabs.Screen
name="BankerDashboard"
component={BankerDashboardScreen}
options={{
title: t("tabs.dashboard"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="stats-chart-outline" size={size} color={color} />
),
}}
/>
<BankerTabs.Screen
name="BankerTools"
component={BankerToolsScreen}
options={{
title: t("tabs.tools"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="construct-outline" size={size} color={color} />
),
}}
/>
<BankerTabs.Screen
name="BankerChat"
component={ChatStackNavigator}
options={{
headerShown: false,
title: t("tabs.chat"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="chatbubble-ellipses-outline" size={size} color={color} />
),
}}
/>
</BankerTabs.Navigator>
);
}
export default function AppNavigator() {
const { t } = useI18n();
const theme = useTheme();
return (
<RootStack.Navigator
screenOptions={{
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerShadowVisible: false,
contentStyle: { backgroundColor: theme.colors.background },
}}
>
<RootStack.Screen name="Entry" component={EntryScreen} options={{ headerShown: false }} />
<RootStack.Screen
name="Lobby"
component={LobbyScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="PlayerTabs"
component={PlayerTabsNavigator}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="BankerTabs"
component={BankerTabsNavigator}
options={{ headerShown: false }}
/>
</RootStack.Navigator>
);
}

View file

@ -0,0 +1,24 @@
export type RootStackParamList = {
Entry: { gameId?: string } | undefined;
Lobby: undefined;
PlayerTabs: undefined;
BankerTabs: undefined;
};
export type ChatStackParamList = {
ChatList: undefined;
ChatThread: { chatId: string };
ChatNew: undefined;
};
export type PlayerTabsParamList = {
PlayerHome: undefined;
PlayerTransfers: undefined;
PlayerChat: undefined;
};
export type BankerTabsParamList = {
BankerDashboard: undefined;
BankerTools: undefined;
BankerChat: undefined;
};

View file

@ -0,0 +1,181 @@
import React, { useMemo } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { useSession } from "../state/session-context";
import { formatTransactionKind, getLocale, useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
import type { Player, Transaction } from "../shared/types";
function formatMoney(amount: number) {
const value = new Intl.NumberFormat(getLocale(), {
maximumFractionDigits: 0,
}).format(amount);
return `${value}`;
}
function formatTransactionTimestamp(value: number) {
const date = new Date(value);
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
const time = date.toLocaleTimeString(getLocale(), { hour: "2-digit", minute: "2-digit" });
if (sameDay) return time;
const day = date.toLocaleDateString(getLocale(), { month: "short", day: "numeric" });
return `${day} ${time}`;
}
function getTransactionLabel(
kind: string,
note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim();
return trimmed || t("common.noReason");
}
return formatTransactionKind(kind, t);
}
function getTransactionDisplay(
transaction: Transaction,
viewerId: string | null | undefined,
players: Player[],
t: ReturnType<typeof useI18n>["t"],
) {
const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId);
let outgoing = false;
let counterparty = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0;
counterparty = t("common.bank");
} else if (transaction.kind === "transfer" || transaction.kind === "banker_force_transfer") {
if (viewerId && transaction.fromId === viewerId) {
outgoing = true;
counterparty = to?.name ?? t("common.player");
} else if (viewerId && transaction.toId === viewerId) {
outgoing = false;
counterparty = from?.name ?? t("common.player");
} else {
outgoing = true;
counterparty = to?.name ?? t("common.player");
}
}
return {
label,
subtitle: viewerId
? `${outgoing ? t("common.to") : t("common.from")} ${counterparty} · ${timeLabel}`
: transaction.kind === "banker_adjust"
? outgoing
? `${to?.name ?? t("common.player")}${t("common.bank")} · ${timeLabel}`
: `${t("common.bank")}${to?.name ?? t("common.player")} · ${timeLabel}`
: `${from?.name ?? t("common.player")}${to?.name ?? t("common.player")} · ${timeLabel}`,
amount: `${outgoing ? "-" : ""}${formatMoney(absAmount)}`,
outgoing,
};
}
export default function BankerDashboardScreen() {
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
if (!manager.session) {
return (
<View style={styles.container}>
<Text style={styles.helper}>{t("common.loading")}</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>{t("banker.dashboard.title")}</Text>
<FlatList
data={manager.session.transactions.slice(0, 10)}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => {
const display = getTransactionDisplay(
item,
null,
manager.session?.players ?? [],
t,
);
return (
<View style={styles.listItem}>
<View style={styles.listContent}>
<Text style={styles.listTitle}>{display.label}</Text>
<Text style={styles.listSubtitle}>{display.subtitle}</Text>
</View>
<Text
style={[styles.listAmount, display.outgoing ? styles.amountNegative : null]}
>
{display.amount}
</Text>
</View>
);
}}
ListEmptyComponent={<Text style={styles.helper}>{t("home.noActivity")}</Text>}
/>
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: theme.colors.background,
},
title: {
fontSize: 20,
fontWeight: "700",
marginBottom: 16,
color: theme.colors.text,
},
list: {
gap: 10,
},
listItem: {
backgroundColor: theme.colors.surface,
borderRadius: 12,
padding: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
listContent: {
flex: 1,
paddingRight: 12,
},
listTitle: {
fontWeight: "600",
color: theme.colors.text,
},
listSubtitle: {
marginTop: 2,
fontSize: 12,
color: theme.colors.textMuted,
},
listAmount: {
fontWeight: "600",
color: theme.colors.primary,
},
amountNegative: {
color: theme.colors.danger,
},
helper: {
color: theme.colors.textMuted,
},
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,391 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation, useRoute } from "@react-navigation/native";
import type { RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/types";
import { useSession } from "../state/session-context";
import type { SessionPreview } from "../shared/types";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
export default function EntryScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "Entry">>();
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const handledLinkRef = useRef<string | null>(null);
const insets = useSafeAreaInsets();
const contentStyle = useMemo(
() => [
styles.container,
{
paddingTop: insets.top + 20,
paddingBottom: insets.bottom + 20,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
],
[styles.container, insets.top, insets.bottom, insets.left, insets.right],
);
const [createName, setCreateName] = useState("");
const [joinCode, setJoinCode] = useState("");
const [joinStep, setJoinStep] = useState<"code" | "choice">("code");
const [joinPreview, setJoinPreview] = useState<SessionPreview | null>(null);
const [joinName, setJoinName] = useState("");
const [takeoverName, setTakeoverName] = useState("");
const [takeoverDummyId, setTakeoverDummyId] = useState("");
const [showDummyOptions, setShowDummyOptions] = useState(false);
const dummyOptions = useMemo(
() => joinPreview?.players.filter((player) => player.isDummy) ?? [],
[joinPreview],
);
const storedPlayer = joinPreview?.players.find((player) => player.id === manager.playerId);
const takeoverDisabled = storedPlayer?.connected === true;
async function handleCreate() {
const data = await manager.createSession(createName.trim());
if (data) {
navigation.replace("Lobby");
}
}
async function handleJoinPreview() {
if (!joinCode.trim()) {
Alert.alert(t("entry.alert.enterCode"));
return;
}
const preview = await manager.fetchSessionPreview(joinCode.trim().toUpperCase());
if (!preview) {
Alert.alert(t("entry.alert.sessionNotFound"));
return;
}
setJoinPreview(preview);
setJoinStep("choice");
}
useEffect(() => {
const raw = route.params?.gameId;
if (typeof raw !== "string") return;
const normalized = raw.trim();
if (!normalized) {
if (__DEV__) {
console.log("[deep-link] invalid gameId");
}
return;
}
const code = normalized.toUpperCase();
if (handledLinkRef.current === code) return;
handledLinkRef.current = code;
if (__DEV__) {
console.log(`[deep-link] navigating to session ${code}`);
}
setJoinCode(code);
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverName("");
setTakeoverDummyId("");
manager.fetchSessionPreview(code).then((preview) => {
if (!preview) {
Alert.alert(t("entry.alert.sessionNotFound"));
return;
}
setJoinPreview(preview);
setJoinStep("choice");
});
}, [route.params?.gameId, manager, t]);
async function handleJoinNew() {
if (!joinPreview) return;
const data = await manager.joinSession(joinPreview.code, joinName.trim());
if (data) {
navigation.replace("Lobby");
}
}
async function handleTakeover() {
if (!joinPreview) return;
if (!takeoverDummyId) {
Alert.alert(t("entry.alert.selectDummy"));
return;
}
const data = await manager.joinSession(
joinPreview.code,
takeoverName.trim() || t("common.guest"),
);
if (data) {
manager.setPendingTakeoverId(takeoverDummyId);
navigation.replace("Lobby");
}
}
useEffect(() => {
if (joinStep === "code" || !joinPreview) {
setShowDummyOptions(false);
}
}, [joinStep, joinPreview]);
return (
<ScrollView style={styles.scroll} contentContainerStyle={contentStyle}>
<Text style={styles.title}>{t("app.name")}</Text>
<Text style={styles.subtitle}>{t("entry.subtitle")}</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("entry.joinTitle")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.sessionCode")}
placeholderTextColor={placeholderColor}
autoCapitalize="characters"
value={joinCode}
onChangeText={(value) => {
setJoinCode(value.toUpperCase());
if (joinStep === "choice") {
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverName("");
setTakeoverDummyId("");
setShowDummyOptions(false);
}
}}
/>
{joinStep === "code" ? (
<TouchableOpacity style={styles.buttonSecondary} onPress={handleJoinPreview}>
<Text style={styles.buttonSecondaryText}>{t("common.continue")}</Text>
</TouchableOpacity>
) : null}
{joinStep === "choice" && joinPreview ? (
<View style={styles.choiceGrid}>
<View style={styles.choiceCard}>
<Text style={styles.choiceTitle}>{t("entry.newPlayer")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.playerName")}
placeholderTextColor={placeholderColor}
value={joinName}
onChangeText={setJoinName}
/>
<TouchableOpacity style={styles.buttonSecondary} onPress={handleJoinNew}>
<Text style={styles.buttonSecondaryText}>{t("common.join")}</Text>
</TouchableOpacity>
</View>
<View style={styles.choiceCard}>
<Text style={styles.choiceTitle}>{t("entry.takeoverTitle")}</Text>
{takeoverDisabled ? (
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
) : (
<>
<View style={styles.dropdown}>
<TouchableOpacity
style={styles.dropdownButton}
onPress={() => {
if (dummyOptions.length === 0) return;
setShowDummyOptions((prev) => !prev);
}}
>
<Text style={styles.dropdownText}>
{dummyOptions.find((player) => player.id === takeoverDummyId)?.name
? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
: t("entry.selectDummy")}
</Text>
</TouchableOpacity>
{showDummyOptions && dummyOptions.length > 0 ? (
<View style={styles.dropdownList}>
{dummyOptions.map((player) => (
<TouchableOpacity
key={player.id}
style={[
styles.dropdownItem,
player.id === takeoverDummyId ? styles.dropdownItemActive : null,
]}
onPress={() => {
setTakeoverDummyId(player.id);
setShowDummyOptions(false);
}}
>
<Text style={styles.dropdownItemText}>{player.name}</Text>
<Text style={styles.dropdownItemMeta}>{player.id}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</View>
<TextInput
style={styles.input}
placeholder={t("entry.yourNameOptional")}
placeholderTextColor={placeholderColor}
value={takeoverName}
onChangeText={setTakeoverName}
/>
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
<Text style={styles.buttonSecondaryText}>{t("entry.requestTakeover")}</Text>
</TouchableOpacity>
</>
)}
{!takeoverDisabled && dummyOptions.length === 0 ? (
<Text style={styles.helper}>{t("entry.noDummies")}</Text>
) : null}
</View>
</View>
) : null}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("entry.createTitle")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.bankerName")}
placeholderTextColor={placeholderColor}
value={createName}
onChangeText={setCreateName}
/>
<TouchableOpacity style={styles.button} onPress={handleCreate}>
<Text style={styles.buttonText}>{t("entry.openVault")}</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
padding: 0,
gap: 16,
},
title: {
fontSize: 28,
fontWeight: "700",
color: theme.colors.text,
},
subtitle: {
fontSize: 16,
color: theme.colors.textMuted,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 16,
shadowColor: "#000",
shadowOpacity: theme.dark ? 0.2 : 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
marginBottom: 12,
color: theme.colors.text,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 10,
},
dropdown: {
gap: 6,
marginBottom: 10,
},
dropdownButton: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
},
dropdownText: {
color: theme.colors.inputText,
fontWeight: "600",
},
dropdownList: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
backgroundColor: theme.colors.surface,
overflow: "hidden",
},
dropdownItem: {
paddingHorizontal: 12,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
},
dropdownItemActive: {
backgroundColor: theme.colors.accentSurface,
},
dropdownItemText: {
fontWeight: "600",
color: theme.colors.text,
},
dropdownItemMeta: {
color: theme.colors.textMuted,
fontSize: 12,
},
button: {
backgroundColor: theme.colors.primary,
paddingVertical: 12,
borderRadius: 999,
alignItems: "center",
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
buttonSecondary: {
backgroundColor: theme.colors.secondary,
paddingVertical: 12,
borderRadius: 999,
alignItems: "center",
},
buttonSecondaryText: {
color: theme.colors.secondaryText,
fontWeight: "600",
},
choiceGrid: {
marginTop: 12,
gap: 12,
},
choiceCard: {
backgroundColor: theme.colors.surfaceAlt,
borderRadius: 12,
padding: 12,
},
choiceTitle: {
fontWeight: "600",
marginBottom: 8,
color: theme.colors.text,
},
helper: {
fontSize: 12,
color: theme.colors.textMuted,
},
});

View file

@ -0,0 +1,235 @@
import React, { useEffect, useMemo, useState } from "react";
import {
FlatList,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/types";
import { useSession } from "../state/session-context";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
import ExitGameButton from "../components/ExitGameButton";
export default function LobbyScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const [dummyName, setDummyName] = useState("");
const [dummyBalance, setDummyBalance] = useState("1500");
const insets = useSafeAreaInsets();
const topInset = insets.top || (Platform.OS === "ios" ? 44 : 0);
const containerStyle = useMemo(
() => [
styles.container,
{
paddingTop: topInset + 20,
paddingBottom: insets.bottom + 20,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
],
[
styles.container,
topInset,
insets.bottom,
insets.left,
insets.right,
],
);
useEffect(() => {
if (!manager.session || !manager.me) return;
if (manager.session.status === "active") {
navigation.replace(manager.isBanker ? "BankerTabs" : "PlayerTabs");
}
}, [manager.session, manager.me, manager.isBanker, navigation]);
if (!manager.session || !manager.me) {
return (
<View style={containerStyle}>
<Text style={styles.title}>{t("common.loadingLobby")}</Text>
{manager.error ? <Text style={styles.helper}>{manager.error}</Text> : null}
<ExitGameButton mode="full" />
</View>
);
}
const canStart = manager.isBanker && manager.session.status === "lobby";
return (
<View style={containerStyle}>
<Text style={styles.title}>{t("lobby.title")}</Text>
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
<FlatList
data={manager.session.players}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.listItem}>
<View>
<Text style={styles.playerName}>{item.name}</Text>
<Text style={styles.playerMeta}>
{item.role === "banker" ? t("common.banker") : t("common.player")}{" "}
{item.isDummy ? `- ${t("common.dummy")}` : ""}
</Text>
</View>
<Text style={styles.playerMeta}>
{item.connected ? t("common.online") : t("common.offline")}
</Text>
</View>
)}
/>
{manager.isBanker && manager.session.status === "lobby" && (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
<TextInput
style={styles.input}
placeholder={t("lobby.enterDummyName")}
placeholderTextColor={placeholderColor}
value={dummyName}
onChangeText={setDummyName}
/>
<TextInput
style={styles.input}
placeholder={t("banker.tools.startingBalance")}
placeholderTextColor={placeholderColor}
value={dummyBalance}
onChangeText={setDummyBalance}
keyboardType="number-pad"
/>
<TouchableOpacity
style={styles.buttonSecondary}
onPress={() => {
manager.sendMessage({
type: "banker_create_dummy",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
name: dummyName,
balance: Number(dummyBalance) || undefined,
});
setDummyName("");
setDummyBalance("1500");
}}
>
<Text style={styles.buttonSecondaryText}>{t("lobby.addDummyButton")}</Text>
</TouchableOpacity>
</View>
)}
{canStart && (
<TouchableOpacity
style={styles.button}
onPress={() =>
manager.sendMessage({
type: "banker_start",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
})
}
>
<Text style={styles.buttonText}>{t("lobby.startGame")}</Text>
</TouchableOpacity>
)}
<ExitGameButton mode="full" />
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 0,
paddingBottom: 0,
gap: 12,
backgroundColor: theme.colors.background,
},
title: {
fontSize: 24,
fontWeight: "700",
color: theme.colors.text,
},
subtitle: {
color: theme.colors.textMuted,
},
list: {
gap: 10,
paddingBottom: 20,
},
listItem: {
backgroundColor: theme.colors.surface,
borderRadius: 12,
padding: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
playerName: {
fontWeight: "600",
color: theme.colors.text,
},
playerMeta: {
fontSize: 12,
color: theme.colors.textMuted,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 16,
gap: 10,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
cardTitle: {
fontWeight: "600",
color: theme.colors.text,
},
helper: {
color: theme.colors.textMuted,
fontSize: 12,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
},
button: {
backgroundColor: theme.colors.primary,
paddingVertical: 14,
borderRadius: 999,
alignItems: "center",
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
buttonSecondary: {
backgroundColor: theme.colors.secondary,
paddingVertical: 12,
borderRadius: 999,
alignItems: "center",
},
buttonSecondaryText: {
color: theme.colors.secondaryText,
fontWeight: "600",
},
});

View file

@ -0,0 +1,222 @@
import React, { useMemo, useState } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { useSession } from "../state/session-context";
import EmpOverlay from "../components/EmpOverlay";
import { formatTransactionKind, getLocale, useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
import type { Player, Transaction } from "../shared/types";
function formatMoney(amount: number) {
const value = new Intl.NumberFormat(getLocale(), {
maximumFractionDigits: 0,
}).format(amount);
return `${value}`;
}
function formatTransactionTimestamp(value: number) {
const date = new Date(value);
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
const time = date.toLocaleTimeString(getLocale(), { hour: "2-digit", minute: "2-digit" });
if (sameDay) return time;
const day = date.toLocaleDateString(getLocale(), { month: "short", day: "numeric" });
return `${day} ${time}`;
}
function getTransactionLabel(
kind: string,
note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim();
return trimmed || t("common.noReason");
}
return formatTransactionKind(kind, t);
}
function getTransactionDisplay(
transaction: Transaction,
viewerId: string | null | undefined,
players: Player[],
t: ReturnType<typeof useI18n>["t"],
) {
const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId);
let outgoing = false;
let counterparty = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0;
counterparty = t("common.bank");
} else if (transaction.kind === "transfer" || transaction.kind === "banker_force_transfer") {
if (viewerId && transaction.fromId === viewerId) {
outgoing = true;
counterparty = to?.name ?? t("common.player");
} else if (viewerId && transaction.toId === viewerId) {
outgoing = false;
counterparty = from?.name ?? t("common.player");
} else {
outgoing = true;
counterparty = to?.name ?? t("common.player");
}
}
return {
label,
subtitle: viewerId
? `${outgoing ? t("common.to") : t("common.from")} ${counterparty} · ${timeLabel}`
: transaction.kind === "banker_adjust"
? outgoing
? `${to?.name ?? t("common.player")}${t("common.bank")} · ${timeLabel}`
: `${t("common.bank")}${to?.name ?? t("common.player")} · ${timeLabel}`
: `${from?.name ?? t("common.player")}${to?.name ?? t("common.player")} · ${timeLabel}`,
amount: `${outgoing ? "-" : ""}${formatMoney(absAmount)}`,
outgoing,
};
}
export default function PlayerHomeScreen() {
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const [visibleCount, setVisibleCount] = useState(20);
if (!manager.session || !manager.me) {
return (
<View style={styles.container}>
<Text style={styles.helper}>{t("common.loading")}</Text>
</View>
);
}
const myTransactions = manager.session.transactions.filter(
(tx) => tx.fromId === manager.me?.id || tx.toId === manager.me?.id,
);
const visibleTransactions = myTransactions.slice(0, visibleCount);
const showEmp = manager.session.blackoutActive && !manager.isBanker;
return (
<View style={styles.container}>
<View style={styles.balanceCard}>
<Text style={styles.balanceLabel}>{t("home.balance")}</Text>
<Text style={styles.balanceValue}>{formatMoney(manager.me.balance)}</Text>
</View>
<Text style={styles.sectionTitle}>{t("home.recent")}</Text>
<FlatList
data={visibleTransactions}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
initialNumToRender={20}
windowSize={7}
onEndReachedThreshold={0.4}
onEndReached={() => {
if (visibleCount >= myTransactions.length) return;
setVisibleCount((count) => Math.min(count + 20, myTransactions.length));
}}
renderItem={({ item }) => {
const display = getTransactionDisplay(
item,
manager.me?.id,
manager.session?.players ?? [],
t,
);
return (
<View style={styles.listItem}>
<View style={styles.listContent}>
<Text style={styles.listTitle}>{display.label}</Text>
<Text style={styles.listSubtitle}>{display.subtitle}</Text>
</View>
<Text
style={[styles.listAmount, display.outgoing ? styles.amountNegative : null]}
>
{display.amount}
</Text>
</View>
);
}}
ListEmptyComponent={<Text style={styles.helper}>{t("home.noActivity")}</Text>}
/>
<EmpOverlay
visible={showEmp}
reason={manager.session.blackoutReason}
/>
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: theme.colors.background,
position: "relative",
},
balanceCard: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 18,
marginBottom: 20,
},
balanceLabel: {
fontSize: 14,
color: theme.colors.textMuted,
},
balanceValue: {
fontSize: 28,
fontWeight: "700",
color: theme.colors.text,
},
sectionTitle: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
color: theme.colors.text,
},
list: {
gap: 10,
},
listItem: {
backgroundColor: theme.colors.surface,
borderRadius: 12,
padding: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
listContent: {
flex: 1,
paddingRight: 12,
},
listTitle: {
fontWeight: "600",
color: theme.colors.text,
},
listSubtitle: {
marginTop: 2,
fontSize: 12,
color: theme.colors.textMuted,
},
listAmount: {
fontWeight: "600",
color: theme.colors.primary,
},
amountNegative: {
color: theme.colors.danger,
},
helper: {
color: theme.colors.textMuted,
},
});

View file

@ -0,0 +1,481 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Keyboard,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSession } from "../state/session-context";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
import EmpOverlay from "../components/EmpOverlay";
function initials(value: string) {
return value
.split(" ")
.filter(Boolean)
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export default function PlayerTransfersScreen() {
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const [targetId, setTargetId] = useState("");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [errorText, setErrorText] = useState("");
const eligible = useMemo(
() =>
manager.session?.players.filter(
(player) => player.role !== "banker" && player.id !== manager.me?.id,
) ?? [],
[manager.session, manager.me?.id],
);
useEffect(() => {
if (!targetId && eligible.length > 0) {
setTargetId(eligible[0].id);
}
}, [eligible, targetId]);
const selectedPlayer = eligible.find((player) => player.id === targetId);
const quickAmounts = [10, 25, 50, 100];
const normalizedAmount = amount.replace(",", ".");
const amountValue = Number(normalizedAmount);
const canSend = Boolean(targetId) && Number.isFinite(amountValue) && amountValue > 0;
const showEmp = Boolean(manager.session?.blackoutActive) && !manager.isBanker;
if (!manager.session || !manager.me) {
return (
<View style={styles.container}>
<Text style={styles.loading}>{t("common.loading")}</Text>
</View>
);
}
return (
<View style={styles.wrapper}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
<View style={styles.hero}>
<Text style={styles.title}>{t("transfers.title")}</Text>
<Text style={styles.subtitle}>{t("transfers.subtitle")}</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("transfers.from")}</Text>
<View style={styles.profileCard}>
<View style={styles.avatarBadge}>
<Text style={styles.avatarText}>
{initials(manager.me.name || t("common.you"))}
</Text>
</View>
<View style={styles.profileMeta}>
<Text style={styles.profileName}>{manager.me.name || t("common.you")}</Text>
<Text style={styles.profileSub}>{t("transfers.availableBalance")}</Text>
</View>
<View style={styles.balancePill}>
<Text style={styles.balanceText}>{manager.me.balance ?? 0}</Text>
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("transfers.to")}</Text>
{eligible.length === 0 ? (
<Text style={styles.helper}>{t("transfers.noPlayers")}</Text>
) : (
<View style={styles.recipientList}>
{eligible.map((player) => {
const active = player.id === targetId;
return (
<Pressable
key={player.id}
onPress={() => setTargetId(player.id)}
style={({ pressed }) => [
styles.recipientCard,
active ? styles.recipientCardActive : null,
pressed ? styles.recipientCardPressed : null,
]}
>
<View style={styles.recipientRow}>
<View style={styles.recipientAvatar}>
<Text style={styles.recipientInitials}>{initials(player.name)}</Text>
</View>
<View style={styles.recipientMeta}>
<Text style={styles.recipientName}>{player.name}</Text>
<Text style={styles.recipientSub}>
{player.isDummy ? t("transfers.dummy") : t("transfers.player")}
</Text>
</View>
<View style={[styles.radio, active ? styles.radioActive : null]} />
</View>
</Pressable>
);
})}
</View>
)}
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("transfers.amount")}</Text>
<View style={styles.amountRow}>
<Text style={styles.currency}></Text>
<TextInput
style={styles.amountInput}
placeholder="0"
placeholderTextColor={placeholderColor}
keyboardType="decimal-pad"
value={amount}
onChangeText={(value) => setAmount(value.replace(/[^0-9.,]/g, ""))}
/>
<TouchableOpacity
style={styles.dismissButton}
onPress={() => Keyboard.dismiss()}
accessibilityRole="button"
>
<Text style={styles.dismissButtonText}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<View style={styles.chipRow}>
{quickAmounts.map((value) => {
const active = Number.isFinite(amountValue) && amountValue === value;
return (
<Pressable
key={value}
onPress={() => setAmount(String(value))}
style={[styles.chip, active ? styles.chipActive : null]}
>
<Text style={[styles.chipText, active ? styles.chipTextActive : null]}>
{value}
</Text>
</Pressable>
);
})}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("transfers.note")}</Text>
<TextInput
style={styles.noteInput}
placeholder={t("transfers.notePlaceholder")}
placeholderTextColor={placeholderColor}
value={note}
onChangeText={setNote}
/>
</View>
{errorText ? <Text style={styles.error}>{errorText}</Text> : null}
<View style={styles.summary}>
<Text style={styles.summaryLabel}>{t("transfers.sending")}</Text>
<Text style={styles.summaryValue}>
{canSend
? t("transfers.summary", {
amount: amountValue,
name: selectedPlayer?.name ?? t("common.player"),
})
: t("transfers.selectPlayer")}
</Text>
</View>
<TouchableOpacity
style={[styles.button, !canSend ? styles.buttonDisabled : null]}
disabled={!canSend}
onPress={() => {
if (!targetId || !Number.isFinite(amountValue) || amountValue <= 0) {
setErrorText(t("transfers.error"));
return;
}
manager.sendMessage({
type: "transfer",
sessionId: manager.sessionId,
playerId: manager.me?.id,
toPlayerId: targetId,
amount: amountValue,
note,
});
setErrorText("");
setAmount("");
setNote("");
}}
>
<Text style={styles.buttonText}>{t("transfers.send")}</Text>
</TouchableOpacity>
</ScrollView>
<EmpOverlay visible={showEmp} reason={manager.session.blackoutReason} />
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
wrapper: {
flex: 1,
position: "relative",
},
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
padding: 20,
gap: 18,
backgroundColor: theme.colors.background,
},
hero: {
gap: 4,
},
title: {
fontSize: 26,
fontWeight: "700",
color: theme.colors.text,
},
subtitle: {
color: theme.colors.textMuted,
},
section: {
gap: 10,
},
sectionTitle: {
fontSize: 12,
fontWeight: "700",
letterSpacing: 1,
textTransform: "uppercase",
color: theme.colors.textMuted,
},
profileCard: {
backgroundColor: theme.colors.brandSurface,
borderRadius: 18,
padding: 16,
flexDirection: "row",
alignItems: "center",
gap: 12,
},
avatarBadge: {
width: 44,
height: 44,
borderRadius: 16,
backgroundColor: theme.colors.brandSurfaceAlt,
alignItems: "center",
justifyContent: "center",
},
avatarText: {
color: theme.colors.brandTextMuted,
fontWeight: "700",
},
profileMeta: {
flex: 1,
gap: 4,
},
profileName: {
color: theme.colors.brandText,
fontSize: 16,
fontWeight: "600",
},
profileSub: {
color: theme.colors.brandTextMuted,
fontSize: 12,
},
balancePill: {
backgroundColor: theme.colors.brandAccent,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
balanceText: {
color: theme.colors.brandAccentText,
fontWeight: "700",
fontSize: 12,
},
recipientList: {
gap: 10,
},
recipientCard: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 12,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
recipientCardActive: {
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accentSurface,
},
recipientCardPressed: {
opacity: 0.85,
},
recipientRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
recipientAvatar: {
width: 40,
height: 40,
borderRadius: 14,
backgroundColor: theme.colors.avatarSurface,
alignItems: "center",
justifyContent: "center",
},
recipientInitials: {
color: theme.colors.avatarText,
fontWeight: "700",
},
recipientMeta: {
flex: 1,
},
recipientName: {
fontSize: 15,
fontWeight: "600",
color: theme.colors.text,
},
recipientSub: {
fontSize: 12,
color: theme.colors.textMuted,
marginTop: 2,
},
radio: {
width: 16,
height: 16,
borderRadius: 8,
borderWidth: 2,
borderColor: theme.colors.radioBorder,
},
radioActive: {
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
},
helper: {
fontSize: 12,
color: theme.colors.textMuted,
},
amountRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
paddingHorizontal: 12,
paddingVertical: 8,
gap: 8,
},
currency: {
fontSize: 12,
fontWeight: "700",
color: theme.colors.text,
},
amountInput: {
flex: 1,
fontSize: 18,
fontWeight: "600",
color: theme.colors.inputText,
},
dismissButton: {
backgroundColor: theme.colors.surfaceAlt,
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
dismissButtonText: {
fontSize: 12,
fontWeight: "600",
color: theme.colors.text,
},
chipRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
chip: {
backgroundColor: theme.colors.chipBackground,
borderRadius: 999,
borderWidth: 1,
borderColor: theme.colors.chipBorder,
paddingHorizontal: 12,
paddingVertical: 6,
},
chipActive: {
backgroundColor: theme.colors.chipActiveBackground,
borderColor: theme.colors.chipActiveBackground,
},
chipText: {
fontSize: 12,
color: theme.colors.chipText,
fontWeight: "600",
},
chipTextActive: {
color: theme.colors.chipActiveText,
},
noteInput: {
backgroundColor: theme.colors.inputBackground,
borderRadius: 16,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
color: theme.colors.inputText,
paddingHorizontal: 12,
paddingVertical: 10,
},
summary: {
backgroundColor: theme.colors.warningSurface,
borderRadius: 16,
padding: 14,
borderWidth: 1,
borderColor: theme.colors.warningBorder,
gap: 6,
},
summaryLabel: {
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 1,
color: theme.colors.warningText,
fontWeight: "700",
},
summaryValue: {
fontSize: 14,
fontWeight: "600",
color: theme.colors.warningTextStrong,
},
error: {
color: theme.colors.danger,
fontWeight: "600",
},
button: {
backgroundColor: theme.colors.action,
paddingVertical: 14,
borderRadius: 999,
alignItems: "center",
marginBottom: 16,
},
buttonText: {
color: theme.colors.actionText,
fontWeight: "700",
letterSpacing: 0.4,
},
buttonDisabled: {
backgroundColor: theme.colors.tabInactive,
},
loading: {
color: theme.colors.text,
},
});

View file

@ -0,0 +1,164 @@
import React, { useMemo } from "react";
import {
FlatList,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { ChatStackParamList } from "../../navigation/types";
import { useSession } from "../../state/session-context";
import { buildThreads } from "./chat-utils";
import { useI18n } from "../../i18n";
import { useTheme } from "../../theme";
import type { AppTheme } from "../../theme";
import EmpOverlay from "../../components/EmpOverlay";
function formatTime(value?: number | null) {
if (!value) return "";
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export default function ChatListScreen() {
const manager = useSession();
const navigation = useNavigation<NativeStackNavigationProp<ChatStackParamList>>();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const threads = useMemo(() => {
if (!manager.session) return [];
return buildThreads(manager.session, manager.me?.id ?? null, manager.isBanker);
}, [manager.session, manager.me, manager.isBanker]);
if (!manager.session) {
return (
<View style={styles.container}>
<Text style={styles.helper}>{t("common.loadingChats")}</Text>
</View>
);
}
const showEmp = manager.session.blackoutActive && !manager.isBanker;
return (
<View style={styles.container}>
<Text style={styles.title}>{t("chat.title")}</Text>
<FlatList
data={threads}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<Pressable
style={styles.listItem}
onPress={() => navigation.navigate("ChatThread", { chatId: item.id })}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{item.kind === "global" ? "#" : item.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.meta}>
<View style={styles.metaRow}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.time}>{formatTime(item.lastMessage?.createdAt)}</Text>
</View>
<Text style={styles.preview}>
{item.lastMessage?.body ?? t("chat.noMessages")}
</Text>
</View>
</Pressable>
)}
/>
<TouchableOpacity
style={styles.fab}
onPress={() => navigation.navigate("ChatNew")}
>
<Text style={styles.fabText}>+</Text>
</TouchableOpacity>
<EmpOverlay visible={showEmp} reason={manager.session.blackoutReason} />
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: theme.colors.background,
position: "relative",
},
title: {
fontSize: 20,
fontWeight: "700",
marginBottom: 16,
color: theme.colors.text,
},
list: {
gap: 12,
paddingBottom: 80,
},
listItem: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 12,
flexDirection: "row",
alignItems: "center",
gap: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 16,
backgroundColor: theme.colors.listAvatarBackground,
justifyContent: "center",
alignItems: "center",
},
avatarText: {
fontWeight: "700",
color: theme.colors.listAvatarText,
},
meta: {
flex: 1,
},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
name: {
fontWeight: "600",
color: theme.colors.text,
},
time: {
fontSize: 12,
color: theme.colors.textMuted,
},
preview: {
color: theme.colors.textMuted,
marginTop: 4,
},
helper: {
color: theme.colors.textMuted,
},
fab: {
position: "absolute",
right: 20,
bottom: 24,
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: theme.colors.primary,
alignItems: "center",
justifyContent: "center",
},
fabText: {
color: theme.colors.primaryText,
fontSize: 26,
fontWeight: "700",
},
});

View file

@ -0,0 +1,230 @@
import React, { useMemo, useState } from "react";
import {
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { ChatStackParamList } from "../../navigation/types";
import { useSession } from "../../state/session-context";
import { useI18n } from "../../i18n";
import { useTheme } from "../../theme";
import type { AppTheme } from "../../theme";
import EmpOverlay from "../../components/EmpOverlay";
export default function ChatNewScreen() {
const manager = useSession();
const navigation = useNavigation<NativeStackNavigationProp<ChatStackParamList>>();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const [mode, setMode] = useState<"direct" | "group">("direct");
const [groupName, setGroupName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const options = useMemo(
() => manager.session?.players.filter((player) => player.id !== manager.me?.id) ?? [],
[manager.session, manager.me],
);
function toggleMember(id: string) {
if (mode === "direct") {
setSelected([id]);
return;
}
setSelected((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
}
if (!manager.session || !manager.me) {
return (
<View style={styles.container}>
<Text style={styles.helper}>{t("common.loading")}</Text>
</View>
);
}
const showEmp = manager.session.blackoutActive && !manager.isBanker;
return (
<View style={styles.wrapper}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
<Text style={styles.title}>{t("chat.newTitle")}</Text>
<View style={styles.toggleRow}>
<TouchableOpacity
style={[styles.toggleButton, mode === "direct" ? styles.toggleActive : null]}
onPress={() => {
setMode("direct");
setSelected([]);
}}
>
<Text style={[styles.toggleText, mode === "direct" ? styles.toggleTextActive : null]}>
{t("chat.direct")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.toggleButton, mode === "group" ? styles.toggleActive : null]}
onPress={() => {
setMode("group");
setSelected([]);
}}
>
<Text style={[styles.toggleText, mode === "group" ? styles.toggleTextActive : null]}>
{t("chat.group")}
</Text>
</TouchableOpacity>
</View>
{mode === "group" && (
<TextInput
style={styles.input}
placeholder={t("chat.groupName")}
placeholderTextColor={placeholderColor}
value={groupName}
onChangeText={setGroupName}
/>
)}
<Text style={styles.sectionTitle}>{t("chat.choosePlayers")}</Text>
{options.map((player) => {
const active = selected.includes(player.id);
return (
<TouchableOpacity
key={player.id}
style={[styles.memberRow, active ? styles.memberActive : null]}
onPress={() => toggleMember(player.id)}
>
<Text style={styles.memberName}>{player.name}</Text>
<Text style={styles.memberMeta}>
{player.role === "banker"
? t("common.banker")
: player.isDummy
? t("common.dummy")
: t("common.player")}
</Text>
</TouchableOpacity>
);
})}
<TouchableOpacity
style={styles.button}
onPress={() => {
if (showEmp) return;
if (mode === "direct" && selected.length !== 1) return;
if (mode === "group" && (!groupName.trim() || selected.length === 0)) return;
manager.sendMessage({
type: "group_create",
sessionId: manager.sessionId,
playerId: manager.me?.id,
name: mode === "direct" ? t("chat.direct") : groupName.trim(),
memberIds: selected,
});
navigation.goBack();
}}
>
<Text style={styles.buttonText}>{t("chat.startChat")}</Text>
</TouchableOpacity>
</ScrollView>
<EmpOverlay visible={showEmp} reason={manager.session.blackoutReason} />
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
wrapper: {
flex: 1,
position: "relative",
},
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
padding: 20,
gap: 12,
},
title: {
fontSize: 20,
fontWeight: "700",
color: theme.colors.text,
},
toggleRow: {
flexDirection: "row",
gap: 8,
},
toggleButton: {
flex: 1,
paddingVertical: 10,
borderRadius: 999,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
backgroundColor: theme.colors.surface,
},
toggleActive: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
},
toggleText: {
color: theme.colors.text,
fontWeight: "600",
},
toggleTextActive: {
color: theme.colors.primaryText,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
},
sectionTitle: {
fontWeight: "600",
marginTop: 8,
color: theme.colors.text,
},
memberRow: {
backgroundColor: theme.colors.surface,
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
memberActive: {
borderWidth: 1,
borderColor: theme.colors.primary,
},
memberName: {
fontWeight: "600",
color: theme.colors.text,
},
memberMeta: {
fontSize: 12,
color: theme.colors.textMuted,
},
button: {
backgroundColor: theme.colors.primary,
paddingVertical: 12,
borderRadius: 999,
alignItems: "center",
marginTop: 12,
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
helper: {
color: theme.colors.textMuted,
},
});

View file

@ -0,0 +1,225 @@
import React, { useMemo, useRef, useState } from "react";
import {
FlatList,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useRoute } from "@react-navigation/native";
import type { RouteProp } from "@react-navigation/native";
import { useHeaderHeight } from "@react-navigation/elements";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { ChatStackParamList } from "../../navigation/types";
import { useSession } from "../../state/session-context";
import { buildThreads, getThreadMessages } from "./chat-utils";
import { useI18n } from "../../i18n";
import { useTheme } from "../../theme";
import type { AppTheme } from "../../theme";
import EmpOverlay from "../../components/EmpOverlay";
function formatTime(value: number) {
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export default function ChatThreadScreen() {
const manager = useSession();
const route = useRoute<RouteProp<ChatStackParamList, "ChatThread">>();
const [message, setMessage] = useState("");
const listRef = useRef<FlatList>(null);
const headerHeight = useHeaderHeight();
const insets = useSafeAreaInsets();
const keyboardOffset = Platform.OS === "ios" ? headerHeight : 0;
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
if (!manager.session || !manager.me) {
return (
<View style={styles.container}>
<Text style={styles.helper}>{t("common.loadingChat")}</Text>
</View>
);
}
const threads = buildThreads(manager.session, manager.me.id, manager.isBanker);
const thread = threads.find((item) => item.id === route.params.chatId);
const messages = thread ? getThreadMessages(manager.session, thread.id) : [];
const threadKindLabel = thread
? thread.kind === "global"
? t("chat.global")
: thread.kind === "direct"
? t("chat.direct")
: t("chat.group")
: "";
if (!thread) {
return (
<View style={styles.container}>
<Text style={styles.helper}>{t("chat.notFound")}</Text>
</View>
);
}
const showEmp = manager.session.blackoutActive && !manager.isBanker;
function handleSend() {
if (showEmp) return;
if (!message.trim()) return;
manager.sendMessage({
type: "chat_send",
sessionId: manager.sessionId,
playerId: manager.me?.id,
body: message.trim(),
groupId: thread.id === "global" ? null : thread.id,
});
setMessage("");
}
return (
<View style={styles.wrapper}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={keyboardOffset}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>{thread.name}</Text>
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
</View>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })}
renderItem={({ item }) => {
const isMe = item.fromId === manager.me?.id;
return (
<View style={[styles.bubbleRow, isMe ? styles.bubbleRowMe : null]}>
<View style={[styles.bubble, isMe ? styles.bubbleMe : null]}>
<Text style={styles.bubbleText}>{item.body}</Text>
<Text style={styles.bubbleTime}>{formatTime(item.createdAt)}</Text>
</View>
</View>
);
}}
/>
<View style={[styles.composer, { paddingBottom: Math.max(insets.bottom, 8) }]}>
<TextInput
style={styles.input}
placeholder={t("chat.messagePlaceholder")}
placeholderTextColor={placeholderColor}
value={message}
onChangeText={setMessage}
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
/>
<TouchableOpacity
style={styles.sendButton}
onPress={handleSend}
>
<Text style={styles.sendText}>{t("common.send")}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
<EmpOverlay visible={showEmp} reason={manager.session.blackoutReason} />
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
wrapper: {
flex: 1,
position: "relative",
},
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
backgroundColor: theme.colors.surface,
},
headerTitle: {
fontSize: 18,
fontWeight: "700",
color: theme.colors.text,
},
headerSubtitle: {
color: theme.colors.textMuted,
fontSize: 12,
marginTop: 4,
},
list: {
padding: 16,
gap: 10,
},
bubbleRow: {
flexDirection: "row",
justifyContent: "flex-start",
},
bubbleRowMe: {
justifyContent: "flex-end",
},
bubble: {
maxWidth: "75%",
backgroundColor: theme.colors.surface,
padding: 10,
borderRadius: 16,
},
bubbleMe: {
backgroundColor: theme.colors.bubbleMe,
},
bubbleText: {
fontSize: 14,
color: theme.colors.text,
},
bubbleTime: {
fontSize: 10,
color: theme.colors.textMuted,
marginTop: 6,
textAlign: "right",
},
composer: {
flexDirection: "row",
padding: 12,
gap: 10,
borderTopWidth: 1,
borderTopColor: theme.colors.borderMuted,
backgroundColor: theme.colors.surface,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
},
sendButton: {
backgroundColor: theme.colors.primary,
borderRadius: 999,
paddingHorizontal: 16,
justifyContent: "center",
},
sendText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
helper: {
color: theme.colors.textMuted,
},
});

View file

@ -0,0 +1,80 @@
import type { ChatMessage, SessionSnapshot } from "../../shared/types";
import { tStatic } from "../../i18n";
export type ChatThread = {
id: string;
name: string;
kind: "global" | "group" | "direct";
members: string[];
lastMessage: ChatMessage | null;
};
function getLastMessage(messages: ChatMessage[]): ChatMessage | null {
if (messages.length === 0) return null;
return messages.reduce((latest, current) =>
current.createdAt > latest.createdAt ? current : latest,
);
}
export function buildThreads(
session: SessionSnapshot,
meId: string | null,
isBanker: boolean,
): ChatThread[] {
const threads: ChatThread[] = [];
const globalMessages = session.chats.filter((message) => message.groupId === null);
threads.push({
id: "global",
name: tStatic("chat.global"),
kind: "global",
members: [],
lastMessage: getLastMessage(globalMessages),
});
session.groups.forEach((group) => {
if (!isBanker && meId && !group.memberIds.includes(meId)) {
return;
}
const groupMessages = session.chats.filter((message) => message.groupId === group.id);
let name = group.name;
let kind: ChatThread["kind"] = "group";
if (group.memberIds.length === 2) {
const [first, second] = group.memberIds;
if (isBanker || !meId) {
const firstName =
session.players.find((player) => player.id === first)?.name ?? tStatic("common.player");
const secondName =
session.players.find((player) => player.id === second)?.name ?? tStatic("common.player");
name = `${firstName} & ${secondName}`;
kind = "direct";
} else {
const otherId = first === meId ? second : first;
const otherName =
session.players.find((player) => player.id === otherId)?.name ??
tStatic("common.player");
name = otherName;
kind = "direct";
}
}
threads.push({
id: group.id,
name,
kind,
members: group.memberIds,
lastMessage: getLastMessage(groupMessages),
});
});
return threads.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? 0;
const bTime = b.lastMessage?.createdAt ?? 0;
return bTime - aTime;
});
}
export function getThreadMessages(session: SessionSnapshot, threadId: string): ChatMessage[] {
if (threadId === "global") {
return session.chats.filter((message) => message.groupId === null).slice().reverse();
}
return session.chats.filter((message) => message.groupId === threadId).slice().reverse();
}

View file

@ -0,0 +1,91 @@
export type SessionStatus = "lobby" | "active" | "ended";
export type PlayerRole = "banker" | "player";
export type Player = {
id: string;
name: string;
role: PlayerRole;
balance: number;
connected: boolean;
isDummy: boolean;
joinedAt: number;
lastActiveAt: number;
};
export type TransactionKind =
| "transfer"
| "banker_adjust"
| "banker_force_transfer";
export type Transaction = {
id: string;
kind: TransactionKind;
fromId: string | null;
toId: string | null;
amount: number;
note: string | null;
createdAt: number;
initiatedBy: PlayerRole;
};
export type ChatGroup = {
id: string;
name: string;
memberIds: string[];
createdAt: number;
createdBy: string;
};
export type ChatMessage = {
id: string;
fromId: string;
body: string;
createdAt: number;
groupId: string | null;
};
export type TakeoverRequest = {
id: string;
dummyId: string;
requesterId: string;
createdAt: number;
status: "pending" | "approved" | "rejected";
};
export type SessionSnapshot = {
id: string;
code: string;
status: SessionStatus;
createdAt: number;
bankerId: string;
blackoutActive: boolean;
blackoutReason: string | null;
players: Player[];
transactions: Transaction[];
chats: ChatMessage[];
groups: ChatGroup[];
takeoverRequests: TakeoverRequest[];
};
export type SessionPreviewPlayer = {
id: string;
name: string;
role: PlayerRole;
isDummy: boolean;
connected: boolean;
};
export type SessionPreview = {
sessionId: string;
code: string;
status: SessionStatus;
players: SessionPreviewPlayer[];
};
export type JoinResponse = {
sessionId: string;
sessionCode: string;
playerId: string;
role: PlayerRole;
status: SessionStatus;
};

View file

@ -0,0 +1,17 @@
import React, { createContext, useContext } from "react";
import { useSessionManager } from "./session";
const SessionContext = createContext<ReturnType<typeof useSessionManager> | null>(null);
export function SessionProvider({ children }: { children: React.ReactNode }) {
const manager = useSessionManager();
return <SessionContext.Provider value={manager}>{children}</SessionContext.Provider>;
}
export function useSession() {
const context = useContext(SessionContext);
if (!context) {
throw new Error("useSession must be used within SessionProvider");
}
return context;
}

267
mobile/src/state/session.ts Normal file
View file

@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
import { getApiBaseUrl, getWsUrl } from "../config/api";
import { tStatic } from "../i18n";
const STORAGE_KEY = "negopoly:session";
type StoredSession = {
sessionId: string;
sessionCode: string;
playerId: string;
};
async function readStoredSession(): Promise<StoredSession | null> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredSession;
} catch {
return null;
}
}
async function writeStoredSession(session: StoredSession) {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
async function clearStoredSession() {
await AsyncStorage.removeItem(STORAGE_KEY);
}
export function useSessionManager() {
const [sessionId, setSessionId] = useState("");
const [sessionCode, setSessionCode] = useState("");
const [playerId, setPlayerId] = useState("");
const [session, setSession] = useState<SessionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "open" | "error"
>("idle");
const [tick, setTick] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
let mounted = true;
readStoredSession().then((stored) => {
if (!mounted || !stored) return;
setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!sessionId || !playerId) {
setConnectionState("idle");
setSession(null);
return;
}
setConnectionState("connecting");
const ws = new WebSocket(getWsUrl(sessionId, playerId));
wsRef.current = ws;
ws.onopen = () => setConnectionState("open");
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "state") {
setSession(message.session as SessionSnapshot);
}
if (message.type === "error") {
setError(message.message);
}
if (message.type === "takeover_approved") {
const assignedId = message.assignedPlayerId as string;
setPlayerId(assignedId);
if (sessionId && sessionCode) {
writeStoredSession({
sessionId,
sessionCode,
playerId: assignedId,
});
}
}
} catch {
setError(tStatic("error.parseResponse"));
}
};
ws.onerror = () => setConnectionState("error");
ws.onclose = (event) => {
setConnectionState("error");
const reason = typeof event?.reason === "string" ? event.reason : "";
if (event?.code === 1008 && /session not found|player not found/i.test(reason)) {
resetSession();
}
};
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", sessionId, playerId }));
}
}, 15000);
return () => {
clearInterval(pingTimer);
ws.close();
};
}, [sessionId, playerId]);
useEffect(() => {
if (!pendingTakeoverId) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
if (!sessionId || !playerId) return;
wsRef.current.send(
JSON.stringify({
type: "takeover_request",
sessionId,
playerId,
dummyId: pendingTakeoverId,
}),
);
setPendingTakeoverId(null);
}, [pendingTakeoverId, sessionId, playerId]);
async function createSession(bankerName: string) {
setError(null);
setSession(null);
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bankerName }),
});
if (!response.ok) {
setError(tStatic("error.createSession"));
return null;
}
const data = (await response.json()) as JoinResponse;
setSessionId(data.sessionId);
setSessionCode(data.sessionCode);
setPlayerId(data.playerId);
await writeStoredSession({
sessionId: data.sessionId,
sessionCode: data.sessionCode,
playerId: data.playerId,
});
return data;
}
async function joinSession(code: string, name: string) {
setError(null);
setSession(null);
if (!code) {
setError(tStatic("entry.alert.enterCode"));
return null;
}
const storedNow = await readStoredSession();
const reusePlayerId =
storedNow && (storedNow.sessionCode === code || storedNow.sessionId === code)
? storedNow.playerId
: undefined;
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, playerId: reusePlayerId }),
});
if (!response.ok) {
setError(tStatic("error.joinSession"));
return null;
}
const data = (await response.json()) as JoinResponse;
setSessionId(data.sessionId);
setSessionCode(data.sessionCode);
setPlayerId(data.playerId);
await writeStoredSession({
sessionId: data.sessionId,
sessionCode: data.sessionCode,
playerId: data.playerId,
});
return data;
}
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
if (!code) return null;
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
if (!response.ok) {
setError(tStatic("error.loadSessionInfo"));
return null;
}
return (await response.json()) as SessionPreview;
}
function sendMessage(payload: Record<string, unknown>) {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError(tStatic("error.connectionNotReady"));
return;
}
wsRef.current.send(JSON.stringify(payload));
}
async function resetSession() {
try {
await clearStoredSession();
} catch {
// Ignore storage errors on reset.
}
setSessionId("");
setSessionCode("");
setPlayerId("");
setSession(null);
setError(null);
setPendingTakeoverId(null);
setConnectionState("idle");
}
async function leaveSession() {
const ws = wsRef.current;
wsRef.current = null;
if (ws && ws.readyState !== WebSocket.CLOSED) {
try {
ws.close();
} catch {
// Ignore failures while closing the socket.
}
}
await resetSession();
}
const me = session?.players.find((player) => player.id === playerId) ?? null;
const isBanker = me?.role === "banker";
return {
sessionId,
sessionCode,
playerId,
session,
me,
isBanker,
tick,
error,
connectionState,
setError,
createSession,
joinSession,
fetchSessionPreview,
sendMessage,
resetSession,
leaveSession,
setSessionId,
setPlayerId,
setSession,
setPendingTakeoverId,
};
}

181
mobile/src/theme.ts Normal file
View file

@ -0,0 +1,181 @@
import { useColorScheme } from "react-native";
import {
DarkTheme,
DefaultTheme,
type Theme as NavigationTheme,
} from "@react-navigation/native";
export type AppTheme = {
dark: boolean;
colors: {
background: string;
surface: string;
surfaceAlt: string;
text: string;
textMuted: string;
border: string;
borderMuted: string;
primary: string;
primaryText: string;
secondary: string;
secondaryText: string;
accent: string;
accentText: string;
accentSurface: string;
danger: string;
warningSurface: string;
warningBorder: string;
warningText: string;
warningTextStrong: string;
brandSurface: string;
brandSurfaceAlt: string;
brandText: string;
brandTextMuted: string;
brandAccent: string;
brandAccentText: string;
avatarSurface: string;
avatarText: string;
chipBackground: string;
chipBorder: string;
chipText: string;
chipActiveBackground: string;
chipActiveText: string;
listAvatarBackground: string;
listAvatarText: string;
bubbleMe: string;
inputBackground: string;
inputText: string;
placeholder: string;
tabActive: string;
tabInactive: string;
headerBackground: string;
headerText: string;
action: string;
actionText: string;
radioBorder: string;
};
};
const lightTheme: AppTheme = {
dark: false,
colors: {
background: "#f7f7f9",
surface: "#ffffff",
surfaceAlt: "#f6f8fa",
text: "#0b1a2b",
textMuted: "#6b7280",
border: "#d8dee5",
borderMuted: "#e2e8f0",
primary: "#1b8b75",
primaryText: "#ffffff",
secondary: "#e7ecef",
secondaryText: "#0c1824",
accent: "#14b8a6",
accentText: "#042f2e",
accentSurface: "#ecfdf9",
danger: "#b91c1c",
warningSurface: "#fff6e5",
warningBorder: "#fde7c1",
warningText: "#b45309",
warningTextStrong: "#7c2d12",
brandSurface: "#0b1a2b",
brandSurfaceAlt: "#1f334d",
brandText: "#f8fafc",
brandTextMuted: "#9fb3c8",
brandAccent: "#14b8a6",
brandAccentText: "#042f2e",
avatarSurface: "#0f172a",
avatarText: "#e2e8f0",
chipBackground: "#ffffff",
chipBorder: "#e2e8f0",
chipText: "#0f172a",
chipActiveBackground: "#0f172a",
chipActiveText: "#f8fafc",
listAvatarBackground: "#e6f6f2",
listAvatarText: "#1b8b75",
bubbleMe: "#dff7ef",
inputBackground: "#ffffff",
inputText: "#0b1a2b",
placeholder: "#9aa6b2",
tabActive: "#0f172a",
tabInactive: "#94a3b8",
headerBackground: "#ffffff",
headerText: "#0b1a2b",
action: "#0f172a",
actionText: "#f8fafc",
radioBorder: "#cbd5f5",
},
};
const darkTheme: AppTheme = {
dark: true,
colors: {
background: "#0b0f14",
surface: "#111922",
surfaceAlt: "#0f1620",
text: "#f8fafc",
textMuted: "#a7b4c5",
border: "#1f2a37",
borderMuted: "#243244",
primary: "#1fbf98",
primaryText: "#ffffff",
secondary: "#1f2a37",
secondaryText: "#e2e8f0",
accent: "#2dd4bf",
accentText: "#04221b",
accentSurface: "#0f2a24",
danger: "#f87171",
warningSurface: "#2a1f0b",
warningBorder: "#5f3b11",
warningText: "#f59e0b",
warningTextStrong: "#fbbf24",
brandSurface: "#101a27",
brandSurfaceAlt: "#1b2b3f",
brandText: "#f8fafc",
brandTextMuted: "#9fb3c8",
brandAccent: "#2dd4bf",
brandAccentText: "#04221b",
avatarSurface: "#1e293b",
avatarText: "#e2e8f0",
chipBackground: "#111922",
chipBorder: "#273244",
chipText: "#e2e8f0",
chipActiveBackground: "#2dd4bf",
chipActiveText: "#04221b",
listAvatarBackground: "#0f2a24",
listAvatarText: "#5eead4",
bubbleMe: "#103128",
inputBackground: "#0f1620",
inputText: "#f8fafc",
placeholder: "#7f90a6",
tabActive: "#e2e8f0",
tabInactive: "#64748b",
headerBackground: "#111922",
headerText: "#f8fafc",
action: "#e2e8f0",
actionText: "#0b1a2b",
radioBorder: "#334155",
},
};
export function useTheme(): AppTheme {
const scheme = useColorScheme();
return scheme === "dark" ? darkTheme : lightTheme;
}
export function getNavigationTheme(theme: AppTheme): NavigationTheme {
const baseTheme = theme.dark ? DarkTheme : DefaultTheme;
return {
...baseTheme,
dark: theme.dark,
colors: {
...baseTheme.colors,
primary: theme.colors.primary,
background: theme.colors.background,
card: theme.colors.headerBackground,
text: theme.colors.headerText,
border: theme.colors.border,
notification: theme.colors.accent,
},
};
}

6
mobile/tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "créditmabligopapp",
"module": "index.ts",
"type": "module",
"private": true,
"dependencies": {
"qrcode": "^1.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5"
},
"devDependencies": {
"@types/bun": "latest",
"@types/qrcode": "^1.5.5"
},
"peerDependencies": {
"typescript": "^5"
}
}

73
react-native-plan.md Normal file
View file

@ -0,0 +1,73 @@
# React Native Version Plan
Goal: build a React Native companion app that connects to the same Bun server used by the web app.
Production base URL: `https://negopoly.fr`
Development base URL: `http://<this-computer's-ip>`
## 1) Project setup
- Choose framework: React Native + Expo (fast iteration) or bare RN (native modules).
- Initialize inside this repo at `mobile/`.
- Configure TypeScript and linting to match existing code style.
- Decide navigation: React Navigation for stack + tab layouts.
## 2) Shared types + networking
- Extract/share types from `shared/types.ts` into a package or keep a synced copy for RN.
- Create a `config` module to switch base URL by build type:
- Dev: `http://<this-computer's-ip>` (read from env or app config).
- Prod: `https://negopoly.fr`.
- Create an API client wrapper for REST and WebSocket calls.
## 3) Auth/session flow
- Implement session creation/joining screens matching web UX:
- Mobile-first flow: Join first, then Create.
- Join flow: room code → choose name or dummy takeover.
- Persist session + player IDs with `AsyncStorage`.
- Sync with server via WebSocket (mirrors web `useSessionManager`).
## 4) Navigation structure
- Root stack:
- Home
- Play (entry)
- Lobby
- Player tabs
- Banker tabs
- Chat list / thread / new
- Player tab layout:
- Home / Transfers / Chat (bottom tabs)
- Banker tab layout:
- Dashboard / Tools / Chat (bottom tabs)
## 5) Real-time state handling
- Implement WebSocket client (auto-reconnect + heartbeats).
- Mirror server messages: state updates, errors, takeover approvals, chat events.
- Handle blackout states with full-screen overlay.
## 6) Chat UX parity
- Chat list with unread indicators.
- Thread view like WhatsApp (bubbles + timestamps + composer).
- New chat screen (direct/group).
- Unread tracking (client-side initially; server-side later).
## 7) Banking features
- Player view: balance, transaction history, transfer form.
- Banker view: force transfers, balance adjustments, dummy creation, approvals, blackout, end session.
- Ensure banker has no balance view.
## 8) UX + performance
- Ensure mobile-safe typography (no auto zoom).
- Use safe-area insets for bottom tabs.
- Optimize list rendering (`FlatList` for chats/transactions).
## 9) Testing
- Add unit tests for API helpers + state reducers (if extracted).
- Smoke test flows for create/join, lobby, player, banker, chat.
## 10) Build + release
- Configure dev builds with a local IP target.
- Configure production builds to use `https://negopoly.fr`.
- Set up CI for builds and OTA updates (if using Expo).
## Open questions
- Expo or bare React Native?
- Any custom native features planned (QR scanner, push notifications)?
- Preferred directory layout for the mobile app inside this repo?

187
server/api.ts Normal file
View file

@ -0,0 +1,187 @@
import type { SessionSnapshot } from "../shared/types";
import type { BunRequest } from "bun";
import { applySnapshot, joinSession, snapshotSession } from "./domain";
import {
createSession,
createTestPreview,
createTestSession,
getSession,
getSessionByCode,
isTestSessionCode,
} from "./store";
import { broadcastSessionState, startTestSimulation } from "./websocket";
function jsonResponse(data: unknown, status = 200): Response {
return Response.json(data, { status });
}
async function readJson<T>(req: Request): Promise<T> {
return (await req.json()) as T;
}
function previewSession(session: SessionSnapshot) {
return {
sessionId: session.id,
code: session.code,
status: session.status,
players: session.players.map((player) => ({
id: player.id,
name: player.name,
role: player.role,
isDummy: player.isDummy,
connected: player.connected,
})),
};
}
function readBankerId(req: Request): string | null {
const url = new URL(req.url);
return url.searchParams.get("bankerId");
}
function isSnapshotCandidate(value: unknown): value is SessionSnapshot {
if (!value || typeof value !== "object") return false;
const snapshot = value as SessionSnapshot;
return (
typeof snapshot.id === "string" &&
typeof snapshot.code === "string" &&
typeof snapshot.status === "string" &&
typeof snapshot.createdAt === "number" &&
typeof snapshot.bankerId === "string" &&
typeof snapshot.blackoutActive === "boolean" &&
Array.isArray(snapshot.players) &&
Array.isArray(snapshot.transactions) &&
Array.isArray(snapshot.chats) &&
Array.isArray(snapshot.groups) &&
Array.isArray(snapshot.takeoverRequests)
);
}
export const apiRoutes = {
"/api/health": {
GET() {
return jsonResponse({ ok: true });
},
},
"/api/session": {
async POST(req: BunRequest) {
let body: { bankerName?: string };
try {
body = await readJson<{ bankerName?: string }>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
const bankerName = body.bankerName?.trim() || "Banker";
const { session, banker } = createSession(bankerName);
return jsonResponse({
sessionId: session.id,
sessionCode: session.code,
playerId: banker.id,
role: banker.role,
status: session.status,
});
},
},
"/api/session/:code/join": {
async POST(req: BunRequest) {
const code = req.params.code;
let body: { name?: string; playerId?: string };
try {
body = await readJson<{ name?: string; playerId?: string }>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
if (isTestSessionCode(code)) {
const session = createTestSession();
const player = joinSession(session, body.name ?? "Player", body.playerId);
startTestSimulation(session.id);
return jsonResponse({
sessionId: session.id,
sessionCode: session.code,
playerId: player.id,
role: player.role,
status: session.status,
});
}
const session = getSessionByCode(code) ?? getSession(code);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
if (session.status === "ended") {
return jsonResponse({ message: "Session has ended" }, 400);
}
const player = joinSession(session, body.name ?? "Player", body.playerId);
return jsonResponse({
sessionId: session.id,
sessionCode: session.code,
playerId: player.id,
role: player.role,
status: session.status,
});
},
},
"/api/session/:id": {
GET(req: BunRequest) {
const session = getSession(req.params.id);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
const snapshot: SessionSnapshot = snapshotSession(session);
return jsonResponse(snapshot);
},
},
"/api/session/:id/state": {
GET(req: BunRequest) {
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
const bankerId = readBankerId(req);
if (!bankerId || bankerId !== session.bankerId) {
return jsonResponse({ message: "Not authorized" }, 403);
}
const snapshot: SessionSnapshot = snapshotSession(session);
return jsonResponse(snapshot);
},
async POST(req: BunRequest) {
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
let body: Record<string, unknown>;
try {
body = await readJson<Record<string, unknown>>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
const bankerId = (typeof body.bankerId === "string" ? body.bankerId : null) ?? readBankerId(req);
if (!bankerId || bankerId !== session.bankerId) {
return jsonResponse({ message: "Not authorized" }, 403);
}
const candidate = (body.state ?? body) as unknown;
if (!isSnapshotCandidate(candidate)) {
return jsonResponse({ message: "Invalid session snapshot" }, 400);
}
applySnapshot(session, candidate);
broadcastSessionState(session.id);
return jsonResponse({ ok: true });
},
},
"/api/session/:code/info": {
GET(req: BunRequest) {
const code = req.params.code;
if (isTestSessionCode(code)) {
const snapshot = createTestPreview();
return jsonResponse(previewSession(snapshot));
}
const session = getSessionByCode(code) ?? getSession(code);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
const snapshot: SessionSnapshot = snapshotSession(session);
return jsonResponse(previewSession(snapshot));
},
},
};

89
server/domain.test.ts Normal file
View file

@ -0,0 +1,89 @@
import { expect, test } from "bun:test";
import {
approveTakeover,
bankerAdjust,
disconnectPlayer,
joinSession,
requestTakeover,
setBlackout,
startSession,
transfer,
} from "./domain";
import { createSession, removeSession } from "./store";
function setupSession() {
const { session, banker } = createSession("Avery");
return { session, banker };
}
test("createSession seeds banker with lobby status", () => {
const { session, banker } = setupSession();
expect(session.status).toBe("lobby");
expect(banker.role).toBe("banker");
expect(session.players.get(banker.id)).toBeDefined();
expect(session.code.length).toBe(5);
removeSession(session.id);
});
test("joinSession reuses player when id is provided", () => {
const { session, banker } = setupSession();
const player = joinSession(session, "Rosa");
const rejoin = joinSession(session, "Rosa II", player.id);
expect(rejoin.id).toBe(player.id);
expect(rejoin.name).toBe("Rosa II");
expect(session.players.size).toBe(2);
removeSession(session.id);
});
test("transfer moves balances between players", () => {
const { session, banker } = setupSession();
startSession(session, banker.id);
const player = joinSession(session, "Sam");
const other = joinSession(session, "Riley");
const before = player.balance;
transfer(session, player.id, other.id, 200, "Rent");
expect(player.balance).toBe(before - 200);
removeSession(session.id);
});
test("blackout blocks money movement", () => {
const { session, banker } = setupSession();
startSession(session, banker.id);
const player = joinSession(session, "Kai");
setBlackout(session, banker.id, true, "City outage");
const other = joinSession(session, "Pax");
expect(() => transfer(session, player.id, other.id, 50, "Loan")).toThrow();
removeSession(session.id);
});
test("blackout does not block banker actions", () => {
const { session, banker } = setupSession();
startSession(session, banker.id);
const player = joinSession(session, "Zuri");
setBlackout(session, banker.id, true, "City outage");
expect(() => bankerAdjust(session, banker.id, player.id, 75, "Override")).not.toThrow();
removeSession(session.id);
});
test("banker can adjust balances", () => {
const { session, banker } = setupSession();
startSession(session, banker.id);
const player = joinSession(session, "Luna");
bankerAdjust(session, banker.id, player.id, 120, "Bonus");
expect(player.balance).toBeGreaterThan(1500);
removeSession(session.id);
});
test("takeover approval assigns dummy to requester", () => {
const { session, banker } = setupSession();
startSession(session, banker.id);
const original = joinSession(session, "Ghost");
disconnectPlayer(session, original.id);
const requester = joinSession(session, "Jules");
requestTakeover(session, requester.id, original.id);
const assignedId = approveTakeover(session, banker.id, original.id, requester.id);
expect(assignedId).toBe(original.id);
expect(session.players.get(original.id)?.isDummy).toBe(false);
expect(session.players.get(requester.id)).toBeUndefined();
removeSession(session.id);
});

528
server/domain.ts Normal file
View file

@ -0,0 +1,528 @@
import type {
ChatGroup,
ChatMessage,
Player,
SessionSnapshot,
TakeoverRequest,
Transaction,
} from "../shared/types";
import type { Session } from "./types";
import { DomainError } from "./errors";
import {
clampBalance,
createId,
now,
parseAmount,
DEFAULT_START_BALANCE,
} from "./util";
const MAX_CHAT_LENGTH = 280;
export function snapshotSession(session: Session): SessionSnapshot {
return {
id: session.id,
code: session.code,
status: session.status,
createdAt: session.createdAt,
bankerId: session.bankerId,
blackoutActive: session.blackoutActive,
blackoutReason: session.blackoutReason,
players: Array.from(session.players.values()),
transactions: session.transactions,
chats: session.chats,
groups: session.groups,
takeoverRequests: session.takeoverRequests,
};
}
function remapPlayerId(value: string, fromId: string, toId: string): string {
return value === fromId ? toId : value;
}
function remapSnapshotBanker(snapshot: SessionSnapshot, banker: Player): SessionSnapshot {
const fromId = snapshot.bankerId;
const toId = banker.id;
if (fromId === toId) {
return snapshot;
}
const players = snapshot.players
.filter((player) => player.id !== toId)
.map((player) => {
if (player.id !== fromId) return player;
return {
...player,
id: toId,
name: banker.name,
role: "banker" as const,
connected: true,
isDummy: false,
lastActiveAt: now(),
};
});
if (!players.some((player) => player.id === toId)) {
players.push({
...banker,
role: "banker",
connected: true,
isDummy: false,
lastActiveAt: now(),
});
}
const transactions = snapshot.transactions.map((tx) => ({
...tx,
fromId: tx.fromId ? remapPlayerId(tx.fromId, fromId, toId) : null,
toId: tx.toId ? remapPlayerId(tx.toId, fromId, toId) : null,
}));
const chats = snapshot.chats.map((message) => ({
...message,
fromId: remapPlayerId(message.fromId, fromId, toId),
}));
const groups = snapshot.groups.map((group) => ({
...group,
createdBy: remapPlayerId(group.createdBy, fromId, toId),
memberIds: group.memberIds.map((id) => remapPlayerId(id, fromId, toId)),
}));
const takeoverRequests = snapshot.takeoverRequests.map((request) => ({
...request,
dummyId: remapPlayerId(request.dummyId, fromId, toId),
requesterId: remapPlayerId(request.requesterId, fromId, toId),
}));
return {
...snapshot,
bankerId: toId,
players,
transactions,
chats,
groups,
takeoverRequests,
};
}
export function applySnapshot(session: Session, snapshot: SessionSnapshot): void {
const currentBanker = session.players.get(session.bankerId) ?? null;
const normalized = currentBanker ? remapSnapshotBanker(snapshot, currentBanker) : snapshot;
session.status = normalized.status;
session.createdAt = normalized.createdAt;
session.bankerId = currentBanker ? currentBanker.id : normalized.bankerId;
session.blackoutActive = normalized.blackoutActive;
session.blackoutReason = normalized.blackoutReason;
const players = new Map<string, Player>();
normalized.players.forEach((player) => {
players.set(player.id, { ...player });
});
const banker = players.get(session.bankerId);
if (banker) {
banker.role = "banker";
banker.connected = true;
banker.isDummy = false;
banker.lastActiveAt = now();
}
players.forEach((player) => {
if (player.id !== session.bankerId && player.role === "banker") {
player.role = "player";
}
});
session.players = players;
session.transactions = normalized.transactions.map((tx) => ({ ...tx }));
session.chats = normalized.chats.map((chat) => ({ ...chat }));
session.groups = normalized.groups.map((group) => ({ ...group }));
session.takeoverRequests = normalized.takeoverRequests.map((request) => ({ ...request }));
}
export function isBlackoutActive(session: Session): boolean {
return session.blackoutActive;
}
export function joinSession(
session: Session,
name: string,
playerId?: string | null,
): Player {
const trimmedName = name.trim() || "Player";
if (playerId) {
const existing = session.players.get(playerId);
if (existing) {
existing.connected = true;
existing.isDummy = false;
existing.name = trimmedName;
existing.lastActiveAt = now();
return existing;
}
}
const id = createId();
const joinedAt = now();
const player: Player = {
id,
name: trimmedName,
role: "player",
balance: DEFAULT_START_BALANCE,
connected: true,
isDummy: false,
joinedAt,
lastActiveAt: joinedAt,
};
session.players.set(id, player);
return player;
}
export function startSession(session: Session, bankerId: string): void {
ensureBanker(session, bankerId);
if (session.status === "ended") {
throw new DomainError("Session has already ended");
}
session.status = "active";
}
export function endSession(session: Session, bankerId: string): void {
ensureBanker(session, bankerId);
session.status = "ended";
}
export function disconnectPlayer(session: Session, playerId: string): void {
const player = getPlayer(session, playerId);
player.connected = false;
player.lastActiveAt = now();
if (player.role !== "banker") {
player.isDummy = true;
}
}
export function setBlackout(
session: Session,
bankerId: string,
active: boolean,
reason?: string | null,
): void {
ensureOpenSession(session);
ensureBanker(session, bankerId);
session.blackoutActive = Boolean(active);
session.blackoutReason = active ? reason?.trim() || "EMP in effect" : null;
}
export function transfer(
session: Session,
fromId: string,
toId: string,
amountValue: unknown,
note?: string | null,
): Transaction {
ensureActiveSession(session);
ensureNotBlackout(session, fromId);
const amount = parseAmount(amountValue);
if (amount === null || amount <= 0) {
throw new DomainError("Transfer amount must be positive");
}
const from = getPlayer(session, fromId);
const to = getPlayer(session, toId);
if (from.role === "banker" || to.role === "banker") {
throw new DomainError("Banker does not hold a balance");
}
if (from.id === to.id) {
throw new DomainError("Cannot transfer to yourself");
}
if (from.balance < amount) {
throw new DomainError("Insufficient funds");
}
from.balance = clampBalance(from.balance - amount);
to.balance = clampBalance(to.balance + amount);
const transaction = createTransaction("transfer", from.id, to.id, amount, note, "player");
session.transactions.unshift(transaction);
return transaction;
}
export function bankerAdjust(
session: Session,
bankerId: string,
targetId: string,
amountValue: unknown,
note?: string | null,
): Transaction {
ensureActiveSession(session);
ensureNotBlackout(session, bankerId);
ensureBanker(session, bankerId);
const amount = parseAmount(amountValue);
if (amount === null || amount === 0) {
throw new DomainError("Adjustment amount must be non-zero");
}
const target = getPlayer(session, targetId);
if (target.role === "banker") {
throw new DomainError("Banker does not hold a balance");
}
target.balance = clampBalance(target.balance + amount);
const transaction = createTransaction(
"banker_adjust",
null,
target.id,
amount,
note,
"banker",
);
session.transactions.unshift(transaction);
return transaction;
}
export function bankerForceTransfer(
session: Session,
bankerId: string,
fromId: string,
toId: string,
amountValue: unknown,
note?: string | null,
): Transaction {
ensureActiveSession(session);
ensureNotBlackout(session, bankerId);
ensureBanker(session, bankerId);
const amount = parseAmount(amountValue);
if (amount === null || amount <= 0) {
throw new DomainError("Transfer amount must be positive");
}
const from = getPlayer(session, fromId);
const to = getPlayer(session, toId);
if (from.role === "banker" || to.role === "banker") {
throw new DomainError("Banker does not hold a balance");
}
if (from.id === to.id) {
throw new DomainError("Cannot transfer to the same player");
}
if (from.balance < amount) {
throw new DomainError("Source player lacks funds");
}
from.balance = clampBalance(from.balance - amount);
to.balance = clampBalance(to.balance + amount);
const transaction = createTransaction(
"banker_force_transfer",
from.id,
to.id,
amount,
note,
"banker",
);
session.transactions.unshift(transaction);
return transaction;
}
export function createDummyPlayer(
session: Session,
bankerId: string,
name: string,
balanceValue?: unknown,
): Player {
ensureOpenSession(session);
ensureBanker(session, bankerId);
const joinedAt = now();
const balance = parseAmount(balanceValue ?? DEFAULT_START_BALANCE) ?? DEFAULT_START_BALANCE;
const player: Player = {
id: createId(),
name: name.trim() || "Dummy",
role: "player",
balance: clampBalance(balance),
connected: false,
isDummy: true,
joinedAt,
lastActiveAt: joinedAt,
};
session.players.set(player.id, player);
return player;
}
export function addChatMessage(
session: Session,
playerId: string,
body: string,
groupId?: string | null,
): ChatMessage {
ensureOpenSession(session);
ensureNotBlackout(session, playerId);
const trimmed = body.trim();
if (!trimmed) {
throw new DomainError("Message cannot be empty");
}
if (trimmed.length > MAX_CHAT_LENGTH) {
throw new DomainError("Message is too long");
}
const sender = getPlayer(session, playerId);
const message: ChatMessage = {
id: createId(),
fromId: sender.id,
body: trimmed,
createdAt: now(),
groupId: groupId ?? null,
};
session.chats.unshift(message);
return message;
}
export function createChatGroup(
session: Session,
playerId: string,
name: string,
memberIds: string[],
): ChatGroup {
ensureOpenSession(session);
ensureNotBlackout(session, playerId);
getPlayer(session, playerId);
const trimmed = name.trim();
if (!trimmed) {
throw new DomainError("Group name is required");
}
const deduped = Array.from(new Set([playerId, ...memberIds]));
deduped.forEach((memberId) => getPlayer(session, memberId));
const group: ChatGroup = {
id: createId(),
name: trimmed,
memberIds: deduped,
createdAt: now(),
createdBy: playerId,
};
session.groups.unshift(group);
return group;
}
export function requestTakeover(
session: Session,
requesterId: string,
dummyId: string,
): TakeoverRequest {
ensureOpenSession(session);
const requester = getPlayer(session, requesterId);
const dummy = getPlayer(session, dummyId);
if (!dummy.isDummy) {
throw new DomainError("Selected player is not available for takeover");
}
if (requester.role === "banker") {
throw new DomainError("Banker cannot request takeover");
}
const existing = session.takeoverRequests.find(
(request) =>
request.dummyId === dummyId &&
request.requesterId === requesterId &&
request.status === "pending",
);
if (existing) {
return existing;
}
const request: TakeoverRequest = {
id: createId(),
dummyId,
requesterId,
createdAt: now(),
status: "pending",
};
session.takeoverRequests.unshift(request);
return request;
}
export function approveTakeover(
session: Session,
bankerId: string,
dummyId: string,
requesterId: string,
): string {
ensureOpenSession(session);
ensureBanker(session, bankerId);
const dummy = getPlayer(session, dummyId);
const requester = getPlayer(session, requesterId);
if (!dummy.isDummy) {
throw new DomainError("Selected player is not a dummy");
}
const targetRequest = session.takeoverRequests.find(
(request) =>
request.dummyId === dummyId &&
request.requesterId === requesterId &&
request.status === "pending",
);
if (!targetRequest) {
throw new DomainError("No takeover request found");
}
targetRequest.status = "approved";
// Reject any other pending requests for the same dummy.
session.takeoverRequests.forEach((request) => {
if (
request.dummyId === dummyId &&
request.status === "pending" &&
request.requesterId !== requesterId
) {
request.status = "rejected";
}
});
dummy.isDummy = false;
dummy.connected = requester.connected;
dummy.name = requester.name;
dummy.lastActiveAt = now();
session.players.delete(requesterId);
return dummy.id;
}
function ensureActiveSession(session: Session): void {
ensureOpenSession(session);
}
function ensureNotBlackout(session: Session, actorId?: string): void {
if (!isBlackoutActive(session)) {
return;
}
if (actorId) {
const actor = session.players.get(actorId);
if (actor?.role === "banker") {
return;
}
}
throw new DomainError("EMP in effect");
}
function ensureOpenSession(session: Session): void {
if (session.status === "ended") {
throw new DomainError("Session has ended");
}
}
function ensureBanker(session: Session, bankerId: string): void {
const banker = getPlayer(session, bankerId);
if (banker.role !== "banker") {
throw new DomainError("Only the banker can perform this action");
}
}
function getPlayer(session: Session, playerId: string): Player {
const player = session.players.get(playerId);
if (!player) {
throw new DomainError("Player not found");
}
return player;
}
function createTransaction(
kind: Transaction["kind"],
fromId: string | null,
toId: string | null,
amount: number,
note: string | null | undefined,
initiatedBy: Transaction["initiatedBy"],
): Transaction {
return {
id: createId(),
kind,
fromId,
toId,
amount: clampBalance(amount),
note: note?.trim() || null,
createdAt: now(),
initiatedBy,
};
}

8
server/errors.ts Normal file
View file

@ -0,0 +1,8 @@
export class DomainError extends Error {
code: string;
constructor(message: string, code = "domain_error") {
super(message);
this.code = code;
}
}

98
server/protocol.ts Normal file
View file

@ -0,0 +1,98 @@
import type { SessionSnapshot } from "../shared/types";
export type ClientMessage =
| {
type: "chat_send";
sessionId: string;
playerId: string;
body: string;
groupId?: string | null;
}
| {
type: "transfer";
sessionId: string;
playerId: string;
toPlayerId: string;
amount: number;
note?: string | null;
}
| {
type: "banker_adjust";
sessionId: string;
bankerId: string;
targetId: string;
amount: number;
note?: string | null;
}
| {
type: "banker_force_transfer";
sessionId: string;
bankerId: string;
fromId: string;
toId: string;
amount: number;
note?: string | null;
}
| {
type: "banker_blackout";
sessionId: string;
bankerId: string;
active: boolean;
reason?: string | null;
}
| {
type: "banker_start";
sessionId: string;
bankerId: string;
}
| {
type: "banker_end";
sessionId: string;
bankerId: string;
}
| {
type: "banker_create_dummy";
sessionId: string;
bankerId: string;
name: string;
balance?: number;
}
| {
type: "group_create";
sessionId: string;
playerId: string;
name: string;
memberIds: string[];
}
| {
type: "takeover_request";
sessionId: string;
playerId: string;
dummyId: string;
}
| {
type: "banker_takeover_approve";
sessionId: string;
bankerId: string;
dummyId: string;
requesterId: string;
}
| {
type: "ping";
sessionId: string;
playerId: string;
};
export type ServerMessage =
| {
type: "state";
session: SessionSnapshot;
}
| {
type: "error";
message: string;
}
| {
type: "takeover_approved";
assignedPlayerId: string;
};

177
server/store.ts Normal file
View file

@ -0,0 +1,177 @@
import type { Session } from "./types";
import type { Player, SessionSnapshot } from "../shared/types";
import { createId, createSessionCode, now, DEFAULT_START_BALANCE } from "./util";
const sessions = new Map<string, Session>();
const sessionsByCode = new Map<string, string>();
export const TEST_SESSION_CODE = "TEST";
const TEST_BANKER_NAME = "Alan Wa Chahabab";
const TEST_PLAYER_NAMES = ["Nesta", "Niko Stinko", "Gaspard l'Batard"];
export type CreateSessionResult = {
session: Session;
banker: Player;
};
export function createSession(bankerName: string): CreateSessionResult {
const id = createId();
const code = createSessionCode(new Set(sessionsByCode.keys()));
const createdAt = now();
const bankerId = createId();
const banker: Player = {
id: bankerId,
name: bankerName.trim() || "Banker",
role: "banker",
balance: 0,
connected: true,
isDummy: false,
joinedAt: createdAt,
lastActiveAt: createdAt,
};
const session: Session = {
id,
code,
status: "lobby",
createdAt,
bankerId,
blackoutActive: false,
blackoutReason: null,
players: new Map([[bankerId, banker]]),
transactions: [],
chats: [],
groups: [],
takeoverRequests: [],
};
sessions.set(id, session);
sessionsByCode.set(code, id);
return { session, banker };
}
export function isTestSessionCode(code: string): boolean {
return code.trim().toUpperCase() === TEST_SESSION_CODE;
}
export function createTestSession(): Session {
const id = createId();
const createdAt = now();
const bankerId = createId();
const banker: Player = {
id: bankerId,
name: TEST_BANKER_NAME,
role: "banker",
balance: 0,
connected: true,
isDummy: false,
joinedAt: createdAt,
lastActiveAt: createdAt,
};
const players = new Map<string, Player>();
players.set(bankerId, banker);
TEST_PLAYER_NAMES.forEach((name) => {
const playerId = createId();
players.set(playerId, {
id: playerId,
name,
role: "player",
balance: DEFAULT_START_BALANCE,
connected: true,
isDummy: false,
joinedAt: createdAt,
lastActiveAt: createdAt,
});
});
const session: Session = {
id,
code: TEST_SESSION_CODE,
status: "active",
createdAt,
bankerId,
blackoutActive: false,
blackoutReason: null,
players,
transactions: [],
chats: [],
groups: [],
takeoverRequests: [],
isTest: true,
};
sessions.set(id, session);
return session;
}
export function createTestPreview(): SessionSnapshot {
const createdAt = now();
const bankerId = createId();
const players: Player[] = [
{
id: bankerId,
name: TEST_BANKER_NAME,
role: "banker",
balance: 0,
connected: true,
isDummy: false,
joinedAt: createdAt,
lastActiveAt: createdAt,
},
...TEST_PLAYER_NAMES.map((name) => ({
id: createId(),
name,
role: "player" as const,
balance: DEFAULT_START_BALANCE,
connected: true,
isDummy: false,
joinedAt: createdAt,
lastActiveAt: createdAt,
})),
];
return {
id: TEST_SESSION_CODE,
code: TEST_SESSION_CODE,
status: "active",
createdAt,
bankerId,
blackoutActive: false,
blackoutReason: null,
players,
transactions: [],
chats: [],
groups: [],
takeoverRequests: [],
};
}
export function getSession(id: string): Session | null {
return sessions.get(id) ?? null;
}
export function getSessionByCode(code: string): Session | null {
const id = sessionsByCode.get(code.toUpperCase());
if (!id) {
return null;
}
return sessions.get(id) ?? null;
}
export function removeSession(id: string): void {
const session = sessions.get(id);
if (!session) {
return;
}
sessions.delete(id);
sessionsByCode.delete(session.code);
}
export function listSessions(): Session[] {
return Array.from(sessions.values());
}

27
server/types.ts Normal file
View file

@ -0,0 +1,27 @@
import type {
ChatGroup,
ChatMessage,
Player,
SessionSnapshot,
SessionStatus,
TakeoverRequest,
Transaction,
} from "../shared/types";
export type Session = {
id: string;
code: string;
status: SessionStatus;
createdAt: number;
bankerId: string;
blackoutActive: boolean;
blackoutReason: string | null;
players: Map<string, Player>;
transactions: Transaction[];
chats: ChatMessage[];
groups: ChatGroup[];
takeoverRequests: TakeoverRequest[];
isTest?: boolean;
};
export type SessionSnapshotPayload = SessionSnapshot;

41
server/util.ts Normal file
View file

@ -0,0 +1,41 @@
export const DEFAULT_START_BALANCE = 1500;
const CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function createId(): string {
return crypto.randomUUID();
}
export function createSessionCode(existing: Set<string>): string {
for (let i = 0; i < 1000; i += 1) {
let code = "";
for (let j = 0; j < 5; j += 1) {
code += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)];
}
if (!existing.has(code)) {
return code;
}
}
throw new Error("Unable to allocate session code");
}
export function now(): number {
return Date.now();
}
export function parseAmount(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
export function clampBalance(value: number): number {
return Math.round(value * 100) / 100;
}

284
server/websocket.ts Normal file
View file

@ -0,0 +1,284 @@
import type { ServerMessage, ClientMessage } from "./protocol";
import type { Session } from "./types";
import { DomainError } from "./errors";
import {
addChatMessage,
approveTakeover,
bankerAdjust,
bankerForceTransfer,
createChatGroup,
createDummyPlayer,
disconnectPlayer,
requestTakeover,
setBlackout,
snapshotSession,
startSession,
endSession,
transfer,
} from "./domain";
import { getSession, removeSession } from "./store";
import { now } from "./util";
const socketsBySession = new Map<string, Set<WebSocket>>();
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function scheduleTestTransfer(sessionId: string): void {
const delay = randomInt(3000, 7000);
const timer = setTimeout(() => {
testTimers.delete(sessionId);
runTestTransfer(sessionId);
}, delay);
testTimers.set(sessionId, timer);
}
function runTestTransfer(sessionId: string): void {
const session = getSession(sessionId);
if (!session || !session.isTest) {
return;
}
if (session.status !== "active") {
return;
}
const players = Array.from(session.players.values()).filter(
(player) => player.role === "player",
);
if (players.length < 2) {
scheduleTestTransfer(sessionId);
return;
}
let attempts = 0;
while (attempts < 5) {
const from = players[randomInt(0, players.length - 1)];
let to = players[randomInt(0, players.length - 1)];
if (to.id === from.id) {
attempts += 1;
continue;
}
const amount = randomInt(10, 250);
if (from.balance < amount) {
attempts += 1;
continue;
}
try {
transfer(session, from.id, to.id, amount, null);
sendStateToSession(session);
break;
} catch {
attempts += 1;
}
}
scheduleTestTransfer(sessionId);
}
export function startTestSimulation(sessionId: string): void {
if (testTimers.has(sessionId)) return;
scheduleTestTransfer(sessionId);
}
export function stopTestSimulation(sessionId: string): void {
const timer = testTimers.get(sessionId);
if (timer) {
clearTimeout(timer);
}
testTimers.delete(sessionId);
}
function getSessionSockets(sessionId: string): Set<WebSocket> {
let set = socketsBySession.get(sessionId);
if (!set) {
set = new Set();
socketsBySession.set(sessionId, set);
}
return set;
}
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
const session = getSession(sessionId);
if (!session) {
ws.close(1008, "Session not found");
return;
}
const player = session.players.get(playerId);
if (!player) {
ws.close(1008, "Player not found");
return;
}
player.connected = true;
player.isDummy = false;
player.lastActiveAt = now();
metaBySocket.set(ws, { sessionId, playerId });
getSessionSockets(sessionId).add(ws);
sendStateToSession(session);
}
export function unregisterSocket(ws: WebSocket): void {
const meta = metaBySocket.get(ws);
if (!meta) {
return;
}
const { sessionId, playerId } = meta;
const session = getSession(sessionId);
if (session) {
disconnectPlayer(session, playerId);
sendStateToSession(session);
}
const set = socketsBySession.get(sessionId);
if (set) {
set.delete(ws);
if (set.size === 0) {
socketsBySession.delete(sessionId);
if (session?.isTest) {
stopTestSimulation(sessionId);
removeSession(sessionId);
}
}
}
metaBySocket.delete(ws);
}
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
let parsed: ClientMessage;
try {
parsed = JSON.parse(messageText) as ClientMessage;
} catch (error) {
send(ws, { type: "error", message: "Invalid message" });
return;
}
const session = getSession(parsed.sessionId);
if (!session) {
send(ws, { type: "error", message: "Session not found" });
return;
}
try {
handleMessage(session, parsed);
} catch (error) {
const message =
error instanceof DomainError
? error.message
: "Unexpected error while processing request";
send(ws, { type: "error", message });
return;
}
sendStateToSession(session);
}
function handleMessage(session: Session, message: ClientMessage): void {
switch (message.type) {
case "chat_send":
addChatMessage(session, message.playerId, message.body, message.groupId);
return;
case "transfer":
transfer(session, message.playerId, message.toPlayerId, message.amount, message.note);
return;
case "banker_adjust":
bankerAdjust(session, message.bankerId, message.targetId, message.amount, message.note);
return;
case "banker_force_transfer":
bankerForceTransfer(
session,
message.bankerId,
message.fromId,
message.toId,
message.amount,
message.note,
);
return;
case "banker_blackout":
setBlackout(session, message.bankerId, message.active, message.reason);
return;
case "banker_start":
startSession(session, message.bankerId);
return;
case "banker_end":
endSession(session, message.bankerId);
return;
case "banker_create_dummy":
createDummyPlayer(session, message.bankerId, message.name, message.balance);
return;
case "group_create":
createChatGroup(session, message.playerId, message.name, message.memberIds);
return;
case "takeover_request":
requestTakeover(session, message.playerId, message.dummyId);
return;
case "banker_takeover_approve": {
const assignedId = approveTakeover(
session,
message.bankerId,
message.dummyId,
message.requesterId,
);
notifyTakeoverApproval(session.id, message.requesterId, assignedId);
return;
}
case "ping":
touchPlayer(session, message.playerId);
return;
default:
return;
}
}
function touchPlayer(session: Session, playerId: string): void {
const player = session.players.get(playerId);
if (player) {
player.lastActiveAt = now();
}
}
function sendStateToSession(session: Session): void {
const sockets = socketsBySession.get(session.id);
if (!sockets) {
return;
}
const stateMessage: ServerMessage = {
type: "state",
session: snapshotSession(session),
};
sockets.forEach((socket) => send(socket, stateMessage));
}
export function broadcastSessionState(sessionId: string): void {
const session = getSession(sessionId);
if (!session) return;
sendStateToSession(session);
}
function notifyTakeoverApproval(
sessionId: string,
requesterId: string,
assignedId: string,
): void {
const sockets = socketsBySession.get(sessionId);
if (!sockets) {
return;
}
sockets.forEach((socket) => {
const meta = metaBySocket.get(socket);
if (!meta) {
return;
}
if (meta.playerId === requesterId) {
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
meta.playerId = assignedId;
}
});
}
function send(ws: WebSocket, message: ServerMessage): void {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(message));
}

68
shared/types.ts Normal file
View file

@ -0,0 +1,68 @@
export type SessionStatus = "lobby" | "active" | "ended";
export type PlayerRole = "banker" | "player";
export type Player = {
id: string;
name: string;
role: PlayerRole;
balance: number;
connected: boolean;
isDummy: boolean;
joinedAt: number;
lastActiveAt: number;
};
export type TransactionKind =
| "transfer"
| "banker_adjust"
| "banker_force_transfer";
export type Transaction = {
id: string;
kind: TransactionKind;
fromId: string | null;
toId: string | null;
amount: number;
note: string | null;
createdAt: number;
initiatedBy: PlayerRole;
};
export type ChatGroup = {
id: string;
name: string;
memberIds: string[];
createdAt: number;
createdBy: string;
};
export type ChatMessage = {
id: string;
fromId: string;
body: string;
createdAt: number;
groupId: string | null;
};
export type TakeoverRequest = {
id: string;
dummyId: string;
requesterId: string;
createdAt: number;
status: "pending" | "approved" | "rejected";
};
export type SessionSnapshot = {
id: string;
code: string;
status: SessionStatus;
createdAt: number;
bankerId: string;
blackoutActive: boolean;
blackoutReason: string | null;
players: Player[];
transactions: Transaction[];
chats: ChatMessage[];
groups: ChatGroup[];
takeoverRequests: TakeoverRequest[];
};

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}