FiveRP: the server from the hosting account, not the earlier one

Wrong server was published before. This is the one that runs on the
hosting account the video was made on: FiveRP - two-step registration
(account, then an identity printed onto a passport), login that returns
you to your resident, and a loading screen driven by the game's own
streaming events.

  resources/[local]/fiverp-auth          NUI + scrypt hashes + oxmysql
  resources/[local]/fiverp-characters    identity, character load, spawn
  resources/[local]/fiverp-loadscreen    loading screen
  sql/schema.sql                         accounts, characters
  docs/DESIGN.md                         the design system every screen follows
  docs/screens/                          shot from the live server

Only our own code is in here. cfx-server-data and oxmysql are fetched by
install.sh instead of being vendored, which keeps the repo at 3.7 MB.

install.sh does the whole box in one command: recommended FXServer
build, cfx-server-data, oxmysql, MariaDB with the schema, resources,
server.cfg (mode 600 - it carries the key and the database password),
boot entry, then it waits for the Cfx registration. Tested end to end on
a spare install root: 29 resources scanned, database and schema created,
and it stopped exactly where a wrong licence key should stop it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 5
2026-08-12 23:42:20 +00:00
co-authored by Claude Opus 5
parent 71856b15e9
commit c857979a55
88 changed files with 3904 additions and 7463 deletions
+4 -5
View File
@@ -1,12 +1,11 @@
# Anything holding a licence key or a password # Anything holding a licence key or a password
server.cfg
secrets.cfg secrets.cfg
*.env *.env
.env
# Runtime state, never in the repo # Runtime state
cache/ cache/
logs/ logs/
*.log *.log
/server/ server/
/data/ server-data/
/server.cfg
+54 -84
View File
@@ -1,36 +1,28 @@
# redl-fivem-rp # FiveRP
An open-source **FiveM roleplay base written completely from scratch** — no ESX, no QBCore, no scripts downloaded from a forum. Every line here was written by an AI agent on a live server, on camera, and it is free for anyone to take and build on. An open **FiveM roleplay base** — account registration, login and character identity, written by an AI agent on a live server, on camera, and free for anyone to take and build on.
> 🇷🇺 Русская версия — ниже / Russian version below. > 🇷🇺 Русская версия — ниже / Russian version below.
This repository is the server itself, exactly as it runs: the resources, the schema, the supervisor, the config. `sh install.sh` puts it on a clean Ubuntu box. Not a framework fork: `fiverp-auth`, `fiverp-characters` and `fiverp-loadscreen` are written for this server. No ESX, no QBCore. The only things that come from outside are the stock Cfx resources (`mapmanager`, `chat`, `spawnmanager`, `sessionmanager`, `hardcap`) and `oxmysql` — the installer fetches both, so this repository stays small and readable.
## Why another base? ## What it does today
Most FiveM projects start by dropping in a 200 MB framework, of which you use maybe 5%. You inherit somebody else's bugs, somebody else's dependencies, and you end up with the same server as a thousand other people — and you're afraid to touch any of it, because you don't know what breaks. - **Two-step registration** — account first (email, username, password), identity second (first and last name, issued once, printed onto a passport).
- **Login** that returns you to your resident, with the character loaded from the database.
- **Loading screen** with real progress from the game's own streaming events.
- **Passwords hashed with scrypt**, per-password salt, stored as `scrypt$salt$hash`. The client never decides anything: registration, login and spawning are all server side.
- **`basic-gamemode` is deliberately off** — it force-respawns on map start, which would drop a player into the world behind the auth screen. `fiverp-auth` owns spawning instead.
This is the opposite. It's small, it's readable, and it only contains what was actually asked for. If you don't understand a file, you can ask for it to be rewritten. | Loading | Sign in | Register | Identity |
|---|---|---|---|
| ![Loading](docs/screens/loading.png) | ![Sign in](docs/screens/signin.png) | ![Register](docs/screens/register.png) | ![Identity](docs/screens/identity.png) |
## What's in it The whole interface follows one design system — a light, glassy theme on **Unbounded**, with the city visible behind the panels. Every colour, radius and font is a token; the rules are in [`docs/DESIGN.md`](docs/DESIGN.md), and the fonts ship with the resources (a NUI page has no guaranteed internet).
- Own MySQL driver (`rp_db`) — the wire protocol spoken directly over a TCP socket: handshake, `mysql_native_password`, text result sets, a connection pool, transactions. No npm packages, no third-party database resource.
- Accounts and characters (`rp_core`, `rp_session`): registration, login, scrypt password hashing, the connection gate (bans, capacity), character creation and spawn. Every decision is made server-side; the client can only ask.
- NUI screens with real design work (`rp_ui`, `rp_loading`), not a template: a loading screen with a procedural night-city flyover and synthesised ambience, sign-in / register with live validation, character creation with an orbiting camera and a live ped, and a spawn picker.
- A self-test (`rp_selftest`) that proves the account/character data path survived the last restart, and a driver test suite (`rp_dbtest`).
- `cfx-server-data` is not used at all — the player cap, chat, spawning and session handling are ours.
## Screens
The whole UI follows one theme the agent picked itself — **Los Santos County records**: your login is a residency file, character creation is an identity-record intake, spawn selection is a county survey map.
| Auth — RESIDENT INTAKE | Character — identity record | Spawn — county survey grid |
|---|---|---|
| ![Auth](docs/screens/auth.png) | ![Character](docs/screens/character.png) | ![Spawn](docs/screens/spawn.png) |
## Install ## Install
Ubuntu 22.04 / 24.04 (or Debian 12), 4 vCPU / 8 GB RAM recommended, and a free server key from [keymaster.fivem.net](https://keymaster.fivem.net). Ubuntu 22.04 / 24.04 or Debian 12, and a free server key from [keymaster.fivem.net](https://keymaster.fivem.net).
```sh ```sh
git clone https://github.com/RedlHosting/redl-fivem-rp.git git clone https://github.com/RedlHosting/redl-fivem-rp.git
@@ -38,79 +30,63 @@ cd redl-fivem-rp
sudo sh install.sh --licence cfxk_your_key_here sudo sh install.sh --licence cfxk_your_key_here
``` ```
The installer fetches the recommended FXServer build, installs MariaDB and applies the schema, copies the resources into `/opt/fivem`, writes `server.cfg` and a `secrets.cfg` with a generated database password, sets the server to start on boot, and waits until it has registered with Cfx. Then connect to `<your ip>:30120`. The installer fetches the recommended FXServer build, clones `cfx-server-data`, installs `oxmysql`, sets up MariaDB with the schema and a generated password, copies the FiveRP resources in, writes `server.cfg`, adds a boot entry and waits until the server has registered with Cfx. Then connect to `<your ip>:30120`.
Useful options: `--dir`, `--hostname`, `--port`, `--name` (run several servers on one box), `--db-name`, `--db-user`, `--no-start`. Run `sh install.sh --help` for the list. Options: `--dir`, `--hostname`, `--port`, `--name` (a second server on the same box), `--db-name`, `--db-user`, `--db-pass`, `--build`, `--no-start`. Run `sh install.sh --help`.
Day to day: Day to day:
```sh ```sh
service fivem start|stop|restart|status # systemd boxes: systemctl ... service fiverp start|stop|restart|status # systemd boxes: systemctl ...
rcon status # send a command to the live console rcon status # command into the live console
tail -f /opt/fivem/logs/server.log tail -f /opt/fivem/logs/server.log
``` ```
## Layout ## Layout
resources/[rp]/ the server: rp_db, rp_core, rp_session, rp_loading, rp_ui, rp_selftest, rp_dbtest resources/[local]/fiverp-auth registration and login (NUI + scrypt + oxmysql)
etc/schema.sql database schema (accounts, characters, transactions, inventory, vehicles) resources/[local]/fiverp-characters identity, character load and spawn
bin/ supervise.sh (keep-alive + console FIFO), rcon, the init script resources/[local]/fiverp-loadscreen loading screen
assets/fonts/ the bundled font subsets — a NUI page has no guaranteed internet sql/schema.sql accounts, characters
server.cfg.example main config server.cfg.example main config
etc/secrets.cfg.example bin/ keep-alive supervisor, rcon, init script
docs/SERVER.md how it is put together, resource by resource docs/DESIGN.md the design system every screen follows
`docs/SERVER.md` is the design document: what each resource does, why the interface looks the way it does, how the database is protected.
## What's not built yet ## What's not built yet
The pre-spawn experience and the framework underneath it are done. Gameplay on top of it — chat, HUD, inventory, jobs, vehicles, policing, medical — is not. `characters`, `transactions`, `inventory` and `vehicles` already have their tables, and the money API in `rp_core/server/player.lua` is journalled and ready. Everything after the spawn: chat, HUD, inventory, money, jobs, vehicles. The `characters` table already carries model, appearance, position and heading, so gameplay has somewhere to write.
- **Part 1** — the foundation: database, registration, login, character creation ← *we are here* Issues and pull requests welcome — this is meant to be used, not admired.
- **Part 2** — money, jobs, the bank
- **Part 3** — inventory and phone
- **Part 4** — garages and vehicles
- **Part 5** — an admin panel and the project website on the same machine
Issues and pull requests are welcome — this is meant to be used, not admired.
## How it was made ## How it was made
Everything is built on a [REDL](https://redl.io) VDS, where an AI agent lives on the server itself with root access. The task is given in plain words, the agent writes the code, fixes its own mistakes and restarts the service. The whole process is recorded — including the parts where it broke. Built on a [REDL](https://redl.io) VDS, where an AI agent lives on the server itself with root access. The task is given in plain words, the agent writes the code, fixes its own mistakes and restarts the service. The whole process is recorded — including the parts where it broke.
## Licence ## Licence
MIT — take it, change it, ship it. Credit is nice but not required. MIT for the FiveRP code. `cfx-server-data` and `oxmysql` keep their own licences; Unbounded is under the SIL Open Font License.
--- ---
# redl-fivem-rp (RU) # FiveRP (RU)
Открытая **ролевая база для FiveM, написанная полностью с нуля** — без ESX, без QBCore, без скачанных с форума скриптов. Каждая строчка здесь написана ИИ-агентом на живом сервере, в кадре, и выложена бесплатно для всех. Открытая **ролевая база для FiveM** — регистрация аккаунта, вход и личность персонажа, написанные ИИ-агентом на живом сервере, в кадре, и выложенные бесплатно для всех.
В репозитории лежит сам сервер, ровно в том виде, в каком он работает: ресурсы, схема базы, супервизор, конфиг. `sh install.sh` ставит его на чистую Ubuntu. Это не форк фреймворка: `fiverp-auth`, `fiverp-characters` и `fiverp-loadscreen` написаны под этот сервер. Никакого ESX и QBCore. Извне берутся только штатные ресурсы Cfx (`mapmanager`, `chat`, `spawnmanager`, `sessionmanager`, `hardcap`) и `oxmysql` — установщик скачивает их сам, поэтому репозиторий остаётся маленьким и читаемым.
## Зачем ещё одна база? ## Что уже работает
Обычно проект начинается с того, что ставится сборка на 200 мегабайт, из которой реально используется процентов пять. Вместе с ней приезжают чужие баги, чужие зависимости, и в итоге у тебя такой же сервер, как ещё у тысячи человек, — и трогать его страшно, потому что непонятно, что отвалится. - **Регистрация в два шага** — сначала аккаунт (почта, логин, пароль), потом личность (имя и фамилия, выдаются один раз и печатаются на паспорте).
- **Вход**, который возвращает к своему персонажу — данные поднимаются из базы.
- **Загрузочный экран** с настоящим прогрессом из событий стриминга игры.
- **Пароли на scrypt**, соль на каждый пароль, хранение в виде `scrypt$соль$хеш`. Клиент ничего не решает: регистрация, вход и спавн — на сервере.
- **`basic-gamemode` намеренно выключен** — он делает принудительный респавн при старте карты и выбрасывал бы игрока в мир за экраном авторизации. Спавном занимается `fiverp-auth`.
Здесь наоборот. Мало кода, он читаемый, и в нём есть только то, что мы попросили. Не понял файл — попросил переписать. Весь интерфейс живёт по одной дизайн-системе — светлая «стеклянная» тема на шрифте **Unbounded**, город виден за панелями. Каждый цвет, радиус и шрифт — токен; правила в [`docs/DESIGN.md`](docs/DESIGN.md), шрифты лежат внутри ресурсов (у страницы NUI нет гарантированного интернета).
## Что внутри
- Свой драйвер MySQL (`rp_db`) — протокол реализован прямо поверх TCP-сокета: рукопожатие, `mysql_native_password`, разбор результатов, пул соединений, транзакции. Ни одного npm-пакета и ни одного чужого ресурса для базы.
- Аккаунты и персонажи (`rp_core`, `rp_session`): регистрация, вход, хеширование паролей scrypt, шлюз подключения (баны, вместимость), создание персонажа и спавн. Все решения принимает сервер, клиент может только попросить.
- Экраны NUI с настоящей дизайн-проработкой (`rp_ui`, `rp_loading`), а не шаблон: загрузочный экран с процедурным облётом ночного города и синтезированной атмосферой, вход/регистрация с живой валидацией, создание персонажа с облётом камеры и живой моделью, выбор точки спавна.
- Самотест (`rp_selftest`), который на каждом запуске доказывает, что данные аккаунтов и персонажей пережили перезапуск, и набор тестов драйвера (`rp_dbtest`).
- `cfx-server-data` не используется вообще — лимит игроков, чат, спавн и сессии свои.
## Экраны
Весь интерфейс сделан в одной теме, которую агент выбрал сам, — **архив округа Лос-Сантос**: вход это дело о проживании, создание персонажа — анкета учёта личности, выбор спавна — топографическая карта округа.
## Установка ## Установка
Ubuntu 22.04 / 24.04 (или Debian 12), рекомендую 4 vCPU / 8 ГБ RAM, и бесплатный ключ сервера с [keymaster.fivem.net](https://keymaster.fivem.net). Ubuntu 22.04 / 24.04 или Debian 12 и бесплатный ключ сервера с [keymaster.fivem.net](https://keymaster.fivem.net).
```sh ```sh
git clone https://github.com/RedlHosting/redl-fivem-rp.git git clone https://github.com/RedlHosting/redl-fivem-rp.git
@@ -118,44 +94,38 @@ cd redl-fivem-rp
sudo sh install.sh --licence cfxk_ваш_ключ sudo sh install.sh --licence cfxk_ваш_ключ
``` ```
Установщик сам скачает рекомендованную сборку FXServer, поставит MariaDB и накатит схему, разложит ресурсы в `/opt/fivem`, напишет `server.cfg` и `secrets.cfg` со сгенерированным паролем базы, поставит сервер в автозапуск и дождётся регистрации в Cfx. Дальше — `connect <ваш ip>:30120`. Установщик скачает рекомендованную сборку FXServer, склонирует `cfx-server-data`, поставит `oxmysql`, поднимет MariaDB со схемой и сгенерированным паролем, положит ресурсы FiveRP, напишет `server.cfg`, пропишет автозапуск и дождётся регистрации сервера в Cfx. Дальше — `connect <ваш ip>:30120`.
Полезные параметры: `--dir`, `--hostname`, `--port`, `--name` (несколько серверов на одной машине), `--db-name`, `--db-user`, `--no-start`. Список — `sh install.sh --help`. Параметры: `--dir`, `--hostname`, `--port`, `--name` (второй сервер на той же машине), `--db-name`, `--db-user`, `--db-pass`, `--build`, `--no-start`. Список — `sh install.sh --help`.
Повседневное: Повседневное:
```sh ```sh
service fivem start|stop|restart|status # на машинах с systemd — systemctl ... service fiverp start|stop|restart|status # на машинах с systemd — systemctl ...
rcon status # команда в живую консоль сервера rcon status # команда в живую консоль
tail -f /opt/fivem/logs/server.log tail -f /opt/fivem/logs/server.log
``` ```
## Что где лежит ## Что где лежит
resources/[rp]/ сам сервер: rp_db, rp_core, rp_session, rp_loading, rp_ui, rp_selftest, rp_dbtest resources/[local]/fiverp-auth регистрация и вход (NUI + scrypt + oxmysql)
etc/schema.sql схема базы (accounts, characters, transactions, inventory, vehicles) resources/[local]/fiverp-characters личность, загрузка персонажа и спавн
bin/ supervise.sh (перезапуск + FIFO консоли), rcon, init-скрипт resources/[local]/fiverp-loadscreen загрузочный экран
assets/fonts/ вшитые подмножества шрифтов — у страницы NUI нет гарантированного интернета sql/schema.sql accounts, characters
server.cfg.example основной конфиг server.cfg.example основной конфиг
etc/secrets.cfg.example bin/ супервизор, rcon, init-скрипт
docs/SERVER.md как всё устроено, ресурс за ресурсом docs/DESIGN.md дизайн-система, по которой сделаны все экраны
## Чего ещё нет ## Чего ещё нет
Всё до спавна и фундамент под этим — готово. Геймплей сверху — чат, HUD, инвентарь, работы, транспорт, полиция, медицина — нет. Таблицы `characters`, `transactions`, `inventory`, `vehicles` уже заведены, денежный API в `rp_core/server/player.lua` журналируемый и готов. Всего, что после спавна: чат, HUD, инвентарь, деньги, работы, транспорт. В таблице `characters` уже есть модель, внешность, позиция и поворот — геймплею есть куда писать.
- **Часть 1** — фундамент: база, регистрация, авторизация, создание персонажа ← *мы здесь*
- **Часть 2** — деньги, работы, банк
- **Часть 3** — инвентарь и телефон
- **Часть 4** — гаражи и транспорт
- **Часть 5** — админка + сайт проекта на этой же машине
Issues и pull request'ы приветствуются — это сделано, чтобы этим пользовались. Issues и pull request'ы приветствуются — это сделано, чтобы этим пользовались.
## Как это сделано ## Как это сделано
Всё собирается на VDS [REDL](https://redl.io), где ИИ-агент живёт прямо на сервере с root-доступом. Задача ставится обычными словами, агент пишет код, чинит свои же ошибки и перезапускает сервис. Весь процесс записан — включая те моменты, где всё ломалось. Собрано на VDS [REDL](https://redl.io), где ИИ-агент живёт прямо на сервере с root-доступом. Задача ставится обычными словами, агент пишет код, чинит свои же ошибки и перезапускает сервис. Весь процесс записан — включая моменты, где всё ломалось.
## Лицензия ## Лицензия
MIT — берите, меняйте, используйте. Упоминание приятно, но не обязательно. MIT на код FiveRP. У `cfx-server-data` и `oxmysql` свои лицензии; Unbounded — под SIL Open Font License.
Binary file not shown.
-30
View File
@@ -1,30 +0,0 @@
/* vietnamese */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 100 900;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/archivo/v25/k3kPo8UDI-1M0wlSV9XAw6lQkqWY8Q82sLySOxK-vA.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 100 900;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/archivo/v25/k3kPo8UDI-1M0wlSV9XAw6lQkqWY8Q82sLyTOxK-vA.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 100 900;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/archivo/v25/k3kPo8UDI-1M0wlSV9XAw6lQkqWY8Q82sLydOxI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Binary file not shown.
Binary file not shown.
-90
View File
@@ -1,90 +0,0 @@
/* cyrillic-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1iIq129k.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1isq129k.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1iAq129k.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1iEq129k.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1i8q1w.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwl1FgtIU.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwlRFgtIU.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwl9FgtIU.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwl5FgtIU.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3twJwlBFgg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+8 -18
View File
@@ -1,43 +1,33 @@
#!/bin/sh #!/bin/sh
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: fivem # Provides: fiverp
# Required-Start: $network mariadb # Required-Start: $network mariadb
# Default-Start: 2 3 4 5 # Default-Start: 2 3 4 5
# Short-Description: Los Santos RP FXServer # Short-Description: FiveRP (FXServer)
### END INIT INFO ### END INIT INFO
NAME=fivem NAME=fiverp
DATA=/opt/fivem/data DIR=/opt/fivem
SERVER=/opt/fivem/server
LOG=/opt/fivem/logs/server.log LOG=/opt/fivem/logs/server.log
SUPPID=/run/$NAME.sup.pid SUPPID=/run/$NAME.sup.pid
CHILDPID=/run/$NAME.child.pid CHILDPID=/run/$NAME.child.pid
status() { status() {
if [ -f $CHILDPID ] && kill -0 "$(cat $CHILDPID)" 2>/dev/null; then if [ -f $CHILDPID ] && kill -0 "$(cat $CHILDPID)" 2>/dev/null; then
echo "$NAME running (pid $(cat $CHILDPID))" echo "$NAME running (pid $(cat $CHILDPID))"; return 0
return 0
fi fi
echo "$NAME not running" echo "$NAME not running"; return 3
return 3
} }
start() { start() {
if status >/dev/null 2>&1; then echo "$NAME already running"; return 0; fi if status >/dev/null 2>&1; then echo "$NAME already running"; return 0; fi
# the database has to answer before resources start querying it
service mariadb start >/dev/null 2>&1 service mariadb start >/dev/null 2>&1
i=0 i=0
while ! mysqladmin ping >/dev/null 2>&1; do while ! mysqladmin ping >/dev/null 2>&1; do
i=$((i+1)) i=$((i+1)); [ $i -gt 30 ] && echo "warning: mariadb did not come up" && break
[ $i -gt 30 ] && echo "warning: mariadb did not come up" && break
sleep 1 sleep 1
done done
setsid $DIR/bin/run-server.sh $NAME $DIR $LOG >/dev/null 2>&1 < /dev/null &
cd $DATA || exit 1
setsid /opt/fivem/bin/supervise.sh "$NAME" "$LOG" \
$SERVER/run.sh +set citizen_dir $SERVER/alpine/opt/cfx-server/citizen/ \
+exec $DATA/server.cfg >/dev/null 2>&1 < /dev/null &
sleep 2 sleep 2
echo "$NAME started" echo "$NAME started"
} }
+5 -4
View File
@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
# Send a command to the running FXServer console, e.g. rcon refresh # Send a command to the running FXServer console, e.g. rcon status
if [ ! -p /run/fivem.stdin ]; then FIFO=${FIFO:-/run/fiverp.stdin}
echo "server is not running (no console pipe)" >&2 if [ ! -p "$FIFO" ]; then
echo "server is not running (no console pipe at $FIFO)" >&2
exit 1 exit 1
fi fi
printf '%s\n' "$*" > /run/fivem.stdin printf '%s\n' "$*" > "$FIFO"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# run-server.sh <name> <dir> <logfile>
#
# Keeps FXServer alive and gives it a console you can talk to.
#
# * FXServer reads its console from stdin and quits the moment stdin hits
# EOF, so it gets a FIFO whose write end this script holds open. That FIFO
# doubles as the command channel used by bin/rcon.
# * A server that dies instantly is usually failing for a reason that will
# not fix itself (bad licence key, database down). The restart delay backs
# off to 5 minutes so a broken server does not hammer Cfx, and resets once
# it has stayed up for a while.
# ---------------------------------------------------------------------------
NAME="$1"; DIR="$2"; LOG="$3"
FIFO="/run/$NAME.stdin"
MIN=5; MAX=300; HEALTHY=120
mkdir -p "$(dirname "$LOG")"
echo $$ > "/run/$NAME.sup.pid"
[ -p "$FIFO" ] || { rm -f "$FIFO"; mkfifo "$FIFO"; }
exec 9<> "$FIFO"
cleanup() { kill "$CHILD" 2>/dev/null; rm -f "/run/$NAME.sup.pid" "/run/$NAME.child.pid"; exit 0; }
trap cleanup TERM INT
delay=$MIN
while true; do
if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG")" -gt 209715200 ]; then mv "$LOG" "$LOG.1"; fi
echo "=== $(date -Is) starting $NAME ===" >> "$LOG"
started=$(date +%s)
( cd "$DIR/server-data" && exec "$DIR/server/run.sh" +exec server.cfg ) >> "$LOG" 2>&1 <&9 &
CHILD=$!
echo $CHILD > "/run/$NAME.child.pid"
wait $CHILD
RC=$?
ran=$(( $(date +%s) - started ))
if [ "$ran" -ge "$HEALTHY" ]; then delay=$MIN; else delay=$(( delay * 2 )); [ "$delay" -gt "$MAX" ] && delay=$MAX; fi
echo "=== $(date -Is) $NAME exited (rc=$RC) after ${ran}s, restarting in ${delay}s ===" >> "$LOG"
sleep "$delay"
done
-66
View File
@@ -1,66 +0,0 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# supervise.sh <name> <logfile> <command...>
#
# Keeps one process alive. There is no systemd in this container, so this is
# what restarts FXServer if it crashes.
#
# Two details that matter for FXServer specifically:
#
# * It reads its console from stdin and quits the moment stdin reaches EOF,
# so it is given a FIFO whose write end this script holds open forever.
# That doubles as a command channel - see /opt/fivem/bin/rcon.
#
# * A server that dies immediately is usually failing for a reason that will
# not fix itself (bad licence key, database down, Cfx rate limiting). The
# restart delay backs off so a broken server does not hammer an upstream
# service, and resets once it has stayed up for a while.
# ---------------------------------------------------------------------------
NAME="$1"; LOG="$2"; shift 2
FIFO="${FIFO:-/run/$NAME.stdin}"
MIN_DELAY=5
MAX_DELAY=300
HEALTHY_AFTER=120 # seconds of uptime that count as "it actually started"
mkdir -p "$(dirname "$LOG")"
echo $$ > "/run/$NAME.sup.pid"
[ -p "$FIFO" ] || { rm -f "$FIFO"; mkfifo "$FIFO"; }
# read-write open: holds the pipe open without blocking and without a helper process
exec 9<> "$FIFO"
cleanup() {
kill "$CHILD" 2>/dev/null
rm -f "/run/$NAME.sup.pid" "/run/$NAME.child.pid"
exit 0
}
trap cleanup TERM INT
delay=$MIN_DELAY
while true; do
if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG")" -gt 209715200 ]; then
mv "$LOG" "$LOG.1"
fi
echo "=== $(date -Is) starting $NAME ===" >> "$LOG"
started=$(date +%s)
"$@" >> "$LOG" 2>&1 <&9 &
CHILD=$!
echo $CHILD > "/run/$NAME.child.pid"
wait $CHILD
RC=$?
ran=$(( $(date +%s) - started ))
if [ "$ran" -ge "$HEALTHY_AFTER" ]; then
delay=$MIN_DELAY
else
delay=$(( delay * 2 ))
[ "$delay" -gt "$MAX_DELAY" ] && delay=$MAX_DELAY
fi
echo "=== $(date -Is) $NAME exited (rc=$RC) after ${ran}s, restarting in ${delay}s ===" >> "$LOG"
sleep "$delay"
done
+303
View File
@@ -0,0 +1,303 @@
# FiveRP — Server Design System
Единый стиль для **всего** сервера: loading screen, авторизация, будущие меню
(инвентарь, телефон, банк, HUD). Основан на белой «стеклянной» теме Filmorum
(`fiveM_design.md`), адаптирован под CEF/NUI FiveM.
> Правило номер один: **ни один компонент не хардкодит цвет, радиус или шрифт.**
> Только `var(--…)` из блока токенов ниже. Смена темы = одна строка.
---
## 0. Шрифт — Unbounded
**Unbounded — основной шрифт всего сервера.** Геометрический дисплейный гротеск,
переменная ось веса 300–800. Никаких других сансов в интерфейсе.
Файлы лежат в каждом ресурсе локально (`html/fonts/`), а НЕ грузятся с
`fonts.googleapis.com`: у части игроков CEF стартует раньше сети, и веб-шрифт
успевает не приехать — интерфейс мигает системным шрифтом. Локальный woff2
рисуется с первого кадра.
```css
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800; /* вариативный: один файл на все веса */
font-display: block; /* block, не swap — подмены шрифта быть не должно */
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* + unbounded-latin-ext.woff2 и unbounded-cyrillic.woff2 теми же блоками */
```
```css
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
'Liberation Mono', 'DejaVu Sans Mono', monospace;
```
**Как набирать Unbounded.** Шрифт широкий и характерный — он требует другой
типографики, чем SF Pro:
| Роль | Размер | Вес | Трекинг |
|---|---|---|---|
| Display (лого, экран загрузки) | 44–72px | 800 | `-0.04em` |
| H1 (заголовок панели) | 26–30px | 700 | `-0.035em` |
| H2 (секция) | 1820px | 600 | `-0.03em` |
| Кнопка / линк | 14px | 600 | `-0.01em` |
| Body / инпут | 1415px | 400 | `-0.005em` |
| Лейбл поля | 11px | 600 | `+0.09em`, `uppercase` |
| Eyebrow / надзаголовок | 10px | 600 | `+0.20em`, `uppercase` |
| Метаданные | 1112px | 400 | `0` |
Правила:
- **Крупный кегль = минусовой трекинг.** У Unbounded широкие апроши; без
`letter-spacing: -0.03em` заголовок расползается.
- **Мелкий кегль = плюсовой трекинг + uppercase.** Ниже 12px строчные буквы
Unbounded слипаются — все подписи только капслоком с разрядкой.
- **Веса 300 и 800 — крайности**, в интерфейсе живут 400/500/600/700.
- **Не набирать Unbounded длинный текст.** Абзац больше двух строк — читать
тяжело. Для многострочных описаний кегль 13px/1.65 и вес 400, не больше.
- **Цифры документов, ID, MRZ — только `--mono`.** Unbounded для них слишком
«дизайнерский», а нужен вид машинного считывания.
---
## 1. Философия
- **Air, light, frosted** — полупрозрачные стеклянные панели поверх живой игры.
- **Кислород** — между карточками 12px, между секциями 40–48px.
- **Один акцент** — синий `#0071e3`, дозированно: кнопка, ссылка, фокус.
- **Сдержанное движение** — fade и подъём на 4px. Анимации ради анимации нет.
- **Один герой на экран** — крупный объект (паспорт, лого), остальное тихое.
---
## 2. Токены
```css
:root {
--bg: #f5f5f7;
--bg-deep: #e8e8ed;
--surface: rgba(255, 255, 255, 0.72);
--surface-solid:#ffffff;
--panel: rgba(255, 255, 255, 0.80);
--header-bg: rgba(255, 255, 255, 0.70);
--field-bg: rgba(0, 0, 0, 0.04);
--field-bg-focus:#ffffff;
--fg: #1d1d1f;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong:#0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--ring: rgba(0, 113, 227, 0.18);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--hover: rgba(0, 0, 0, 0.04);
--success: #34c759;
--danger: #ff3b30;
--radius-lg: 1.5rem; /* 24px — крупные панели */
--radius-md: 1rem; /* 16px — карточки, инпуты */
--radius-sm: 0.7rem; /* 11px — мелочь */
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
color-scheme: light;
}
```
Тёмная и бежевая темы — через `data-theme` на `html`, переопределением тех же
токенов (`--bg`, `--surface`, `--fg`, `--accent`, `--success`, `--danger`).
---
## 3. КРИТИЧНО: особенности CEF во FiveM
Это не «хорошо бы», а условия, при нарушении которых интерфейс ломается
в игре, оставаясь идеальным в браузере.
1. **`backdrop-filter` и `box-shadow` не живут на одном элементе.**
Композитор CEF рисует тень такого элемента непрозрачным прямоугольником —
в игре под панелью появляется чёрный квадрат. Если нужны и блюр, и тень —
тень вешать на элемент-обёртку, а блюр на внутренний. По умолчанию
**у стеклянных панелей тени нет вообще**: их отделяет от игры блюр и
светлая граница, этого достаточно.
2. **`html, body { background: transparent }`** — иначе NUI закрашивает игру.
3. **Никаких затемняющих подложек.** Экран за панелью — это игра, её видно
и она красивая. Контраст добирается блюром и белой заливкой стекла, а не
`rgba(0,0,0,…)` поверх кадра. Допустим только **осветляющий** вуаль:
`radial-gradient(…, rgba(255,255,255,0.28), transparent)`.
4. **Шрифты — локальные woff2.** См. раздел 0.
5. **`prefers-reduced-motion`** — обязательный блок с самого начала.
6. **Курсор и фокус**`SetNuiFocus(true, true)` только когда панель открыта,
и обязательно `SetNuiFocus(false, false)` при закрытии, иначе игрок
остаётся без управления.
---
## 4. Стеклянная панель
```css
.glass {
background: var(--surface);
backdrop-filter: saturate(180%) blur(24px);
-webkit-backdrop-filter: saturate(180%) blur(24px);
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: var(--radius-lg);
/* без box-shadow — см. раздел 3.1 */
}
```
Тень разрешена только элементам **без** `backdrop-filter` (карточки на
непрозрачном фоне, экран загрузки):
```css
.shadow-soft { box-shadow: 0 1px 2px rgba(0,0,0,.03), 0 12px 40px -12px rgba(0,0,0,.12); }
.shadow-lift { box-shadow: 0 2px 4px rgba(0,0,0,.04), 0 24px 50px -16px rgba(0,0,0,.20); }
```
Ховер кликабельной карточки: `transform: translateY(-4px)`, 0.4s `--ease`.
---
## 5. Кнопки
```css
.btn-primary {
border-radius: 999px;
padding: 12px 24px;
font: 600 14px var(--sans);
letter-spacing: -0.01em;
color: #fff;
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
box-shadow: 0 1px 1px rgba(0,0,0,.10), inset 0 1px 0 rgba(255,255,255,.20);
}
.btn-primary:hover { filter: brightness(1.08); }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: .5; pointer-events: none; }
.btn-ghost {
border-radius: 999px;
padding: 10px 20px;
font: 600 14px var(--sans);
color: var(--accent);
background: var(--accent-soft);
}
.btn-ghost:hover { background: rgba(0,113,227,.14); }
```
Ровно два стиля кнопок на весь сервер. Третий не заводить.
---
## 6. Поля ввода
```css
.field {
width: 100%;
border-radius: var(--radius-md);
padding: 13px 14px;
font: 400 15px var(--sans);
letter-spacing: -0.005em;
color: var(--fg);
background: var(--field-bg);
border: 1px solid transparent;
outline: none;
transition: background-color .2s, border-color .2s, box-shadow .2s;
}
.field::placeholder { color: var(--fg-tertiary); }
.field:focus {
background: var(--field-bg-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--ring); /* можно: у инпута нет backdrop-filter */
}
.field.is-bad { border-color: var(--danger); box-shadow: 0 0 0 4px rgba(255,59,48,.14); }
```
Лейбл над полем — 11px/600/`+0.09em`/uppercase/`--fg-secondary`.
---
## 7. Компоновка
- Панель по центру экрана, ширина `min(940px, 92vw)`, радиус `--radius-lg`.
- Внутренние отступы панели 32–40px.
- Между полями формы 16px, между группами 24px.
- Двухколоночная схема «форма + герой»: `grid-template-columns: minmax(0,1fr) 420px`.
- Сворачивание в одну колонку — анимацией `grid-template-columns` и `width`,
620ms `--ease`.
---
## 8. Motion
```css
.fade-up { animation: fadeUp .6s cubic-bezier(.22,1,.36,1) both; }
.delay-1 { animation-delay: .06s } .delay-2 { animation-delay: .12s }
.delay-3 { animation-delay: .18s } .delay-4 { animation-delay: .24s }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
transition-duration: .001ms !important;
}
}
```
---
## 9. Фокус и доступность
```css
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
::selection { background: var(--accent-soft); color: var(--accent-strong); }
```
Контраст текста к стеклу — не ниже 4.5:1. `--fg-tertiary` только для
плейсхолдеров и декоративных подписей, никогда для смысловых.
---
## 10. Существующие интерфейсы
| Ресурс | Что это | Файлы |
|---|---|---|
| `fiverp-loadscreen` | экран загрузки сервера | `html/index.html`, `style.css`, `app.js` |
| `fiverp-auth` | регистрация и вход, паспорт США | `html/index.html`, `style.css`, `app.js` |
`fiverp-auth` использует один «герой» — разворот паспорта США (страница с
данными: фото, поля, MRZ). Паспорт — единственный насыщенный цветом объект
на экране; форма рядом с ним остаётся полностью нейтральной.
---
## 11. Чек-лист нового интерфейса
1. Скопировать `html/fonts/` и блок `:root` из `fiverp-auth`.
2. `html, body { background: transparent }`.
3. Панель — `.glass`, **без** `box-shadow`.
4. Ни одного затемняющего слоя поверх игры.
5. Шрифт Unbounded, трекинг по таблице из раздела 0.
6. Акцент только `--accent`; кнопки только `.btn-primary` / `.btn-ghost`.
7. Подключить `prefers-reduced-motion`.
8. `SetNuiFocus(false, false)` на закрытии — проверить руками.
9. Отрендерить в headless Chrome и **посмотреть глазами** до выката.
-99
View File
@@ -1,99 +0,0 @@
# Los Santos RP — server notes
Everything here was written for this server. No ESX, no QBCore, no downloaded
resources: `cfx-server-data` is not used at all, and the bundled `chat` and
`monitor` resources are not started.
## Layout
/opt/fivem/server FXServer artefacts (build 25770, the recommended one)
/opt/fivem/data server-data root; run FXServer with this as cwd
server.cfg main config
secrets.cfg licence key + DB credentials, mode 600
resources/[rp]/ our resources
/opt/fivem/bin supervise.sh (keep-alive), rcon (console pipe)
/opt/fivem/logs server.log, rotated at 200MB
/opt/fivem/etc/schema.sql the database schema
## Resources
| resource | what it is |
|---------------|------------|
| `rp_db` | MariaDB client speaking the MySQL wire protocol directly over a TCP socket — handshake, `mysql_native_password`, text result sets, connection pool, transactions. No npm packages. Lua front end in `lib/db.lua`. |
| `rp_core` | Config, shared validation, appearance model, scrypt password hashing, the player registry, and the client/server request bridge. |
| `rp_session` | Connection gate (bans, capacity), accounts, characters, spawn. Every decision is made here; the client can only ask. |
| `rp_loading` | Loading screen: procedural night-city flyover on canvas, progress bound to the game's real load events, ambience synthesised with the Web Audio API. |
| `rp_ui` | Sign in / register, character creation with a live ped preview, spawn picker. Scripted cameras live in `client/camera.lua`. |
| `rp_selftest` | Checks the account/character data path on every boot and proves it survived the restart. |
| `rp_dbtest` | Driver test suite. Not started by default; `ensure rp_dbtest` to run it. |
## The look
The interface is the paperwork side of a life in Los Santos: every screen is a
sheet from the city's Office of Vital Records, lying on a dark desk while the
game runs on behind it. Paper is the only surface we draw; the game is the room
the desk is in. That is why the NUI body stays transparent and the sheets are
opaque - an opaque sheet stays legible over a bright daylight city, where a dark
glass panel would not.
`html/tokens.css` is shared verbatim by `rp_ui` and `rp_loading` and holds the
whole system. Three typographic voices, and the rule between them is literal:
| voice | face | what it is |
|-------|------|------------|
| preprinted | Archivo, expanded, caps | what the form was printed with: headings, field captions, buttons |
| typed | IBM Plex Mono | what somebody entered on it: values, serials, names, user input |
| prose | IBM Plex Sans | the plain-English notes in the margin: blurbs and hints |
Three inks, each with exactly one job and never used decoratively: `--canary`
(municipal form yellow) is what is selected right now, `--stamp` (oxblood) is
filed / refused / destroyed, `--verdi` (municipal teal) is checked and valid.
The one loud thing is the rubber stamp, and it only ever marks a real change of
state: the file opened, the application approved, the placement issued. It is
masked with SVG turbulence so the ink lands unevenly, and the paper on the desk
takes the hit with it.
Fonts are bundled, never fetched: a NUI page has no guaranteed internet. Any
glyph used in `content:` must exist in the shipped latin subset, which is why
the validation tick is drawn from borders rather than U+2713.
Screens can be looked at without launching the game: `python3 /opt/preview/build.py`
stubs the NUI bridge, pushes each screen into a representative state, and writes
standalone pages to `/opt/preview/out` for headless Chrome to shoot.
## Operating it
service fivem start|stop|restart|status
rcon <command> # e.g. rcon refresh, rcon status
tail -f /opt/fivem/logs/server.log
`supervise.sh` restarts FXServer if it dies, backing off from 5s up to 5min so
a server that cannot start (bad key, database down) does not hammer Cfx. It
also holds a FIFO open on the server's stdin — FXServer quits the moment stdin
reaches EOF, and that FIFO is what `rcon` writes to.
## Starting on boot
There is no systemd in this container. The platform runs a login shell as PID 1,
which sources `/etc/profile.d/redl-autostart.sh`, which starts everything marked
in `/etc/redl/enabled`. Both `mariadb` and `fivem` are marked, and `/etc/init.d/fivem`
waits for MariaDB to answer before starting the server.
## Database
MariaDB, database `rp`, application user `rp@127.0.0.1` with only
SELECT/INSERT/UPDATE/DELETE. Passwords are scrypt with a per-password salt and
the cost parameters stored in the hash. Tables: `accounts`, `auth_attempts`,
`characters`, `transactions`, `inventory`, `vehicles`.
Every query goes through `DB.Query`/`DB.Insert`/... with `?` parameters.
`CLIENT_MULTI_STATEMENTS` is deliberately not negotiated, so even a failure of
the escaper could not turn a parameter into a second statement.
## Not yet built
The pre-spawn experience and the framework underneath it are done. Gameplay on
top of it — chat, HUD, inventory, jobs, vehicles, policing, medical — is not.
`characters`, `transactions`, `inventory` and `vehicles` already have their
tables and the money API in `rp_core/server/player.lua` is journalled and ready.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

-105
View File
@@ -1,105 +0,0 @@
-- ---------------------------------------------------------------------------
-- Los Santos RP - authoritative schema
-- Everything the server considers truth lives here. Nothing gameplay-relevant
-- is stored client-side.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS accounts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(24) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- scrypt$N$r$p$salt_b64$hash_b64
role ENUM('player','mod','admin','owner') NOT NULL DEFAULT 'player',
license VARCHAR(64) NULL, -- Cfx license: bound on first login
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL,
last_ip VARCHAR(45) NULL,
banned TINYINT(1) NOT NULL DEFAULT 0,
ban_reason VARCHAR(255) NULL,
ban_until DATETIME NULL, -- NULL + banned=1 => permanent
PRIMARY KEY (id),
UNIQUE KEY uq_accounts_username (username),
KEY idx_accounts_license (license)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Brute-force throttling. One row per (identity, window); pruned by the server.
CREATE TABLE IF NOT EXISTS auth_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
identity VARCHAR(64) NOT NULL, -- license or username, lowercased
ip VARCHAR(45) NULL,
success TINYINT(1) NOT NULL DEFAULT 0,
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_attempts_identity_at (identity, at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS characters (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id INT UNSIGNED NOT NULL,
slot TINYINT UNSIGNED NOT NULL DEFAULT 0,
first_name VARCHAR(24) NOT NULL,
last_name VARCHAR(24) NOT NULL,
dob DATE NOT NULL,
gender ENUM('m','f') NOT NULL DEFAULT 'm',
backstory TEXT NULL,
appearance MEDIUMTEXT NULL, -- JSON: model, face blend, features, overlays, clothing
position VARCHAR(128) NULL, -- JSON: {x,y,z,h}
cash BIGINT NOT NULL DEFAULT 500,
bank BIGINT NOT NULL DEFAULT 2500,
health SMALLINT NOT NULL DEFAULT 200,
armour SMALLINT NOT NULL DEFAULT 0,
job VARCHAR(32) NOT NULL DEFAULT 'unemployed',
job_grade TINYINT UNSIGNED NOT NULL DEFAULT 0,
needs VARCHAR(128) NULL, -- JSON: {hunger,thirst,stress}
playtime INT UNSIGNED NOT NULL DEFAULT 0, -- seconds
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_played_at DATETIME NULL,
deleted TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uq_char_name (first_name, last_name),
KEY idx_char_account (account_id, deleted),
CONSTRAINT fk_char_account FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Every money movement is journalled; cash/bank on `characters` is the running total.
CREATE TABLE IF NOT EXISTS transactions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
character_id INT UNSIGNED NOT NULL,
account_kind ENUM('cash','bank') NOT NULL,
delta BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
reason VARCHAR(64) NOT NULL,
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_tx_character (character_id, at),
CONSTRAINT fk_tx_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS inventory (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
owner_kind ENUM('character','stash','ground','vehicle') NOT NULL,
owner_id VARCHAR(64) NOT NULL, -- character id, stash key, drop uuid, plate
slot SMALLINT UNSIGNED NOT NULL,
item VARCHAR(48) NOT NULL,
qty INT UNSIGNED NOT NULL,
meta VARCHAR(512) NULL, -- JSON: durability, serial, ...
PRIMARY KEY (id),
UNIQUE KEY uq_inv_slot (owner_kind, owner_id, slot),
KEY idx_inv_owner (owner_kind, owner_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS vehicles (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
character_id INT UNSIGNED NOT NULL,
plate VARCHAR(12) NOT NULL,
model VARCHAR(48) NOT NULL,
props MEDIUMTEXT NULL, -- JSON: colours, mods, damage
fuel FLOAT NOT NULL DEFAULT 100,
garage VARCHAR(32) NOT NULL DEFAULT 'legion',
stored TINYINT(1) NOT NULL DEFAULT 1,
impounded TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_veh_plate (plate),
KEY idx_veh_character (character_id),
CONSTRAINT fk_veh_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-12
View File
@@ -1,12 +0,0 @@
# Copy to /opt/fivem/data/secrets.cfg and chmod 600.
# This file is exec'd by server.cfg and must never be committed anywhere.
# Free key from https://keymaster.fivem.net
sv_licenseKey "cfxk_your_key_here"
# Database the rp_db driver connects to.
set rp_db_host "127.0.0.1"
set rp_db_port "3306"
set rp_db_user "rp"
set rp_db_pass "your_database_password"
set rp_db_name "rp"
+101 -96
View File
@@ -1,38 +1,40 @@
#!/bin/sh #!/bin/sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Los Santos RP - installer # FiveRP - installer
# #
# Puts a working server on a clean Ubuntu/Debian box: FXServer artefacts, # Turns a clean Ubuntu/Debian box into this server: FXServer artefacts,
# MariaDB with the schema, the resources from this repository, the keep-alive # cfx-server-data, oxmysql, MariaDB with the schema, the FiveRP resources,
# supervisor and a boot entry. # a keep-alive supervisor and a boot entry.
# #
# sudo sh install.sh --licence cfxk_xxxxxxxx # sudo sh install.sh --licence cfxk_xxxxxxxx
# #
# Options # Options
# --licence KEY Cfx.re server key from https://keymaster.fivem.net # --licence KEY Cfx.re server key from https://keymaster.fivem.net
# --dir PATH install root (default /opt/fivem) # --dir PATH install root (default /opt/fivem)
# --hostname NAME server name shown in the list (default from server.cfg.example) # --hostname NAME server name in the list (default FiveRP)
# --port N game port (default 30120) # --port N game port (default 30120)
# --name NAME service name, one per server (default fivem) # --name NAME service name, one per server (default fiverp)
# --db-name NAME database (default rp) # --db-name NAME database (default fiverp)
# --db-user USER database user (default rp) # --db-user USER database user (default fiverp)
# --db-pass PASS password for that user (default: generated) # --db-pass PASS password for that user (default: generated)
# --build ID FXServer build to use (default: latest recommended) # --build ID FXServer build (default: latest recommended)
# --no-start install everything, do not start the server # --no-start install everything, do not start the server
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set -e set -e
DIR=/opt/fivem DIR=/opt/fivem
LICENCE="" LICENCE=""
HOSTNAME_="" HOSTNAME_="FiveRP"
BUILD="" BUILD=""
DB_NAME=fiverp
DB_USER=fiverp
DB_PASS="" DB_PASS=""
DB_NAME=rp
DB_USER=rp
PORT=30120 PORT=30120
SVC=fivem SVC=fiverp
START=1 START=1
ARTIFACTS="https://runtime.fivem.net/artifacts/fivem/build_proot_linux/master/" ARTIFACTS="https://runtime.fivem.net/artifacts/fivem/build_proot_linux/master/"
OXMYSQL="https://github.com/overextended/oxmysql/releases/latest/download/oxmysql.zip"
SERVER_DATA_REPO="https://github.com/citizenfx/cfx-server-data"
SRC=$(cd "$(dirname "$0")" && pwd) SRC=$(cd "$(dirname "$0")" && pwd)
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@@ -41,33 +43,33 @@ while [ $# -gt 0 ]; do
--dir) DIR="$2"; shift 2 ;; --dir) DIR="$2"; shift 2 ;;
--hostname) HOSTNAME_="$2"; shift 2 ;; --hostname) HOSTNAME_="$2"; shift 2 ;;
--build) BUILD="$2"; shift 2 ;; --build) BUILD="$2"; shift 2 ;;
--db-pass) DB_PASS="$2"; shift 2 ;;
--db-name) DB_NAME="$2"; shift 2 ;; --db-name) DB_NAME="$2"; shift 2 ;;
--db-user) DB_USER="$2"; shift 2 ;; --db-user) DB_USER="$2"; shift 2 ;;
--db-pass) DB_PASS="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;;
--name) SVC="$2"; shift 2 ;; --name) SVC="$2"; shift 2 ;;
--no-start) START=0; shift ;; --no-start) START=0; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;; -h|--help) sed -n '2,25p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;; *) echo "unknown option: $1" >&2; exit 1 ;;
esac esac
done done
say() { printf '\n\033[1;33m==\033[0m %s\n' "$*"; } say() { printf '\n\033[1;34m==\033[0m %s\n' "$*"; }
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; } ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
die() { printf '\n\033[31mfailed:\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\n\033[31mfailed:\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root (sudo sh install.sh ...)" [ "$(id -u)" = 0 ] || die "run as root (sudo sh install.sh ...)"
[ -f "$SRC/etc/schema.sql" ] || die "run this from a clone of the repository" [ -f "$SRC/sql/schema.sql" ] || die "run this from a clone of the repository"
command -v apt-get >/dev/null || die "this installer is for Debian/Ubuntu" command -v apt-get >/dev/null || die "this installer is for Debian/Ubuntu"
# --- 1. packages ----------------------------------------------------------- # --- 1. packages -----------------------------------------------------------
say "Installing packages" say "Installing packages"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y -qq curl ca-certificates xz-utils mariadb-server >/dev/null apt-get install -y -qq curl ca-certificates wget xz-utils git unzip mariadb-server >/dev/null
ok "curl, xz-utils, mariadb-server" ok "curl, git, unzip, xz-utils, mariadb-server"
# --- 2. FXServer artefacts ------------------------------------------------- # --- 2. FXServer -----------------------------------------------------------
say "Fetching FXServer" say "Fetching FXServer"
if [ -n "$BUILD" ]; then if [ -n "$BUILD" ]; then
REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -oE "\./$BUILD-[0-9a-f]+/fx\.tar\.xz" | head -1) REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -oE "\./$BUILD-[0-9a-f]+/fx\.tar\.xz" | head -1)
@@ -80,8 +82,8 @@ fi
URL="$ARTIFACTS${REL#./}" URL="$ARTIFACTS${REL#./}"
BUILD_ID=$(echo "$REL" | grep -oE '[0-9]+' | head -1) BUILD_ID=$(echo "$REL" | grep -oE '[0-9]+' | head -1)
mkdir -p "$DIR/server" "$DIR/data/resources" "$DIR/bin" "$DIR/logs" "$DIR/etc" mkdir -p "$DIR/server" "$DIR/bin" "$DIR/logs"
if [ -x "$DIR/server/run.sh" ] && [ -f "$DIR/server/.build" ] && [ "$(cat "$DIR/server/.build")" = "$BUILD_ID" ]; then if [ -x "$DIR/server/run.sh" ] && [ "$(cat "$DIR/server/.build" 2>/dev/null)" = "$BUILD_ID" ]; then
ok "build $BUILD_ID already installed" ok "build $BUILD_ID already installed"
else else
TMP=$(mktemp -d) TMP=$(mktemp -d)
@@ -94,11 +96,31 @@ else
ok "build $BUILD_ID unpacked into $DIR/server" ok "build $BUILD_ID unpacked into $DIR/server"
fi fi
# --- 3. database ----------------------------------------------------------- # --- 3. server-data + oxmysql ---------------------------------------------
say "Preparing the database" say "Fetching the base resources"
if [ ! -f /etc/init.d/mariadb ] && [ ! -d /run/systemd/system ]; then if [ -d "$DIR/server-data/resources" ]; then
die "no init system found for mariadb" ok "server-data already present"
else
git clone --depth=1 -q "$SERVER_DATA_REPO" "$DIR/server-data" || die "could not clone cfx-server-data"
ok "cfx-server-data cloned (mapmanager, chat, spawnmanager, sessionmanager, hardcap)"
fi fi
if [ -f "$DIR/server-data/resources/[system]/oxmysql/fxmanifest.lua" ]; then
ok "oxmysql already present"
else
TMP=$(mktemp -d)
curl -fsSL "$OXMYSQL" -o "$TMP/oxmysql.zip" || die "could not download oxmysql"
mkdir -p "$DIR/server-data/resources/[system]"
unzip -q -o "$TMP/oxmysql.zip" -d "$TMP"
rm -rf "$DIR/server-data/resources/[system]/oxmysql"
mv "$(find "$TMP" -maxdepth 2 -name fxmanifest.lua | head -1 | xargs dirname)" \
"$DIR/server-data/resources/[system]/oxmysql"
rm -rf "$TMP"
ok "oxmysql installed"
fi
# --- 4. database -----------------------------------------------------------
say "Preparing the database"
if [ -d /run/systemd/system ]; then if [ -d /run/systemd/system ]; then
systemctl enable --now mariadb >/dev/null 2>&1 || true systemctl enable --now mariadb >/dev/null 2>&1 || true
else else
@@ -111,75 +133,63 @@ while ! mysqladmin ping >/dev/null 2>&1; do
done done
if [ -z "$DB_PASS" ]; then if [ -z "$DB_PASS" ]; then
if [ -f "$DIR/data/secrets.cfg" ]; then if [ -f "$DIR/server-data/server.cfg" ]; then
DB_PASS=$(sed -n 's/^set rp_db_pass "\(.*\)"$/\1/p' "$DIR/data/secrets.cfg" | head -1) DB_PASS=$(sed -n "s|^set mysql_connection_string \"mysql://[^:]*:\([^@]*\)@.*|\1|p" "$DIR/server-data/server.cfg" | head -1)
fi fi
[ -n "$DB_PASS" ] || DB_PASS=$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | cut -c1-24) [ -n "$DB_PASS" ] || DB_PASS=$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | cut -c1-24)
fi fi
mysql -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -e "CREATE USER IF NOT EXISTS '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';" mysql -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
mysql -e "ALTER USER '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';" mysql -e "ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
mysql -e "GRANT SELECT, INSERT, UPDATE, DELETE ON \`$DB_NAME\`.* TO '$DB_USER'@'127.0.0.1'; FLUSH PRIVILEGES;" mysql -e "GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES;"
mysql "$DB_NAME" < "$SRC/etc/schema.sql" mysql "$DB_NAME" < "$SRC/sql/schema.sql"
ok "database $DB_NAME, user $DB_USER@127.0.0.1, schema applied" ok "database $DB_NAME, user $DB_USER@localhost, schema applied"
# --- 4. resources and configuration --------------------------------------- # --- 5. FiveRP resources and configuration --------------------------------
say "Installing the server files" say "Installing FiveRP"
rm -rf "$DIR/data/resources/[rp]" mkdir -p "$DIR/server-data/resources/[local]"
mkdir -p "$DIR/data/resources" for r in fiverp-auth fiverp-characters fiverp-loadscreen; do
cp -r "$SRC/resources/[rp]" "$DIR/data/resources/" rm -rf "$DIR/server-data/resources/[local]/$r"
cp "$SRC/bin/supervise.sh" "$DIR/bin/" cp -r "$SRC/resources/[local]/$r" "$DIR/server-data/resources/[local]/"
sed "s|/run/fivem.stdin|/run/$SVC.stdin|" "$SRC/bin/rcon" > "$DIR/bin/rcon" done
chmod +x "$DIR/bin/supervise.sh" "$DIR/bin/rcon" ok "fiverp-auth, fiverp-characters, fiverp-loadscreen"
cp "$SRC/etc/schema.sql" "$DIR/etc/schema.sql"
ln -sf "$DIR/bin/rcon" "/usr/local/bin/$([ "$SVC" = fivem ] && echo rcon || echo "rcon-$SVC")"
if [ -f "$DIR/data/server.cfg" ]; then cp "$SRC/bin/run-server.sh" "$DIR/bin/"
ok "server.cfg kept (already present)" sed "s|/run/fiverp.stdin|/run/$SVC.stdin|" "$SRC/bin/rcon" > "$DIR/bin/rcon"
else chmod +x "$DIR/bin/run-server.sh" "$DIR/bin/rcon"
sed -e "s|exec /opt/fivem/data/secrets.cfg|exec $DIR/data/secrets.cfg|" \ ln -sf "$DIR/bin/rcon" "/usr/local/bin/$([ "$SVC" = fiverp ] && echo rcon || echo "rcon-$SVC")"
-e "s|0.0.0.0:30120|0.0.0.0:$PORT|g" \
"$SRC/server.cfg.example" > "$DIR/data/server.cfg" CFG="$DIR/server-data/server.cfg"
[ -n "$HOSTNAME_" ] && sed -i "s|^sv_hostname .*|sv_hostname \"$HOSTNAME_\"|;s|^sets sv_projectName .*|sets sv_projectName \"$HOSTNAME_\"|" "$DIR/data/server.cfg" if [ -z "$LICENCE" ] && [ -f "$CFG" ]; then
ok "server.cfg written" LICENCE=$(sed -n 's/^sv_licenseKey "\(.*\)"$/\1/p' "$CFG" | head -1)
fi fi
sed -e "s|0.0.0.0:30120|0.0.0.0:$PORT|g" \
-e "s|^sv_hostname .*|sv_hostname \"$HOSTNAME_\"|" \
-e "s|^sets sv_projectName .*|sets sv_projectName \"$HOSTNAME_\"|" \
-e "s|^sv_licenseKey .*|sv_licenseKey \"${LICENCE:-cfxk_your_key_here}\"|" \
-e "s|^set mysql_connection_string .*|set mysql_connection_string \"mysql://$DB_USER:$DB_PASS@localhost/$DB_NAME?charset=utf8mb4\"|" \
"$SRC/server.cfg.example" > "$CFG"
chmod 600 "$CFG"
ok "server.cfg written (mode 600 - it holds the key and the password)"
if [ -z "$LICENCE" ] && [ -f "$DIR/data/secrets.cfg" ]; then # --- 6. boot entry ---------------------------------------------------------
LICENCE=$(sed -n 's/^sv_licenseKey "\(.*\)"$/\1/p' "$DIR/data/secrets.cfg" | head -1)
fi
cat > "$DIR/data/secrets.cfg" <<EOF
# Licence key and database credentials. Never commit this file.
sv_licenseKey "${LICENCE:-cfxk_your_key_here}"
set rp_db_host "127.0.0.1"
set rp_db_port "3306"
set rp_db_user "$DB_USER"
set rp_db_pass "$DB_PASS"
set rp_db_name "$DB_NAME"
EOF
chmod 600 "$DIR/data/secrets.cfg"
ok "secrets.cfg written (mode 600)"
# --- 5. boot entry ---------------------------------------------------------
say "Setting up start on boot" say "Setting up start on boot"
sed -e "s|^DATA=.*|DATA=$DIR/data|" -e "s|^SERVER=.*|SERVER=$DIR/server|" \ sed -e "s|^NAME=.*|NAME=$SVC|" -e "s|^DIR=.*|DIR=$DIR|" -e "s|^LOG=.*|LOG=$DIR/logs/server.log|" \
-e "s|^LOG=.*|LOG=$DIR/logs/server.log|" -e "s|^NAME=.*|NAME=$SVC|" \ "$SRC/bin/fiverp.init" > "/etc/init.d/$SVC"
-e "s|/opt/fivem/bin/supervise.sh|$DIR/bin/supervise.sh|" \
"$SRC/bin/fivem.init" > "/etc/init.d/$SVC"
chmod +x "/etc/init.d/$SVC" chmod +x "/etc/init.d/$SVC"
if [ -d /run/systemd/system ]; then if [ -d /run/systemd/system ]; then
cat > "/etc/systemd/system/$SVC.service" <<EOF cat > "/etc/systemd/system/$SVC.service" <<EOF
[Unit] [Unit]
Description=Los Santos RP (FXServer) Description=FiveRP (FXServer)
After=network-online.target mariadb.service After=network-online.target mariadb.service
Wants=mariadb.service Wants=mariadb.service
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=$DIR/data WorkingDirectory=$DIR/server-data
ExecStart=$DIR/bin/supervise.sh $SVC $DIR/logs/server.log $DIR/server/run.sh +set citizen_dir $DIR/server/alpine/opt/cfx-server/citizen/ +exec $DIR/data/server.cfg ExecStart=$DIR/bin/run-server.sh $SVC $DIR $DIR/logs/server.log
KillMode=mixed KillMode=mixed
Restart=always Restart=always
RestartSec=5 RestartSec=5
@@ -194,43 +204,38 @@ elif [ -d /etc/redl/enabled ]; then
# REDL VDS: no systemd, the platform starts whatever is marked here # REDL VDS: no systemd, the platform starts whatever is marked here
touch "/etc/redl/enabled/$SVC" /etc/redl/enabled/mariadb touch "/etc/redl/enabled/$SVC" /etc/redl/enabled/mariadb
ok "marked in /etc/redl/enabled (REDL VDS autostart)" ok "marked in /etc/redl/enabled (REDL VDS autostart)"
elif [ -f /etc/redl/enabled ]; then
grep -qx "$SVC" /etc/redl/enabled || echo "$SVC" >> /etc/redl/enabled
grep -qx mariadb /etc/redl/enabled || echo mariadb >> /etc/redl/enabled
ok "marked in /etc/redl/enabled (REDL VDS autostart)"
else else
command -v update-rc.d >/dev/null && update-rc.d "$SVC" defaults >/dev/null 2>&1 || true command -v update-rc.d >/dev/null && update-rc.d "$SVC" defaults >/dev/null 2>&1 || true
ok "/etc/init.d/$SVC installed" ok "/etc/init.d/$SVC installed"
fi fi
# --- 6. start -------------------------------------------------------------- # --- 7. start --------------------------------------------------------------
if [ "$START" = 0 ]; then if [ "$START" = 0 ]; then say "Done (not started, --no-start)"; exit 0; fi
say "Done (not started, --no-start)"
exit 0
fi
if [ -z "$LICENCE" ]; then if [ -z "$LICENCE" ]; then
say "Done" say "Done"
echo " No licence key given. Get a free one at https://keymaster.fivem.net," echo " No licence key given. Get a free one at https://keymaster.fivem.net,"
echo " put it into $DIR/data/secrets.cfg and run: service $SVC start" echo " put it into $CFG and run: service $SVC start"
exit 0 exit 0
fi fi
say "Starting the server" say "Starting the server"
if [ -d /run/systemd/system ]; then systemctl restart "$SVC"; else service "$SVC" restart >/dev/null; fi if [ -d /run/systemd/system ]; then systemctl restart "$SVC"; else service "$SVC" restart >/dev/null; fi
LOG="$DIR/logs/server.log"
i=0 i=0
while [ $i -lt 90 ]; do while [ $i -lt 90 ]; do
if grep -q "Authenticated with cfx.re Nucleus" "$DIR/logs/server.log" 2>/dev/null; then if grep -q "Authenticated with cfx.re Nucleus" "$LOG" 2>/dev/null; then
ok "server is up and registered with Cfx" ok "server is up and registered with Cfx"
grep -q "\[selftest\] data path OK" "$DIR/logs/server.log" && ok "self-test passed: the data path works" grep -q "fiverp-auth\] server ready" "$LOG" && ok "fiverp-auth ready"
grep -q "Database server connection established" "$LOG" && ok "oxmysql connected to the database"
IP=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo "<server ip>") IP=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo "<server ip>")
printf '\n connect %s:%s\n logs: tail -f %s\n console: rcon status\n\n' "$IP" "$PORT" "$DIR/logs/server.log" printf '\n connect %s:%s\n logs: tail -f %s\n console: rcon status\n\n' "$IP" "$PORT" "$LOG"
exit 0 exit 0
fi fi
if grep -qE "Could not authenticate server license key|invalid license key|Failed to verify" "$DIR/logs/server.log" 2>/dev/null; then if grep -qE "Could not authenticate server license key|invalid license key" "$LOG" 2>/dev/null; then
if [ -d /run/systemd/system ]; then systemctl stop "$SVC"; else service "$SVC" stop >/dev/null 2>&1; fi if [ -d /run/systemd/system ]; then systemctl stop "$SVC"; else service "$SVC" stop >/dev/null 2>&1; fi
die "the licence key was refused - get one at https://keymaster.fivem.net, put it into $DIR/data/secrets.cfg and run: service $SVC start" die "the licence key was refused - get one at https://keymaster.fivem.net, put it into $CFG and run: service $SVC start"
fi fi
i=$((i+1)); sleep 2 i=$((i+1)); sleep 2
done done
die "the server did not come up in 3 minutes - see $DIR/logs/server.log" die "the server did not come up in 3 minutes - see $LOG"
View File
@@ -0,0 +1,145 @@
local isOpen = false
local cam = nil
local authDone = false
-- A slow drift over the city gives the glass something to sit on.
local CAM_POS = vector3(-1030.0, -2730.0, 320.0)
local CAM_LOOK = vector3(-75.0, -815.0, 220.0)
local SPAWN_POS = vector3(195.17, -889.36, 30.69)
local SPAWN_HDG = 145.0
local function startCamera()
cam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA',
CAM_POS.x, CAM_POS.y, CAM_POS.z, 0.0, 0.0, 0.0, 55.0, false, 0)
PointCamAtCoord(cam, CAM_LOOK.x, CAM_LOOK.y, CAM_LOOK.z)
SetCamActive(cam, true)
RenderScriptCams(true, false, 0, true, true)
-- Drift the camera so the backdrop is never a frozen still.
CreateThread(function()
local angle = 0.0
while cam and DoesCamExist(cam) and not authDone do
angle = angle + 0.02
local radius = 260.0
SetCamCoord(cam,
CAM_POS.x + math.cos(math.rad(angle)) * radius,
CAM_POS.y + math.sin(math.rad(angle)) * radius,
CAM_POS.z)
PointCamAtCoord(cam, CAM_LOOK.x, CAM_LOOK.y, CAM_LOOK.z)
Wait(20)
end
end)
end
local function stopCamera()
if cam and DoesCamExist(cam) then
RenderScriptCams(false, true, 900, true, true)
DestroyCam(cam, false)
cam = nil
end
end
local function openAuth()
isOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
DoScreenFadeIn(600)
end
local function closeAuth()
isOpen = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
-- Keep the unauthenticated player parked and invisible behind the glass.
CreateThread(function()
while not authDone do
local ped = PlayerPedId()
SetEntityVisible(ped, false, false)
SetEntityCollision(ped, false, false)
FreezeEntityPosition(ped, true)
SetPlayerInvincible(PlayerId(), true)
DisableAllControlActions(0)
Wait(0)
end
end)
-- Any map resource can re-enable auto spawn when it starts, so hold the gate
-- shut every time that happens until the player actually has an identity.
AddEventHandler('onClientMapStart', function()
if not authDone then
exports.spawnmanager:setAutoSpawn(false)
end
end)
CreateThread(function()
-- Take spawning away from spawnmanager until the player has an identity.
exports.spawnmanager:setAutoSpawn(false)
DoScreenFadeOut(0)
while not NetworkIsSessionStarted() do Wait(100) end
Wait(500)
startCamera()
openAuth()
end)
-- The account is known; fiverp-characters owns everything from here — the
-- slot screen, the appearance creator and the spawn itself. Auth only has to
-- fade out, release its camera and step aside.
local function enterCity()
authDone = true
closeAuth()
DoScreenFadeOut(500)
Wait(600)
stopCamera()
exports.spawnmanager:setAutoSpawn(false)
TriggerEvent('fiverp-chars:begin')
end
-- ------------------------------------------------------------- NUI callbacks
RegisterNUICallback('registerAccount', function(data, cb)
TriggerServerEvent('fiverp-auth:register:account', data)
cb({})
end)
RegisterNUICallback('registerIdentity', function(data, cb)
TriggerServerEvent('fiverp-auth:register:identity', data)
cb({})
end)
RegisterNUICallback('resumeIdentity', function(data, cb)
TriggerServerEvent('fiverp-auth:resume:identity', data)
cb({})
end)
RegisterNUICallback('login', function(data, cb)
TriggerServerEvent('fiverp-auth:login', data)
cb({})
end)
-- --------------------------------------------------------------- server said
RegisterNetEvent('fiverp-auth:register:accountResult', function(result)
SendNUIMessage({ action = 'accountResult', payload = result })
end)
RegisterNetEvent('fiverp-auth:register:identityResult', function(result)
SendNUIMessage({ action = 'identityResult', payload = result })
if result.ok then
CreateThread(function()
Wait(1600) -- let the permit finish stamping before the fade
enterCity(result.firstName, result.lastName)
end)
end
end)
RegisterNetEvent('fiverp-auth:loginResult', function(result)
SendNUIMessage({ action = 'loginResult', payload = result })
if result.ok and not result.needsIdentity then
CreateThread(function()
Wait(1200)
enterCity(result.firstName, result.lastName)
end)
end
end)
@@ -0,0 +1,24 @@
fx_version 'cerulean'
game 'gta5'
node_version '22'
name 'fiverp-auth'
author 'FiveRP'
description 'Two-step account registration and login for FiveRP'
version '1.0.0'
ui_page 'html/index.html'
client_script 'client/cl_auth.lua'
server_script 'server/sv_auth.js'
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2'
}
dependency 'oxmysql'
+391
View File
@@ -0,0 +1,391 @@
(() => {
'use strict';
const RESOURCE = 'fiverp-auth';
const stage = document.getElementById('stage');
const slab = document.getElementById('slab');
const railFill = document.getElementById('railFill');
const permit = document.getElementById('permit');
const sheen = document.getElementById('sheen');
const seal = document.getElementById('seal');
const cardNo = document.getElementById('cardNo');
const cardFirst = document.getElementById('cardFirst');
const cardLast = document.getElementById('cardLast');
const cardDob = document.getElementById('cardDob');
const cardIssue = document.getElementById('cardIssue');
const cardExpiry = document.getElementById('cardExpiry');
const cardSign = document.getElementById('cardSign');
const mrz1 = document.getElementById('mrz1');
const mrz2 = document.getElementById('mrz2');
const stampDate = document.getElementById('stampDate');
const views = {
login: document.getElementById('formLogin'),
account: document.getElementById('formAccount'),
identity: document.getElementById('formIdentity')
};
let current = 'login';
let busy = false;
// Set when sign-in finds an account whose registration never reached step 2.
let resuming = false;
// ------------------------------------------------------------------ post
function post(endpoint, data) {
return fetch(`https://${RESOURCE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
// ------------------------------------------------------------------ views
function show(view) {
current = view;
slab.dataset.view = view;
Object.entries(views).forEach(([name, form]) => {
form.classList.toggle('is-current', name === view);
});
document.querySelectorAll('.rail-step').forEach((step) => {
const which = step.dataset.step;
step.classList.toggle('is-active', which === view);
step.classList.toggle('is-done', which === 'account' && view === 'identity');
});
railFill.classList.toggle('is-full', view === 'identity');
const first = views[view].querySelector('input');
if (first) setTimeout(() => first.focus(), 240);
}
function notice(view, message, good) {
const el = views[view].querySelector('[data-notice]');
if (!message) {
el.classList.remove('is-shown', 'is-good');
el.textContent = '';
return;
}
el.textContent = message;
el.classList.add('is-shown');
el.classList.toggle('is-good', !!good);
}
function setBusy(view, state) {
busy = state;
const btn = views[view].querySelector('.btn');
btn.classList.toggle('is-busy', state);
btn.disabled = state;
}
function values(view) {
const out = {};
views[view].querySelectorAll('input').forEach((input) => {
out[input.name] = input.value.trim();
});
return out;
}
function markBad(view, names) {
views[view].querySelectorAll('input').forEach((input) => {
input.classList.toggle('is-bad', names.includes(input.name));
});
}
// =========================================================== the passport
// Everything on the data page is derived from the name, so the same resident
// always gets the same document — it reads as a record, not a random mock-up.
function seedOf(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return hash;
}
function passportNumber(seed) {
return String(seed % 1000000000).padStart(9, '0');
}
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
function human(date) {
return String(date.getDate()).padStart(2, '0') + ' ' +
MONTHS[date.getMonth()] + ' ' + date.getFullYear();
}
// MRZ dates are YYMMDD.
function mrzDate(date) {
return String(date.getFullYear() % 100).padStart(2, '0') +
String(date.getMonth() + 1).padStart(2, '0') +
String(date.getDate()).padStart(2, '0');
}
// ICAO 9303 check digit: weights cycle 7-3-1 over the field.
function checkDigit(field) {
const WEIGHTS = [7, 3, 1];
let sum = 0;
for (let i = 0; i < field.length; i++) {
const c = field[i];
let v;
if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48;
else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55;
else v = 0; // '<' filler
sum += v * WEIGHTS[i % 3];
}
return String(sum % 10);
}
function pad(text, length) {
return (text + '<'.repeat(length)).slice(0, length);
}
function buildDocument(firstName, lastName) {
const seed = seedOf((firstName + lastName).toLowerCase());
const number = passportNumber(seed);
const now = new Date();
const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate());
// A plausible adult birth date, stable for this name.
const dob = new Date(
now.getFullYear() - (21 + (seed % 34)),
(seed >>> 7) % 12,
1 + ((seed >>> 13) % 28)
);
const surname = lastName.toUpperCase();
const given = firstName.toUpperCase();
const line1 = pad('P<USA' + surname + '<<' + given, 44);
const numCd = checkDigit(number);
const dobCd = checkDigit(mrzDate(dob));
const expCd = checkDigit(mrzDate(expiry));
const personal = '<'.repeat(14);
const personalCd = checkDigit(personal);
const composite = number + numCd + mrzDate(dob) + dobCd +
mrzDate(expiry) + expCd + personal + personalCd;
const line2 = number + numCd + 'USA' + mrzDate(dob) + dobCd + 'X' +
mrzDate(expiry) + expCd + personal + personalCd +
checkDigit(composite);
return { number, issue, expiry, dob, line1, line2 };
}
function printName(el, text) {
if (!text) { el.textContent = ''; return; }
if (el.textContent === text) return;
el.textContent = '';
const span = document.createElement('span');
span.className = 'printed';
span.textContent = text;
el.appendChild(span);
}
const BLANK1 = 'P<USA' + '<'.repeat(39);
const BLANK2 = '<'.repeat(10) + 'USA' + '<'.repeat(31);
function fillPassport(firstName, lastName) {
printName(cardFirst, firstName.toUpperCase());
printName(cardLast, lastName.toUpperCase());
// Below two letters there is not enough to derive a document from.
if (firstName.length < 2 || lastName.length < 2) {
cardNo.textContent = '—';
cardDob.textContent = '—';
cardIssue.textContent = '—';
cardExpiry.textContent = '—';
cardSign.textContent = '';
mrz1.textContent = BLANK1;
mrz2.textContent = BLANK2;
return;
}
const doc = buildDocument(firstName, lastName);
cardNo.textContent = doc.number;
cardDob.textContent = human(doc.dob);
cardIssue.textContent = human(doc.issue);
cardExpiry.textContent = human(doc.expiry);
cardSign.textContent = firstName + ' ' + lastName;
mrz1.textContent = doc.line1;
mrz2.textContent = doc.line2;
stampDate.textContent = human(doc.issue);
}
function syncPassport() {
const { firstName, lastName } = values('identity');
fillPassport(firstName, lastName);
}
function issuePassport() {
permit.classList.add('is-issued');
seal.classList.add('is-stamped');
sheen.style.opacity = '0.85';
}
// Laminate glare tracks the cursor across the page.
permit.addEventListener('pointermove', (e) => {
const box = permit.getBoundingClientRect();
sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`);
sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`);
});
// --------------------------------------------------------- password meter
const pwInput = views.account.querySelector('input[name="password"]');
const meter = views.account.querySelector('[data-meter]');
pwInput.addEventListener('input', () => {
const v = pwInput.value;
meter.classList.toggle('is-live', v.length > 0);
let level = 1;
if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2;
if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3;
meter.dataset.level = String(level);
meter.querySelector('i').style.width = `${level * 33.4}%`;
});
views.identity.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => {
// Keep the document honest: only English letters ever reach it.
input.value = input.value.replace(/[^A-Za-z]/g, '');
syncPassport();
});
});
// ------------------------------------------------------------ validation
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/;
const RE_NAME = /^[A-Za-z]{2,24}$/;
function checkAccount(v) {
if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']];
if (!RE_USERNAME.test(v.username)) return ['Username must be 332 characters, letters, numbers or underscore.', ['username']];
if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']];
return null;
}
function checkIdentity(v) {
if (!RE_NAME.test(v.firstName)) return ['First name must be 224 English letters.', ['firstName']];
if (!RE_NAME.test(v.lastName)) return ['Last name must be 224 English letters.', ['lastName']];
return null;
}
// ---------------------------------------------------------------- submits
views.login.addEventListener('submit', (e) => {
e.preventDefault();
if (busy) return;
const v = values('login');
if (!v.username || !v.password) {
markBad('login', [!v.username ? 'username' : 'password']);
return notice('login', 'Enter your username and password.');
}
markBad('login', []);
notice('login', '');
setBusy('login', true);
post('login', v);
});
views.account.addEventListener('submit', (e) => {
e.preventDefault();
if (busy) return;
const v = values('account');
const bad = checkAccount(v);
if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); }
markBad('account', []);
notice('account', '');
setBusy('account', true);
post('registerAccount', v);
});
views.identity.addEventListener('submit', (e) => {
e.preventDefault();
if (busy) return;
const v = values('identity');
const bad = checkIdentity(v);
if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); }
markBad('identity', []);
notice('identity', '');
setBusy('identity', true);
post(resuming ? 'resumeIdentity' : 'registerIdentity', v);
});
document.querySelectorAll('[data-goto]').forEach((btn) => {
btn.addEventListener('click', () => {
if (busy) return;
notice(current, '');
show(btn.dataset.goto);
});
});
// ------------------------------------------------------ messages from Lua
window.addEventListener('message', (event) => {
const msg = event.data || {};
const p = msg.payload || {};
switch (msg.action) {
case 'open':
stage.classList.add('is-open');
stage.setAttribute('aria-hidden', 'false');
show('login');
break;
case 'close':
stage.classList.remove('is-open');
stage.setAttribute('aria-hidden', 'true');
break;
case 'accountResult':
setBusy('account', false);
if (p.ok) {
notice('account', '');
show('identity');
syncPassport();
} else {
notice('account', p.error || 'Something went wrong.');
}
break;
case 'identityResult':
setBusy('identity', false);
if (p.ok) {
fillPassport(String(p.firstName), String(p.lastName));
issuePassport();
notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true);
views.identity.querySelector('.btn span').textContent = 'Entering the city…';
views.identity.querySelector('.btn').disabled = true;
// Nothing left to go back to once the document exists.
views.identity.querySelector('.switch').style.display = 'none';
} else {
if (p.reset) { resuming = false; show('account'); }
notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.');
}
break;
case 'loginResult':
setBusy('login', false);
if (p.ok && p.needsIdentity) {
// Account is real but has no resident yet — finish step 2.
resuming = true;
show('identity');
syncPassport();
notice('identity', 'Your file is open. Name your resident to finish.', true);
} else if (p.ok) {
notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true);
views.login.querySelector('.btn span').textContent = 'Entering the city…';
views.login.querySelector('.btn').disabled = true;
} else {
markBad('login', ['username', 'password']);
notice('login', p.error || 'Something went wrong.');
}
break;
}
});
// Clear the error ring as soon as the player starts fixing the field.
document.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => input.classList.remove('is-bad'));
});
})();
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FiveRP — Passport Office</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="stage" id="stage" aria-hidden="true">
<section class="slab" id="slab" data-view="login">
<!-- ------------------------------------------------------------- form -->
<div class="pane pane-form">
<p class="eyebrow">Los&nbsp;Santos <span class="dot"></span> Passport Office</p>
<div class="rail" id="rail" role="list">
<div class="rail-step is-active" data-step="account" role="listitem">
<span class="rail-mark"></span>
<span class="rail-label">Account</span>
</div>
<span class="rail-line"><i id="railFill"></i></span>
<div class="rail-step" data-step="identity" role="listitem">
<span class="rail-mark"></span>
<span class="rail-label">Identity</span>
</div>
</div>
<!-- Sign in -->
<form class="view" data-view="login" id="formLogin" novalidate>
<h1 class="title">Welcome back</h1>
<p class="sub">Sign in to return to the city.</p>
<label class="field">
<span class="label">Username</span>
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
</label>
<label class="field">
<span class="label">Password</span>
<input type="password" name="password" autocomplete="current-password" placeholder="••••••••">
</label>
<p class="notice" data-notice></p>
<button class="btn" type="submit"><span>Sign in</span></button>
<p class="switch">New to Los Santos? <button type="button" class="link" data-goto="account">Create an account</button></p>
</form>
<!-- Register step 1 -->
<form class="view" data-view="account" id="formAccount" novalidate>
<h1 class="title">Open your file</h1>
<p class="sub">The registry keeps this private. It is how you sign back in.</p>
<label class="field">
<span class="label">Email</span>
<input type="email" name="email" autocomplete="email" spellcheck="false" placeholder="you@example.com">
</label>
<label class="field">
<span class="label">Username</span>
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
<span class="hint">332 characters. Letters, numbers, underscore.</span>
</label>
<label class="field">
<span class="label">Password</span>
<input type="password" name="password" autocomplete="new-password" placeholder="At least 8 characters">
<span class="pwmeter" data-meter><i></i></span>
</label>
<p class="notice" data-notice></p>
<button class="btn" type="submit"><span>Continue</span></button>
<p class="switch">Already registered? <button type="button" class="link" data-goto="login">Sign in</button></p>
</form>
<!-- Register step 2 -->
<form class="view" data-view="identity" id="formIdentity" novalidate>
<h1 class="title">Name your resident</h1>
<p class="sub">This is the name the city will know you by. English letters only.</p>
<div class="row">
<label class="field">
<span class="label">First name</span>
<input type="text" name="firstName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Marcus">
</label>
<label class="field">
<span class="label">Last name</span>
<input type="text" name="lastName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Reyes">
</label>
</div>
<p class="notice" data-notice></p>
<button class="btn" type="submit"><span>Issue passport</span></button>
<p class="switch"><button type="button" class="link" data-goto="account">Back to account details</button></p>
</form>
</div>
<!-- ---------------------------------------------------------- passport -->
<div class="pane pane-card" id="pane-card">
<div class="passport" id="permit">
<div class="pp-guilloche" aria-hidden="true"></div>
<div class="pp-sheen" id="sheen" aria-hidden="true"></div>
<header class="pp-head">
<span class="pp-seal" aria-hidden="true">
<svg viewBox="0 0 64 64">
<circle class="ring-out" cx="32" cy="32" r="30"/>
<circle class="ring-in" cx="32" cy="32" r="26"/>
<!-- 13 stars, arced over the shield -->
<g class="stars">
<circle cx="32" cy="14" r="1.5"/>
<circle cx="25" cy="15.3" r="1.5"/><circle cx="39" cy="15.3" r="1.5"/>
<circle cx="19" cy="19" r="1.5"/><circle cx="45" cy="19" r="1.5"/>
<circle cx="15" cy="25" r="1.5"/><circle cx="49" cy="25" r="1.5"/>
</g>
<!-- shield: blue chief over 13 pales -->
<path class="shield-chief" d="M17 28h30v7H17z"/>
<g class="shield-pales">
<path d="M17 35h3v9.5c0 .6.1 1.2.2 1.8H17z"/>
<path d="M23 35h3v13h-3z"/>
<path d="M29 35h3v13h-3z"/>
<path d="M35 35h3v13h-3z"/>
<path d="M41 35h3v13h-3z"/>
</g>
<path class="shield-edge" d="M17 28h30v16.5c0 5.5-6.5 10.5-15 13.5-8.5-3-15-8-15-13.5z"/>
</svg>
</span>
<span class="pp-head-text">
<span class="pp-kind">Passport</span>
<span class="pp-country">United States of America</span>
</span>
<span class="pp-chip" aria-hidden="true">
<svg viewBox="0 0 20 16">
<rect x="0.6" y="0.6" width="18.8" height="14.8" rx="2.4"/>
<path d="M7 .6v15M13 .6v15M.6 5.4h18.8M.6 10.6h18.8"/>
</svg>
</span>
</header>
<div class="pp-body">
<div class="pp-left">
<div class="pp-photo">
<svg class="pp-bust" viewBox="0 0 48 60" aria-hidden="true">
<defs>
<linearGradient id="bust" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#9aa7b8"/>
<stop offset="1" stop-color="#5d6b7e"/>
</linearGradient>
</defs>
<circle cx="24" cy="18" r="11" fill="url(#bust)"/>
<path d="M3 60c0-11.5 9.4-20 21-20s21 8.5 21 20z" fill="url(#bust)"/>
</svg>
<span class="pp-photo-label">Photo</span>
</div>
<div class="pp-sign">
<span class="pp-sign-ink" id="cardSign"></span>
<span class="pp-sign-rule"></span>
<span class="pp-sign-label">Signature</span>
</div>
</div>
<dl class="pp-fields">
<div class="pp-f pp-f--type"><dt>Type</dt><dd>P</dd></div>
<div class="pp-f pp-f--code"><dt>Code</dt><dd>USA</dd></div>
<div class="pp-f pp-f--no"><dt>Passport No.</dt><dd id="cardNo" class="mono"></dd></div>
<div class="pp-f pp-f--wide"><dt>Surname</dt>
<dd class="pp-name" id="cardLast" data-empty="—"></dd></div>
<div class="pp-f pp-f--wide"><dt>Given names</dt>
<dd class="pp-name" id="cardFirst" data-empty="—"></dd></div>
<div class="pp-f pp-f--wide"><dt>Nationality</dt>
<dd>United States of America</dd></div>
<div class="pp-f"><dt>Date of birth</dt><dd class="mono" id="cardDob"></dd></div>
<div class="pp-f"><dt>Sex</dt><dd>X</dd></div>
<div class="pp-f pp-f--wide"><dt>Place of birth</dt><dd>Los Santos, San Andreas</dd></div>
<div class="pp-f"><dt>Date of issue</dt><dd class="mono" id="cardIssue"></dd></div>
<div class="pp-f"><dt>Date of expiration</dt><dd class="mono" id="cardExpiry"></dd></div>
<!-- Spans 4 of 6 columns, leaving the bottom-right corner clear so
the entry stamp lands on paper rather than on top of data. -->
<div class="pp-f pp-f--auth"><dt>Authority</dt>
<dd>United States Department of State</dd></div>
</dl>
<div class="pp-ghost" aria-hidden="true">
<svg viewBox="0 0 48 60">
<circle cx="24" cy="20" r="12"/>
<path d="M2 60c0-12 10-21 22-21s22 9 22 21z"/>
</svg>
</div>
</div>
<div class="pp-mrz mono">
<span id="mrz1">P&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span>
<span id="mrz2">&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span>
</div>
<div class="pp-stamp" id="seal" aria-hidden="true">
<span class="pp-stamp-top">Admitted</span>
<span class="pp-stamp-mid">Los Santos</span>
<span class="pp-stamp-bot" id="stampDate">— — —</span>
</div>
</div>
<p class="pp-caption">Issued once. The city will know you by this name.</p>
</div>
</section>
</div>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,727 @@
/* FiveRP — passport office (registration + sign in).
Design system: /opt/fivem/server-data/CLAUDE.md
Deliberately zero box-shadow on panels. CEF composites the shadow of a
backdrop-filtered element as an opaque rectangle, which is what put a black
square behind this panel in game. Depth here comes from blur, hairlines and
inner highlights only. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--surface: rgba(255, 255, 255, 0.72);
--panel: rgba(255, 255, 255, 0.80);
--field-bg: rgba(0, 0, 0, 0.04);
--field-bg-focus:#ffffff;
--fg: #1d1d1f;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong: #0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--ring: rgba(0, 113, 227, 0.18);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--hover: rgba(0, 0, 0, 0.04);
--success: #34c759;
--danger: #ff3b30;
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
/* passport ink */
--pp-navy: #10315e;
--pp-label: #46719f;
--pp-value: #14202f;
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
'Liberation Mono', 'DejaVu Sans Mono', monospace;
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
color-scheme: light;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: transparent; /* the game is behind this page */
overflow: hidden;
font-family: var(--sans);
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
}
/* ------------------------------------------------------------------- stage */
.stage {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 32px;
opacity: 0;
visibility: hidden;
transition: opacity 460ms var(--ease), visibility 0s linear 460ms;
/* Lightening only. Never darken the game to make the panel readable —
that is what produced the grey box before. */
background: radial-gradient(90% 70% at 50% 50%,
rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 72%);
}
.stage.is-open {
opacity: 1;
visibility: visible;
transition: opacity 460ms var(--ease), visibility 0s;
}
/* -------------------------------------------------------------------- slab */
.slab {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 520px;
width: min(1080px, 94vw);
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: var(--radius-lg);
overflow: hidden;
transform: translateY(14px) scale(0.985);
opacity: 0;
transition:
width 620ms var(--ease),
grid-template-columns 620ms var(--ease),
transform 620ms var(--ease),
opacity 480ms var(--ease);
}
.stage.is-open .slab { transform: none; opacity: 1; }
/* Signing in needs no passport — collapse to a single column. */
.slab[data-view="login"] {
width: min(460px, 94vw);
grid-template-columns: minmax(0, 1fr) 0px;
}
.slab[data-view="login"] .pane-card {
opacity: 0;
pointer-events: none;
transform: translateX(24px);
}
.pane { min-width: 0; }
.pane-form {
padding: 40px 40px 34px;
display: flex;
flex-direction: column;
}
.pane-card {
padding: 32px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 14px;
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.14));
transition: opacity 420ms var(--ease), transform 620ms var(--ease);
}
/* ----------------------------------------------------------------- eyebrow */
.eyebrow {
display: flex;
align-items: center;
gap: 9px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.20em;
text-transform: uppercase;
color: var(--fg-tertiary);
margin-bottom: 24px;
}
.eyebrow .dot {
width: 3px; height: 3px;
border-radius: 50%;
background: currentColor;
}
/* -------------------------------------------------------------------- rail */
.rail {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 0;
opacity: 0;
height: 0;
overflow: hidden;
transition: opacity 360ms var(--ease);
}
/* Signing in has no steps, so the rail collapses margin and all. */
.slab[data-view="account"] .rail,
.slab[data-view="identity"] .rail { opacity: 1; height: auto; margin-bottom: 30px; }
.rail-step { display: flex; align-items: center; gap: 8px; }
.rail-mark {
position: relative;
width: 11px; height: 11px;
border-radius: 50%;
border: 1.5px solid var(--line-strong);
background: transparent;
transition: background-color .3s var(--ease), border-color .3s var(--ease);
}
.rail-step.is-active .rail-mark {
border-color: var(--accent);
background: var(--accent);
}
.rail-step.is-done .rail-mark {
border-color: var(--accent);
background: var(--accent);
}
/* Done and active must not look identical — done gets a tick. */
.rail-step.is-done .rail-mark::after {
content: '';
position: absolute;
left: 2.4px; top: 1px;
width: 3px; height: 5.5px;
border: solid #fff;
border-width: 0 1.5px 1.5px 0;
transform: rotate(42deg);
}
.rail-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--fg-tertiary);
transition: color .3s var(--ease);
}
.rail-step.is-active .rail-label,
.rail-step.is-done .rail-label { color: var(--fg-secondary); }
.rail-line {
position: relative;
flex: 1;
height: 1.5px;
border-radius: 2px;
background: var(--line-strong);
overflow: hidden;
}
.rail-line i {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: var(--accent);
transition: width 560ms var(--ease);
}
.rail-line i.is-full { width: 100%; }
/* ------------------------------------------------------------------- views */
.view {
display: none;
flex-direction: column;
}
.view.is-current { display: flex; animation: viewIn .5s var(--ease) both; }
@keyframes viewIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.1;
color: var(--fg);
margin-bottom: 8px;
}
.sub {
font-size: 13.5px;
font-weight: 400;
line-height: 1.55;
color: var(--fg-secondary);
margin-bottom: 26px;
}
/* ------------------------------------------------------------------ fields */
.field {
display: block;
margin-bottom: 16px;
}
.label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--fg-secondary);
margin-bottom: 7px;
}
.field input {
width: 100%;
border-radius: var(--radius-md);
padding: 13px 14px;
font-family: var(--sans);
font-size: 14.5px;
font-weight: 400;
letter-spacing: -0.005em;
color: var(--fg);
background: var(--field-bg);
border: 1px solid transparent;
outline: none;
transition: background-color .2s, border-color .2s, box-shadow .2s;
}
.field input::placeholder { color: var(--fg-tertiary); }
.field input:focus {
background: var(--field-bg-focus);
border-color: var(--accent);
}
.field input.is-bad {
border-color: var(--danger);
}
.hint {
display: block;
margin-top: 7px;
font-size: 11px;
font-weight: 400;
color: var(--fg-tertiary);
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.row .field { margin-bottom: 16px; }
/* password strength */
.pwmeter {
display: block;
height: 3px;
margin-top: 9px;
border-radius: 2px;
background: var(--line);
opacity: 0;
transition: opacity .25s ease;
}
.pwmeter.is-live { opacity: 1; }
.pwmeter i {
display: block;
height: 100%;
width: 0;
border-radius: 2px;
background: var(--danger);
transition: width .3s var(--ease), background-color .3s ease;
}
.pwmeter[data-level="2"] i { background: #ff9f0a; }
.pwmeter[data-level="3"] i { background: var(--success); }
/* ------------------------------------------------------------------ notice */
.notice {
font-size: 12.5px;
font-weight: 400;
line-height: 1.5;
color: var(--danger);
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height .3s var(--ease), opacity .3s ease, margin .3s var(--ease);
}
.notice.is-shown {
max-height: 60px;
opacity: 1;
margin-bottom: 14px;
}
.notice.is-good { color: #1f8f3d; }
/* ----------------------------------------------------------------- buttons */
.btn {
position: relative;
width: 100%;
border: 0;
border-radius: 999px;
padding: 14px 24px;
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
color: #fff;
cursor: pointer;
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
transition: filter .2s ease, transform .12s var(--ease), opacity .2s ease;
}
.btn:hover:not(:disabled) { filter: brightness(1.08); }
.btn:active:not(:disabled) { transform: scale(0.978); }
.btn:disabled { opacity: .55; cursor: default; }
.btn.is-busy span { opacity: 0; }
.btn.is-busy::after {
content: '';
position: absolute;
top: 50%; left: 50%;
width: 16px; height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.switch {
margin-top: 18px;
text-align: center;
font-size: 12.5px;
font-weight: 400;
color: var(--fg-secondary);
}
.link {
border: 0;
background: none;
padding: 0;
font-family: var(--sans);
font-size: 12.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--accent);
cursor: pointer;
}
.link:hover { text-decoration: underline; }
/* ==================================================================== PASSPORT
A United States passport data page. This is the one saturated object on the
screen; the form beside it stays neutral so the document reads as the hero. */
.passport {
position: relative;
width: 100%;
aspect-ratio: 125 / 88; /* ID-3, the real bio-page proportion */
border-radius: 12px;
overflow: hidden;
padding: 11px 15px 0;
display: flex;
flex-direction: column;
color: var(--pp-value);
background:
radial-gradient(78% 66% at 16% 14%, rgba(255,255,255,0.92), rgba(255,255,255,0) 62%),
linear-gradient(155deg, #f8fbff 0%, #e9f1fa 46%, #f5f1e7 100%);
border: 1px solid rgba(16, 49, 94, 0.16);
transition: transform 700ms var(--ease);
}
.passport.is-issued { transform: translateY(-3px); }
/* Engine-turned security print. Three cheap repeating gradients read
convincingly as guilloche at this scale. */
.pp-guilloche {
position: absolute;
inset: 0;
pointer-events: none;
opacity: .55;
background:
repeating-radial-gradient(circle at 20% 44%, rgba(16,49,94,.055) 0 1px, transparent 1px 7px),
repeating-radial-gradient(circle at 80% 62%, rgba(16,49,94,.05) 0 1px, transparent 1px 9px),
repeating-linear-gradient(58deg, rgba(16,49,94,.032) 0 1px, transparent 1px 6px);
}
/* Laminate glare that follows the cursor. */
.pp-sheen {
position: absolute;
inset: 0;
pointer-events: none;
opacity: .55;
background: radial-gradient(circle at var(--mx, 78%) var(--my, 10%),
rgba(255,255,255,0.75) 0%, rgba(255,255,255,0) 46%);
transition: opacity .5s ease;
}
/* ------------------------------------------------------------ passport head */
.pp-head {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 9px;
border-bottom: 1px solid rgba(16, 49, 94, 0.18);
}
.pp-seal { flex: none; width: 30px; height: 30px; display: block; }
.pp-seal svg { width: 100%; height: 100%; display: block; }
.pp-seal .ring-out,
.pp-seal .ring-in { fill: none; stroke: rgba(16,49,94,.55); stroke-width: 1.1; }
.pp-seal .ring-in { stroke-width: .6; }
.pp-seal .stars { fill: rgba(16,49,94,.6); }
.pp-seal .shield-chief { fill: rgba(16,49,94,.75); }
.pp-seal .shield-pales { fill: rgba(16,49,94,.28); }
.pp-seal .shield-edge { fill: none; stroke: rgba(16,49,94,.65); stroke-width: 1.2; }
.pp-head-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.pp-kind {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.30em;
text-transform: uppercase;
color: var(--pp-navy);
}
.pp-country {
font-size: 8px;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--pp-label);
}
.pp-chip { margin-left: auto; width: 19px; height: 15px; opacity: .6; }
.pp-chip svg { width: 100%; height: 100%; fill: none; stroke: rgba(16,49,94,.6); stroke-width: 1; }
/* ------------------------------------------------------------ passport body */
.pp-body {
position: relative;
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 12px;
padding-top: 8px;
}
.pp-left { display: flex; flex-direction: column; gap: 9px; }
.pp-photo {
position: relative;
align-self: start;
border: 1px solid rgba(16, 49, 94, 0.22);
border-radius: 3px;
background: linear-gradient(180deg, #eef2f7, #dde5ee);
aspect-ratio: 35 / 45; /* passport photo proportion */
overflow: hidden;
display: grid;
place-items: end center;
}
.pp-bust { width: 76%; height: 88%; align-self: end; }
.pp-photo-label {
position: absolute;
top: 4px; left: 5px;
font-size: 6px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(16, 49, 94, 0.42);
}
/* The secondary faded portrait real passports carry to the right of the data. */
.pp-ghost {
position: absolute;
right: 2px; top: 34%;
width: 50px;
opacity: .1;
pointer-events: none;
}
.pp-ghost svg { width: 100%; display: block; fill: var(--pp-navy); }
.pp-fields {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px 10px;
align-content: start;
min-width: 0;
}
.pp-f { grid-column: span 2; min-width: 0; }
.pp-f--type { grid-column: span 1; }
.pp-f--code { grid-column: span 1; }
.pp-f--no { grid-column: span 4; }
.pp-f--wide { grid-column: span 6; }
.pp-f dt {
font-size: 6.5px;
font-weight: 600;
line-height: 1.35;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--pp-label);
white-space: nowrap;
}
.pp-f dd {
font-size: 10px;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.01em;
color: var(--pp-value);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pp-f dd.mono {
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
}
.pp-f--auth { grid-column: span 4; }
.pp-f--auth dd { font-size: 8.5px; font-weight: 400; color: var(--pp-label); }
/* The two fields the player actually controls — set larger than the rest. */
.pp-name {
font-size: 15px !important;
font-weight: 700 !important;
line-height: 1.25 !important;
letter-spacing: -0.02em !important;
text-transform: uppercase;
color: var(--pp-navy) !important;
min-height: 19px;
}
.pp-name:empty::before {
content: attr(data-empty);
color: rgba(16, 49, 94, 0.25);
}
/* Each keystroke re-prints the value; the fade makes it feel typeset. */
.printed { display: inline-block; animation: printIn .4s var(--ease) both; }
@keyframes printIn {
from { opacity: 0; transform: translateY(3px); filter: blur(3px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
/* ----------------------------------------------------------------- signature */
.pp-sign {
position: relative;
display: flex;
flex-direction: column;
}
.pp-sign-ink {
font-family: 'Segoe Script', 'Bradley Hand', 'Snell Roundhand', cursive;
font-size: 11px;
color: rgba(16, 49, 94, 0.75);
height: 14px;
line-height: 14px;
white-space: nowrap;
overflow: hidden;
transform: rotate(-1.5deg);
transform-origin: left bottom;
}
.pp-sign-rule { height: 1px; background: rgba(16, 49, 94, 0.28); }
.pp-sign-label {
font-size: 6px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--pp-label);
margin-top: 2px;
}
/* ----------------------------------------------------------------------- MRZ */
.pp-mrz {
margin: 6px -15px 0;
margin-top: auto; /* pinned to the foot of the page */
padding: 6px 15px 7px;
border-top: 1px solid rgba(16, 49, 94, 0.18);
background: rgba(255, 255, 255, 0.62);
display: flex;
flex-direction: column;
gap: 2px;
}
.pp-mrz span {
display: block;
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
line-height: 1.25;
letter-spacing: 0.055em;
color: var(--pp-navy);
white-space: nowrap;
overflow: hidden;
}
/* --------------------------------------------------------------------- stamp */
.pp-stamp {
position: absolute;
right: 13px; bottom: 50px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 7px 13px;
border: 2px solid rgba(176, 42, 42, 0.62);
border-radius: 4px;
color: rgba(176, 42, 42, 0.78);
/* multiply lets the guilloche show through, like real stamp ink */
mix-blend-mode: multiply;
transform: rotate(-13deg) scale(1.5);
opacity: 0;
pointer-events: none;
transition: opacity 420ms var(--ease), transform 520ms cubic-bezier(.2,1.4,.4,1);
}
.pp-stamp.is-stamped { opacity: 1; transform: rotate(-13deg) scale(1); }
.pp-stamp-top, .pp-stamp-bot {
font-size: 6.5px;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.pp-stamp-mid {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.pp-caption {
text-align: center;
font-size: 11px;
font-weight: 400;
color: var(--fg-tertiary);
}
/* ------------------------------------------------------------------- motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
::selection { background: var(--accent-soft); color: var(--accent-strong); }
/* ---------------------------------------------------------------- NUI final
CEF turns backdrop-filter and box-shadow into opaque black plates at the
element bounds, so both are gone from this file entirely. Panels earn their
separation from opacity and hairlines instead of blur, which means the
surface has to carry more white than it did when a blur sat behind it.
Appended last so it wins over anything above. */
:root {
--surface: rgba(255, 255, 255, 0.95);
--panel: rgba(255, 255, 255, 0.97);
}
.slab {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.stage { background: none; }
.field:focus,
.field:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
@@ -0,0 +1,370 @@
-- Character slots, live appearance creator and spawn.
-- The preview ped IS the player ped, so what the player sculpts is literally
-- the body they walk away with — no separate preview entity to keep in sync.
local isOpen = false
local inEditor = false
local activeChar = nil -- id of the character being played
local editSlot = nil -- slot being filled
local editId = nil -- id when finishing an existing draft
local cam = nil
local zoom = 'head'
local PREVIEW_POS = vector3(-1042.0, -2745.0, 21.36)
local PREVIEW_HDG = 315.0
local SPAWN_POS = vector3(195.17, -889.36, 30.69)
local SPAWN_HDG = 145.0
-- Camera framing per zoom step: forward distance, height above the ped root,
-- and the height it looks at.
local FRAMES = {
head = { dist = 0.85, height = 0.70, look = 0.68 },
body = { dist = 2.30, height = 0.30, look = 0.25 },
legs = { dist = 2.10, height = -0.35, look = -0.45 },
}
-- Fallback counts, used only if the native that reports them is unavailable.
local OVERLAY_FALLBACK = { 24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12 }
-- ------------------------------------------------------------------- helpers
local function loadModel(name)
local hash = GetHashKey(name)
if not IsModelInCdimage(hash) then return nil end
RequestModel(hash)
local timeout = GetGameTimer() + 10000
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
return HasModelLoaded(hash) and hash or nil
end
local function num(v, fallback)
v = tonumber(v)
if v == nil then return fallback end
return v + 0.0
end
-- Overlays 1/2/10 take hair colours, 4/5/8 take makeup colours, the rest none.
local function overlayColourType(index)
if index == 1 or index == 2 or index == 10 then return 1 end
if index == 4 or index == 5 or index == 8 then return 2 end
return 0
end
local function applyAppearance(app)
if type(app) ~= 'table' then return end
local wanted = GetHashKey(app.model or 'mp_m_freemode_01')
if GetEntityModel(PlayerPedId()) ~= wanted then
local hash = loadModel(app.model or 'mp_m_freemode_01')
if hash then
SetPlayerModel(PlayerId(), hash)
SetModelAsNoLongerNeeded(hash)
SetPedDefaultComponentVariation(PlayerPedId())
end
end
local ped = PlayerPedId()
local b = app.blend or {}
SetPedHeadBlendData(ped,
math.floor(num(b.shapeFirst, 0)), math.floor(num(b.shapeSecond, 0)), 0,
math.floor(num(b.skinFirst, 0)), math.floor(num(b.skinSecond, 0)), 0,
num(b.shapeMix, 0.5), num(b.skinMix, 0.5), 0.0, false)
local features = app.features or {}
for i = 0, 19 do
SetPedFaceFeature(ped, i, num(features[i + 1], 0.0))
end
local overlays = app.overlays or {}
for i = 0, 11 do
local o = overlays[i + 1] or {}
local value = math.floor(num(o.v, 255))
SetPedHeadOverlay(ped, i, value, num(o.o, 1.0))
local kind = overlayColourType(i)
if kind > 0 then
SetPedHeadOverlayColor(ped, i, kind, math.floor(num(o.c1, 0)), math.floor(num(o.c2, 0)))
end
end
local hair = app.hair or {}
SetPedComponentVariation(ped, 2, math.floor(num(hair.style, 0)), 0, 2)
SetPedHairColor(ped, math.floor(num(hair.color, 0)), math.floor(num(hair.highlight, 0)))
SetPedEyeColor(ped, math.floor(num(app.eye, 0)))
local fit = app.outfit or {}
SetPedComponentVariation(ped, 11, math.floor(num(fit.torso, 0)), 0, 2) -- jacket / top
SetPedComponentVariation(ped, 8, math.floor(num(fit.undershirt, 0)), 0, 2)
SetPedComponentVariation(ped, 4, math.floor(num(fit.legs, 0)), 0, 2)
SetPedComponentVariation(ped, 6, math.floor(num(fit.shoes, 0)), 0, 2)
SetPedComponentVariation(ped, 3, 0, 0, 2) -- bare arms/torso
end
-- ------------------------------------------------------------------- camera
local function updateCamera()
if not cam or not DoesCamExist(cam) then return end
local ped = PlayerPedId()
local f = FRAMES[zoom] or FRAMES.head
local pos = GetOffsetFromEntityInWorldCoords(ped, 0.0, f.dist, f.height)
local look = GetOffsetFromEntityInWorldCoords(ped, 0.0, 0.0, f.look)
SetCamCoord(cam, pos.x, pos.y, pos.z)
PointCamAtCoord(cam, look.x, look.y, look.z)
end
local function startCamera()
if cam and DoesCamExist(cam) then DestroyCam(cam, false) end
cam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 32.0, false, 0)
SetCamActive(cam, true)
RenderScriptCams(true, false, 0, true, true)
updateCamera()
end
local function stopCamera()
if cam and DoesCamExist(cam) then
RenderScriptCams(false, true, 700, true, true)
DestroyCam(cam, false)
end
cam = nil
end
-- Park the ped on the creator stage: visible, lit, frozen, facing the camera.
local function stagePed()
local ped = PlayerPedId()
RequestCollisionAtCoord(PREVIEW_POS.x, PREVIEW_POS.y, PREVIEW_POS.z)
SetEntityCoordsNoOffset(ped, PREVIEW_POS.x, PREVIEW_POS.y, PREVIEW_POS.z, false, false, false)
SetEntityHeading(ped, PREVIEW_HDG)
FreezeEntityPosition(ped, true)
SetEntityInvincible(ped, true)
SetEntityVisible(ped, true, false)
SetEntityCollision(ped, true, true)
ClearPedTasksImmediately(ped)
end
-- ---------------------------------------------------------------------- NUI
local function sendLimits()
local ped = PlayerPedId()
local overlays = {}
for i = 0, 11 do
local ok, count = pcall(GetNumHeadOverlayValues, i)
overlays[i + 1] = (ok and count and count > 0) and count or OVERLAY_FALLBACK[i + 1]
end
SendNUIMessage({ action = 'limits', payload = {
overlays = overlays,
hair = GetNumberOfPedDrawableVariations(ped, 2),
torso = GetNumberOfPedDrawableVariations(ped, 11),
undershirt = GetNumberOfPedDrawableVariations(ped, 8),
legs = GetNumberOfPedDrawableVariations(ped, 4),
shoes = GetNumberOfPedDrawableVariations(ped, 6),
}})
end
local function openUI(view)
isOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open', view = view })
end
local function closeUI()
isOpen = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
-- Hold the player still and silent while the slots or the creator are up.
CreateThread(function()
while true do
if isOpen then
DisableAllControlActions(0)
-- Mouse look would fight the scripted camera.
DisableControlAction(0, 1, true)
DisableControlAction(0, 2, true)
SetPlayerInvincible(PlayerId(), true)
Wait(0)
else
Wait(300)
end
end
end)
-- --------------------------------------------------------------- the flow
local function beginSelection()
exports.spawnmanager:setAutoSpawn(false)
-- A clear midday sky so nobody sculpts a face in the dark.
NetworkOverrideClockTime(13, 0, 0)
SetWeatherTypeNowPersist('EXTRASUNNY')
local ped = PlayerPedId()
SetEntityVisible(ped, false, false)
stagePed()
SetEntityVisible(ped, false, false)
startCamera()
zoom = 'body'
updateCamera()
openUI('select')
TriggerServerEvent('fiverp-chars:request')
DoScreenFadeIn(600)
end
-- fiverp-auth hands over with a local TriggerEvent once the account is known.
RegisterNetEvent('fiverp-chars:begin', beginSelection)
AddEventHandler('onClientMapStart', function()
if not activeChar then exports.spawnmanager:setAutoSpawn(false) end
end)
RegisterNetEvent('fiverp-chars:slots', function(slots)
SendNUIMessage({ action = 'slots', payload = slots })
end)
RegisterNetEvent('fiverp-chars:saveResult', function(result)
SendNUIMessage({ action = 'saveResult', payload = result })
end)
local function enterCity(data)
activeChar = data.id
inEditor = false
closeUI()
DoScreenFadeOut(500)
Wait(600)
stopCamera()
NetworkClearClockTimeOverride()
ClearWeatherTypePersist()
local pos = data.position
local x, y, z, h
if pos and pos.x and pos.x ~= 0 then
x, y, z, h = pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, (pos.heading or 0.0) + 0.0
else
x, y, z, h = SPAWN_POS.x, SPAWN_POS.y, SPAWN_POS.z, SPAWN_HDG
end
exports.spawnmanager:spawnPlayer({
x = x, y = y, z = z, heading = h,
model = data.model or 'mp_m_freemode_01',
skipFade = true
}, function()
-- spawnPlayer resets the model, so the face has to go back on afterwards.
applyAppearance(data.appearance)
local ped = PlayerPedId()
FreezeEntityPosition(ped, false)
SetEntityInvincible(ped, false)
SetEntityVisible(ped, true, false)
SetEntityCollision(ped, true, true)
EnableAllControlActions(0)
SetPlayerInvincible(PlayerId(), false)
DoScreenFadeIn(800)
TriggerEvent('chat:addMessage', {
color = { 0, 113, 227 },
multiline = true,
args = { 'Los Santos', ('Welcome back, %s %s.'):format(data.firstName, data.lastName) }
})
end)
end
RegisterNetEvent('fiverp-chars:spawn', function(data)
if not data.ok then
return SendNUIMessage({ action = 'saveResult', payload = { ok = false, error = data.error } })
end
enterCity(data)
end)
-- Persist where the player stood, so the next session resumes there.
CreateThread(function()
while true do
Wait(60000)
if activeChar then
local c = GetEntityCoords(PlayerPedId())
TriggerServerEvent('fiverp-chars:savePosition', {
id = activeChar, x = c.x, y = c.y, z = c.z, heading = GetEntityHeading(PlayerPedId())
})
end
end
end)
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
if isOpen then SetNuiFocus(false, false) end
stopCamera()
end)
-- --------------------------------------------------------------- callbacks
RegisterNUICallback('startEditor', function(data, cb)
inEditor = true
editSlot = data.slot
editId = data.id
zoom = 'head'
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
stagePed()
applyAppearance(data.appearance)
stagePed()
sendLimits()
updateCamera()
cb({ ok = true })
end)
RegisterNUICallback('preview', function(data, cb)
if inEditor then
local before = GetEntityModel(PlayerPedId())
applyAppearance(data)
if GetEntityModel(PlayerPedId()) ~= before then
-- The model swap hands back a fresh ped: re-stage it and re-measure the
-- wardrobe, whose drawable counts differ between the two bodies.
stagePed()
applyAppearance(data)
sendLimits()
end
updateCamera()
end
cb({ ok = true })
end)
RegisterNUICallback('rotate', function(data, cb)
local ped = PlayerPedId()
SetEntityHeading(ped, (GetEntityHeading(ped) + (tonumber(data.by) or 0.0)) % 360.0)
updateCamera()
cb({ ok = true })
end)
RegisterNUICallback('zoom', function(data, cb)
zoom = data.part or 'head'
updateCamera()
cb({ ok = true })
end)
RegisterNUICallback('createChar', function(data, cb)
TriggerServerEvent('fiverp-chars:create', data)
cb({ ok = true })
end)
RegisterNUICallback('finishChar', function(data, cb)
TriggerServerEvent('fiverp-chars:finish', data)
cb({ ok = true })
end)
RegisterNUICallback('deleteChar', function(data, cb)
TriggerServerEvent('fiverp-chars:delete', data)
cb({ ok = true })
end)
RegisterNUICallback('playChar', function(data, cb)
TriggerServerEvent('fiverp-chars:play', data)
cb({ ok = true })
end)
RegisterNUICallback('backToSelect', function(data, cb)
inEditor = false
editSlot, editId = nil, nil
zoom = 'body'
local ped = PlayerPedId()
SetEntityVisible(ped, false, false)
stagePed()
SetEntityVisible(ped, false, false)
updateCamera()
TriggerServerEvent('fiverp-chars:request')
cb({ ok = true })
end)
@@ -0,0 +1,25 @@
fx_version 'cerulean'
game 'gta5'
node_version '22'
name 'fiverp-characters'
author 'FiveRP'
description 'Character slots, appearance creator and spawn for FiveRP'
version '1.0.0'
ui_page 'html/index.html'
client_script 'client/cl_chars.lua'
server_script 'server/sv_chars.js'
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2'
}
dependency 'oxmysql'
dependency 'fiverp-auth'
@@ -0,0 +1,512 @@
/* FiveRP — character slots and appearance creator.
Every control writes into one `look` object; that object is what the ped is
rebuilt from on the client and what the row stores in the database, so the
preview and the saved character can never drift apart. */
const RES = 'fiverp-characters';
function post(name, data) {
return fetch(`https://${RES}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
const $ = (id) => document.getElementById(id);
const root = $('root');
const screens = { select: $('screenSelect'), editor: $('screenEditor') };
// ------------------------------------------------------------------ state
// Parent heads: the game keeps the male faces at the bottom of the list and
// the female ones above them.
const FATHERS = 21; // head ids 0..20
const MOTHERS = 24; // head ids 21..44
const FEATURES = [
'Nose width', 'Nose height', 'Nose length', 'Nose bridge', 'Nose tip',
'Nose bridge shift', 'Brow height', 'Brow width', 'Cheekbone height',
'Cheekbone width', 'Cheek width', 'Eye opening', 'Lip thickness',
'Jaw width', 'Jaw length', 'Chin height', 'Chin length', 'Chin width',
'Chin dimple', 'Neck thickness'
];
let limits = {
overlays: [24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12],
hair: 1, torso: 1, undershirt: 1, legs: 1, shoes: 1
};
let slots = [];
let ctx = { slot: 1, id: null, mode: 'create' };
let tab = 'heritage';
let look = blankLook();
function blankLook() {
return {
model: 'mp_m_freemode_01',
blend: { shapeFirst: 0, shapeSecond: 21, shapeMix: 0.5, skinFirst: 0, skinSecond: 21, skinMix: 0.5 },
features: new Array(20).fill(0),
overlays: Array.from({ length: 12 }, () => ({ v: 255, o: 1, c1: 0, c2: 0 })),
hair: { style: 0, color: 0, highlight: 0 },
eye: 0,
outfit: { torso: 0, undershirt: 0, legs: 0, shoes: 0 }
};
}
// The ped rebuild is cheap but not free; coalesce a slider drag into one call
// per frame-ish so dragging stays smooth.
let pushTimer = null;
function pushLook() {
clearTimeout(pushTimer);
pushTimer = setTimeout(() => post('preview', look), 45);
}
// ----------------------------------------------------------- slot screen
function show(name) {
Object.entries(screens).forEach(([key, el]) => el.classList.toggle('is-active', key === name));
}
function when(value) {
if (!value) return null;
const d = new Date(String(value).replace(' ', 'T'));
if (isNaN(d)) return null;
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
function renderSlots() {
const host = $('slots');
host.innerHTML = '';
slots.forEach((s, i) => {
const card = document.createElement('article');
card.className = 'slot fade-up delay-' + Math.min(i + 1, 3);
if (!s.id) {
card.classList.add('slot--empty');
card.innerHTML = `
<div class="plus">+</div>
<div class="label">Create character</div>
<div class="hint">Slot ${s.slot} is free</div>`;
card.addEventListener('click', () => openEditor({ slot: s.slot, mode: 'create' }));
} else {
const played = when(s.lastPlayed);
card.innerHTML = `
<p class="slot-no">Slot ${s.slot}</p>
<h2 class="slot-name">${s.firstName}<br>${s.lastName}</h2>
<p class="slot-meta">${played ? 'Last seen ' + played : 'Never played'}</p>
<span class="slot-badge ${s.ready ? '' : 'is-draft'}">${s.ready ? 'Resident' : 'Needs appearance'}</span>
<div class="slot-actions"></div>`;
const actions = card.querySelector('.slot-actions');
const primary = document.createElement('button');
primary.className = 'btn-primary';
primary.textContent = s.ready ? 'Enter the city' : 'Finish character';
primary.addEventListener('click', () => {
if (s.ready) post('playChar', { id: s.id });
else openEditor({ slot: s.slot, id: s.id, mode: 'finish', first: s.firstName, last: s.lastName });
});
// Deleting is permanent, so the button asks once before it fires.
const remove = document.createElement('button');
remove.className = 'btn-ghost btn-danger';
remove.textContent = 'Delete';
let armed = false;
remove.addEventListener('click', () => {
if (!armed) {
armed = true;
remove.textContent = 'Confirm delete';
setTimeout(() => { armed = false; remove.textContent = 'Delete'; }, 4000);
return;
}
post('deleteChar', { id: s.id });
});
actions.append(primary, remove);
}
host.appendChild(card);
});
}
// --------------------------------------------------------------- controls
function control(label, opts) {
const wrap = document.createElement('div');
wrap.className = 'ctl';
const top = document.createElement('div');
top.className = 'ctl-top';
const name = document.createElement('span');
name.className = 'ctl-name';
name.textContent = label;
const val = document.createElement('span');
val.className = 'ctl-val';
top.append(name, val);
const input = document.createElement('input');
input.type = 'range';
input.min = opts.min;
input.max = opts.max;
input.step = opts.step || 1;
input.value = opts.get();
const paint = () => { val.textContent = (opts.fmt || String)(Number(input.value)); };
paint();
input.addEventListener('input', () => {
opts.set(Number(input.value));
paint();
pushLook();
});
wrap.append(top, input);
return wrap;
}
function groupLabel(text) {
const p = document.createElement('p');
p.className = 'group-label';
p.textContent = text;
return p;
}
// An overlay slider where the first stop means "none" (the game uses 255).
function overlayControl(host, label, index, colour) {
const count = limits.overlays[index] || 1;
host.appendChild(control(label, {
min: 0, max: count, step: 1,
get: () => (look.overlays[index].v === 255 ? 0 : look.overlays[index].v + 1),
set: (v) => { look.overlays[index].v = v === 0 ? 255 : v - 1; },
fmt: (v) => (v === 0 ? 'None' : String(v))
}));
host.appendChild(control(label + ' — strength', {
min: 0, max: 1, step: 0.05,
get: () => look.overlays[index].o,
set: (v) => { look.overlays[index].o = v; },
fmt: (v) => Math.round(v * 100) + '%'
}));
if (colour) {
host.appendChild(control(label + ' — colour', {
min: 0, max: 63, step: 1,
get: () => look.overlays[index].c1,
set: (v) => { look.overlays[index].c1 = v; look.overlays[index].c2 = v; },
fmt: (v) => '#' + v
}));
}
}
const TABS = [
{
id: 'heritage', name: 'Heritage',
build(host) {
const toggle = document.createElement('div');
toggle.className = 'toggle';
[['mp_m_freemode_01', 'Male'], ['mp_f_freemode_01', 'Female']].forEach(([model, label]) => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
b.className = look.model === model ? 'is-on' : '';
b.addEventListener('click', () => {
if (look.model === model) return;
look.model = model;
// A new body resets the wardrobe indices; the old ones may not exist.
look.outfit = { torso: 0, undershirt: 0, legs: 0, shoes: 0 };
look.hair.style = 0;
post('preview', look);
renderTab();
});
toggle.appendChild(b);
});
host.append(groupLabel('Body'), toggle);
host.appendChild(groupLabel('Parents'));
host.appendChild(control('Father', {
min: 0, max: FATHERS - 1,
get: () => look.blend.shapeFirst,
set: (v) => { look.blend.shapeFirst = v; look.blend.skinFirst = v; },
fmt: (v) => String(v + 1)
}));
host.appendChild(control('Mother', {
min: 21, max: 21 + MOTHERS - 1,
get: () => look.blend.shapeSecond,
set: (v) => { look.blend.shapeSecond = v; look.blend.skinSecond = v; },
fmt: (v) => String(v - 20)
}));
host.appendChild(control('Resemblance', {
min: 0, max: 1, step: 0.02,
get: () => look.blend.shapeMix,
set: (v) => { look.blend.shapeMix = v; },
fmt: (v) => v <= 0.5
? 'Father ' + Math.round((1 - v) * 100) + '%'
: 'Mother ' + Math.round(v * 100) + '%'
}));
host.appendChild(control('Skin tone', {
min: 0, max: 1, step: 0.02,
get: () => look.blend.skinMix,
set: (v) => { look.blend.skinMix = v; },
fmt: (v) => Math.round(v * 100) + '%'
}));
}
},
{
id: 'face', name: 'Face',
build(host) {
host.appendChild(groupLabel('Structure'));
FEATURES.forEach((label, i) => {
host.appendChild(control(label, {
min: -1, max: 1, step: 0.05,
get: () => look.features[i],
set: (v) => { look.features[i] = v; },
fmt: (v) => (v > 0 ? '+' : '') + v.toFixed(2)
}));
});
}
},
{
id: 'hair', name: 'Hair',
build(host) {
host.appendChild(groupLabel('Hair'));
host.appendChild(control('Style', {
min: 0, max: Math.max(0, limits.hair - 1),
get: () => look.hair.style,
set: (v) => { look.hair.style = v; },
fmt: (v) => (v === 0 ? 'Bald' : String(v))
}));
host.appendChild(control('Colour', {
min: 0, max: 63,
get: () => look.hair.color,
set: (v) => { look.hair.color = v; },
fmt: (v) => '#' + v
}));
host.appendChild(control('Highlights', {
min: 0, max: 63,
get: () => look.hair.highlight,
set: (v) => { look.hair.highlight = v; },
fmt: (v) => '#' + v
}));
host.appendChild(groupLabel('Eyebrows'));
overlayControl(host, 'Eyebrows', 2, 'hair');
host.appendChild(groupLabel('Facial hair'));
overlayControl(host, 'Beard', 1, 'hair');
host.appendChild(groupLabel('Body hair'));
overlayControl(host, 'Chest hair', 10, 'hair');
}
},
{
id: 'skin', name: 'Skin',
build(host) {
host.appendChild(groupLabel('Complexion'));
overlayControl(host, 'Complexion', 6, null);
overlayControl(host, 'Blemishes', 0, null);
overlayControl(host, 'Ageing', 3, null);
overlayControl(host, 'Sun damage', 7, null);
overlayControl(host, 'Freckles', 9, null);
overlayControl(host, 'Body blemishes', 11, null);
host.appendChild(groupLabel('Eyes'));
host.appendChild(control('Eye colour', {
min: 0, max: 31,
get: () => look.eye,
set: (v) => { look.eye = v; },
fmt: (v) => '#' + v
}));
host.appendChild(groupLabel('Makeup'));
overlayControl(host, 'Makeup', 4, 'makeup');
overlayControl(host, 'Blush', 5, 'makeup');
overlayControl(host, 'Lipstick', 8, 'makeup');
}
},
{
id: 'style', name: 'Clothes',
build(host) {
host.appendChild(groupLabel('Outfit'));
const rows = [
['Top', 'torso', 'torso'],
['Undershirt', 'undershirt', 'undershirt'],
['Legs', 'legs', 'legs'],
['Shoes', 'shoes', 'shoes']
];
rows.forEach(([label, key, limitKey]) => {
host.appendChild(control(label, {
min: 0, max: Math.max(0, (limits[limitKey] || 1) - 1),
get: () => look.outfit[key],
set: (v) => { look.outfit[key] = v; },
fmt: (v) => String(v + 1)
}));
});
}
},
{
id: 'identity', name: 'Identity',
build(host) {
host.appendChild(groupLabel('Legal name'));
const row = document.createElement('div');
row.className = 'field-row';
[['first', 'First name', 'John'], ['last', 'Last name', 'Doe']].forEach(([key, label, hint]) => {
const cell = document.createElement('div');
const lab = document.createElement('label');
lab.className = 'field-label';
lab.textContent = label;
const input = document.createElement('input');
input.className = 'field';
input.id = 'name-' + key;
input.maxLength = 24;
input.placeholder = hint;
input.value = ctx[key] || '';
input.disabled = ctx.mode === 'finish';
input.addEventListener('input', () => { ctx[key] = input.value.trim(); });
cell.append(lab, input);
row.appendChild(cell);
});
host.appendChild(row);
const note = document.createElement('p');
note.className = 'foot-note';
note.style.marginTop = '12px';
note.textContent = ctx.mode === 'finish'
? 'This name was registered with your passport and cannot be changed.'
: 'English letters only. This is the name Los Santos will know you by.';
host.appendChild(note);
}
}
];
function renderTabs() {
const host = $('tabs');
host.innerHTML = '';
TABS.forEach((t) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tab' + (t.id === tab ? ' is-on' : '');
b.textContent = t.name;
b.addEventListener('click', () => { tab = t.id; renderTabs(); renderTab(); });
host.appendChild(b);
});
}
function renderTab() {
const host = $('controls');
host.innerHTML = '';
host.scrollTop = 0;
const def = TABS.find((t) => t.id === tab) || TABS[0];
def.build(host);
renderTabs();
}
// ---------------------------------------------------------------- editor
function openEditor(options) {
ctx = {
slot: options.slot,
id: options.id || null,
mode: options.mode,
first: options.first || '',
last: options.last || ''
};
look = blankLook();
tab = 'heritage';
notice('');
$('editorSlot').textContent = 'Slot ' + ctx.slot;
$('btnSave').textContent = 'Save & enter city';
show('editor');
renderTab();
post('startEditor', { slot: ctx.slot, id: ctx.id, appearance: look });
}
function notice(text) {
const el = $('notice');
el.textContent = text || '';
el.classList.toggle('is-shown', !!text);
}
const RE_NAME = /^[A-Za-z]{2,24}$/;
function save() {
if (ctx.mode === 'create') {
if (!RE_NAME.test(ctx.first || '') || !RE_NAME.test(ctx.last || '')) {
tab = 'identity';
renderTab();
return notice('Enter a first and last name — 2 to 24 English letters each.');
}
}
notice('');
$('btnSave').disabled = true;
if (ctx.mode === 'finish') post('finishChar', { id: ctx.id, appearance: look });
else post('createChar', { slot: ctx.slot, firstName: ctx.first, lastName: ctx.last, appearance: look });
}
// Randomise heritage and the obvious surface details, leaving the 20 face
// sliders alone — random values there produce a melted face, not a person.
function randomise() {
const r = (n) => Math.floor(Math.random() * n);
look.blend.shapeFirst = look.blend.skinFirst = r(FATHERS);
look.blend.shapeSecond = look.blend.skinSecond = 21 + r(MOTHERS);
look.blend.shapeMix = Math.round(Math.random() * 50) / 50;
look.blend.skinMix = Math.round(Math.random() * 50) / 50;
look.hair.style = r(Math.max(1, limits.hair));
look.hair.color = look.hair.highlight = r(24);
look.eye = r(12);
look.overlays[2].v = r(limits.overlays[2] || 1); // eyebrows
look.overlays[2].c1 = look.overlays[2].c2 = look.hair.color;
look.outfit.torso = r(Math.max(1, limits.torso));
look.outfit.legs = r(Math.max(1, limits.legs));
look.outfit.shoes = r(Math.max(1, limits.shoes));
post('preview', look);
renderTab();
}
$('btnSave').addEventListener('click', save);
$('btnRandom').addEventListener('click', randomise);
$('btnCancel').addEventListener('click', () => {
show('select');
post('backToSelect', {});
});
$('rotLeft').addEventListener('click', () => post('rotate', { by: -20 }));
$('rotRight').addEventListener('click', () => post('rotate', { by: 20 }));
document.querySelectorAll('.chip[data-zoom]').forEach((chip) => {
chip.addEventListener('click', () => {
document.querySelectorAll('.chip[data-zoom]').forEach((c) => c.classList.remove('is-on'));
chip.classList.add('is-on');
post('zoom', { part: chip.dataset.zoom });
});
});
// ------------------------------------------------------------ from client
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'open') {
root.classList.add('is-open');
show(msg.view || 'select');
} else if (msg.action === 'close') {
root.classList.remove('is-open');
} else if (msg.action === 'slots') {
slots = msg.payload || [];
renderSlots();
} else if (msg.action === 'limits') {
limits = Object.assign(limits, msg.payload || {});
if (screens.editor.classList.contains('is-active')) renderTab();
} else if (msg.action === 'saveResult') {
const p = msg.payload || {};
$('btnSave').disabled = false;
if (p.ok) {
// Straight into the world with the character just written to the row.
post('playChar', { id: p.id || ctx.id });
} else {
notice(p.error || 'Something went wrong. Try again.');
}
}
});
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FiveRP — Characters</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="root" id="root">
<!-- ------------------------------------------------ character slots -->
<section class="screen screen--select" id="screenSelect">
<header class="head fade-up">
<p class="eyebrow">Los Santos &middot; Residency Office</p>
<h1 class="title">Choose your character</h1>
<p class="sub">Three residency slots are available on this account.</p>
</header>
<div class="slots" id="slots"></div>
<p class="foot-note fade-up delay-3">Deleting a resident is permanent — the slot is freed for a new file.</p>
</section>
<!-- ---------------------------------------------- appearance editor -->
<section class="screen screen--editor" id="screenEditor">
<aside class="panel">
<header class="panel-head">
<p class="eyebrow" id="editorSlot">Slot 1</p>
<h2 class="panel-title">Create your resident</h2>
</header>
<nav class="tabs" id="tabs"></nav>
<div class="controls" id="controls"></div>
<p class="notice" id="notice"></p>
<footer class="panel-foot">
<button class="btn-ghost" id="btnCancel" type="button">Back</button>
<button class="btn-ghost btn-dice" id="btnRandom" type="button">Randomise</button>
<button class="btn-primary" id="btnSave" type="button">Save &amp; enter city</button>
</footer>
</aside>
<div class="stagebar">
<button class="round" id="rotLeft" type="button" aria-label="Rotate left">&#8249;</button>
<div class="chips">
<button class="chip is-on" data-zoom="head" type="button">Head</button>
<button class="chip" data-zoom="body" type="button">Body</button>
<button class="chip" data-zoom="legs" type="button">Legs</button>
</div>
<button class="round" id="rotRight" type="button" aria-label="Rotate right">&#8250;</button>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,297 @@
/* FiveRP — character slots and appearance creator.
Follows CLAUDE.md. Two hard rules from that document apply everywhere here:
no backdrop-filter and no box-shadow. CEF composites both as opaque black
plates the size of the element, which is the black-square artefact. Depth
comes from opacity, hairlines and inner borders instead. */
@font-face {
font-family: 'Unbounded';
src: url('fonts/unbounded-latin.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+2122, U+2191, U+2193, U+2212;
}
@font-face {
font-family: 'Unbounded';
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0100-02AF, U+0304, U+0308, U+1E00-1EFF, U+2020, U+20A0-20AB;
}
@font-face {
font-family: 'Unbounded';
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--surface: rgba(255, 255, 255, 0.95);
--surface-soft: rgba(255, 255, 255, 0.88);
--fg: #1d1d1f;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong: #0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--field-bg: rgba(0, 0, 0, 0.04);
--danger: #ff3b30;
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
color-scheme: light;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: transparent; /* the game is the backdrop */
overflow: hidden;
font-family: 'Unbounded', -apple-system, 'Segoe UI', sans-serif;
color: var(--fg);
-webkit-font-smoothing: antialiased;
user-select: none;
}
.root { position: fixed; inset: 0; display: none; }
.root.is-open { display: block; }
.screen { position: absolute; inset: 0; display: none; }
.screen.is-active { display: flex; }
/* --------------------------------------------------------- shared bits */
.eyebrow {
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.title {
font-size: 30px; font-weight: 700; letter-spacing: -0.035em;
line-height: 1.1; margin-top: 10px;
}
.sub { margin-top: 8px; font-size: 13px; font-weight: 400; color: var(--fg-secondary); }
.btn-primary {
border: none; border-radius: 999px; padding: 11px 22px;
font-family: inherit; font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
color: #fff; cursor: pointer;
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
transition: filter .2s var(--ease), transform .2s var(--ease);
}
.btn-primary:hover { filter: brightness(1.08); }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: .45; pointer-events: none; }
.btn-ghost {
border: none; border-radius: 999px; padding: 11px 18px;
font-family: inherit; font-size: 13px; font-weight: 600;
color: var(--accent); background-color: var(--accent-soft); cursor: pointer;
transition: background-color .2s var(--ease);
}
.btn-ghost:hover { background-color: rgba(0, 113, 227, 0.14); }
.btn-danger { color: var(--danger); background-color: rgba(255, 59, 48, 0.08); }
.btn-danger:hover { background-color: rgba(255, 59, 48, 0.15); }
/* ---------------------------------------------------------- slot screen */
.screen--select { flex-direction: column; align-items: center; justify-content: center; gap: 34px; }
.head { text-align: center; }
.slots { display: flex; gap: 16px; }
.slot {
position: relative;
width: 268px; min-height: 330px;
display: flex; flex-direction: column;
padding: 26px 22px 22px;
border-radius: var(--radius-lg);
border: 1px solid var(--line-strong);
background: var(--surface);
transition: transform .35s var(--ease), border-color .35s var(--ease);
}
.slot:hover { transform: translateY(-4px); border-color: rgba(0, 0, 0, 0.14); }
.slot-no {
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.slot-name {
margin-top: 14px;
font-size: 21px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2;
}
.slot-meta { margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--fg-secondary); }
.slot-badge {
display: inline-block; margin-top: 12px; align-self: flex-start;
padding: 4px 11px; border-radius: 999px;
font-size: 10px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--accent); background: var(--accent-soft);
}
.slot-badge.is-draft { color: #b25000; background: rgba(255, 149, 0, 0.12); }
.slot-actions { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
.slot-actions button { width: 100%; }
/* Empty slot: one big invitation, centred. */
.slot--empty {
align-items: center; justify-content: center; text-align: center;
gap: 14px; cursor: pointer;
background: var(--surface-soft);
border-style: dashed; border-color: rgba(0, 0, 0, 0.14);
}
.slot--empty:hover { border-color: var(--accent); background: rgba(255, 255, 255, 0.97); }
.plus {
width: 54px; height: 54px; border-radius: 50%;
display: grid; place-items: center;
font-size: 26px; font-weight: 300; line-height: 1;
color: var(--accent); background: var(--accent-soft);
}
.slot--empty .label { font-size: 14px; font-weight: 600; letter-spacing: -0.02em; }
.slot--empty .hint { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
.foot-note { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
/* -------------------------------------------------------- editor screen */
.screen--editor { pointer-events: none; } /* the ped stays clickable-through */
.screen--editor .panel,
.screen--editor .stagebar { pointer-events: auto; }
.panel {
position: absolute; top: 24px; right: 24px; bottom: 24px;
width: 372px;
display: flex; flex-direction: column;
padding: 24px 0 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--line-strong);
background: var(--surface);
}
.panel-head { padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.panel-title { margin-top: 8px; font-size: 20px; font-weight: 700; letter-spacing: -0.03em; }
.tabs {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 14px 24px; border-bottom: 1px solid var(--line);
}
.tab {
border: none; border-radius: 999px; padding: 7px 13px;
font-family: inherit; font-size: 10px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
transition: color .2s var(--ease), background-color .2s var(--ease);
}
.tab:hover { color: var(--fg); }
.tab.is-on { color: #fff; background: var(--accent); }
.controls { flex: 1; overflow-y: auto; padding: 18px 24px 8px; }
.controls::-webkit-scrollbar { width: 5px; }
.controls::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.14); border-radius: 3px; }
.group-label {
margin: 16px 0 10px;
font-size: 10px; font-weight: 600; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.group-label:first-child { margin-top: 0; }
.ctl { margin-bottom: 13px; }
.ctl-top {
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px;
}
.ctl-name { font-size: 11px; font-weight: 500; letter-spacing: -0.01em; color: var(--fg); }
.ctl-val { font-size: 10px; font-weight: 600; color: var(--fg-tertiary); font-variant-numeric: tabular-nums; }
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
background: rgba(0, 0, 0, 0.09); cursor: pointer; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
background: #fff; border: 1px solid rgba(0, 0, 0, 0.16);
transition: transform .15s var(--ease), border-color .15s var(--ease);
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.12); border-color: var(--accent); }
/* Two-state toggle (male / female) */
.toggle { display: flex; gap: 6px; margin-bottom: 16px; }
.toggle button {
flex: 1; border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
padding: 10px; font-family: inherit; font-size: 12px; font-weight: 600;
letter-spacing: -0.01em; color: var(--fg-secondary);
background: rgba(255, 255, 255, 0.7); cursor: pointer;
transition: all .2s var(--ease);
}
.toggle button.is-on { color: #fff; background: var(--accent); border-color: var(--accent); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field-label {
display: block; margin-bottom: 6px;
font-size: 10px; font-weight: 600; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.field {
width: 100%; border: 1px solid transparent; border-radius: var(--radius-sm);
padding: 11px 12px; font-family: inherit; font-size: 13px; font-weight: 500;
color: var(--fg); background: var(--field-bg); outline: none;
transition: background-color .2s, border-color .2s;
}
.field::placeholder { color: var(--fg-tertiary); font-weight: 400; }
.field:focus { background: #fff; border-color: var(--accent); }
.notice {
margin: 0 24px; min-height: 0; font-size: 11px; font-weight: 500; color: var(--danger);
opacity: 0; transition: opacity .2s var(--ease);
}
.notice.is-shown { opacity: 1; margin-bottom: 10px; }
.panel-foot {
display: flex; gap: 8px; padding: 16px 24px 0;
border-top: 1px solid var(--line);
}
.panel-foot .btn-primary { flex: 1; }
.btn-dice { padding: 11px 14px; }
/* Camera controls, centred under the ped. */
.stagebar {
position: absolute; left: calc(50% - 210px); bottom: 40px;
transform: translateX(-50%);
display: flex; align-items: center; gap: 10px;
padding: 8px; border-radius: 999px;
border: 1px solid var(--line-strong); background: var(--surface);
}
.round {
width: 34px; height: 34px; border: none; border-radius: 50%;
font-family: inherit; font-size: 17px; font-weight: 500; line-height: 1;
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
transition: color .2s, background-color .2s;
}
.round:hover { color: #fff; background: var(--accent); }
.chips { display: flex; gap: 4px; }
.chip {
border: none; border-radius: 999px; padding: 8px 14px;
font-family: inherit; font-size: 10px; font-weight: 600;
letter-spacing: 0.09em; text-transform: uppercase;
color: var(--fg-secondary); background: transparent; cursor: pointer;
transition: color .2s, background-color .2s;
}
.chip:hover { color: var(--fg); }
.chip.is-on { color: #fff; background: var(--accent); }
/* ---------------------------------------------------------------- motion */
.fade-up { animation: fadeUp .6s var(--ease) both; }
.delay-1 { animation-delay: .06s; }
.delay-2 { animation-delay: .12s; }
.delay-3 { animation-delay: .18s; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
transition-duration: .001ms !important;
}
}
@@ -0,0 +1,18 @@
fx_version 'cerulean'
game 'gta5'
name 'fiverp-loadscreen'
author 'FiveRP'
description 'Loading screen for FiveRP'
version '1.0.0'
loadscreen 'html/index.html'
files {
'html/index.html',
'html/style.css',
'html/app.js',
'html/fonts/unbounded-latin.woff2',
'html/fonts/unbounded-latin-ext.woff2',
'html/fonts/unbounded-cyrillic.woff2',
}
@@ -0,0 +1,133 @@
/* FiveRP — loading screen behaviour.
The game drives this page by posting messages at `window`. The only field
that is a genuine 0..1 measure of progress is `loadProgress.loadFraction`;
everything else is narration we turn into the phase and detail lines. */
(function () {
'use strict';
const elPct = document.getElementById('pct');
const elFill = document.getElementById('fill');
const elPhase = document.getElementById('phase');
const elDetail = document.getElementById('detail');
const elTip = document.getElementById('tip');
/* ------------------------------------------------------------- progress */
// Shown and target values are kept apart so the number eases toward the
// truth instead of jumping. The game reports progress in coarse steps.
let shown = 0;
let target = 0;
function setTarget(fraction) {
const pct = Math.max(0, Math.min(100, fraction * 100));
// Never walk backwards: a later phase reporting a lower fraction would
// otherwise make the bar visibly retreat.
if (pct > target) target = pct;
}
function tick() {
const gap = target - shown;
if (gap > 0.05) {
shown += Math.max(gap * 0.08, 0.05);
if (shown > target) shown = target;
elFill.style.width = shown.toFixed(2) + '%';
elPct.textContent = Math.floor(shown);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
/* ---------------------------------------------------------------- phases */
function phase(text) {
if (elPhase.textContent !== text) elPhase.textContent = text;
}
function detail(text) {
const clean = String(text || '').trim();
if (!clean) return;
elDetail.textContent = clean.length > 88 ? clean.slice(0, 87) + '…' : clean;
}
// Init function names arrive as engine internals ("CPathServer::InitSession").
// Strip the noise so the line reads as a status, not a stack trace.
function humanise(name) {
return String(name || '')
.replace(/^dlc_?/i, 'DLC ')
.replace(/::.*$/, '')
.replace(/^C(?=[A-Z])/, '')
.replace(/([a-z])([A-Z])/g, '$1 $2');
}
window.addEventListener('message', function (event) {
const data = event.data || {};
switch (data.eventName) {
case 'loadProgress':
setTarget(Number(data.loadFraction) || 0);
break;
case 'startInitFunctionOrder':
phase('Loading game');
break;
case 'startInitFunction':
phase('Loading game');
break;
case 'initFunctionInvoking':
phase('Loading game');
detail(humanise(data.name));
// The engine gives idx/count per order; use it when loadProgress is
// quiet so the bar keeps creeping during long orders.
if (data.count) setTarget(0.1 + 0.5 * (Number(data.idx) / Number(data.count)));
break;
case 'startDataFileEntries':
phase('Streaming content');
detail('Mounting ' + (data.count || 0) + ' data files');
break;
case 'performMappedDataFileEntry':
phase('Streaming content');
detail(String(data.name || '').split('/').pop());
break;
case 'onLogLine':
detail(data.message);
break;
case 'onDataFileEntry':
phase('Streaming content');
break;
}
});
/* ------------------------------------------------------------------ tips */
const TIPS = [
'Your account and your character are separate — one login can hold your identity for good.',
'Names are issued once. Pick a first and last name you want to be known by.',
'Press F8 at any time to open the console if something looks wrong.',
'Your passport is proof of residency — officers may ask to see it.',
'Use a real email address. It is the only way to recover an account.',
'Speak in character. The city is more fun when everyone stays in the story.',
'Lost? The Legion Square spawn is the heart of downtown Los Santos.'
];
let tipIndex = 0;
setInterval(function () {
tipIndex = (tipIndex + 1) % TIPS.length;
elTip.classList.add('is-swapping');
setTimeout(function () {
elTip.textContent = TIPS[tipIndex];
elTip.classList.remove('is-swapping');
}, 450);
}, 7000);
/* Nothing has reported in yet — creep to a believable floor so the screen
never sits at a dead 0% while the engine warms up. */
setTimeout(function () { setTarget(0.04); }, 400);
})();
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Slow drifting colour behind frosted white. Nothing here is interactive. -->
<div class="sky" aria-hidden="true">
<span class="blob blob-a"></span>
<span class="blob blob-b"></span>
<span class="blob blob-c"></span>
<span class="blob blob-d"></span>
</div>
<div class="veil" aria-hidden="true"></div>
<main class="stage">
<div class="mark fade-up">
<span class="mark-glyph">FR</span>
</div>
<p class="eyebrow fade-up delay-1">San Andreas &middot; Los Santos</p>
<h1 class="wordmark fade-up delay-1">FiveRP</h1>
<p class="tagline fade-up delay-2">Preparing the city for your arrival</p>
<section class="meter fade-up delay-3" aria-label="Loading progress">
<div class="meter-top">
<span class="phase" id="phase">Starting up</span>
<span class="pct"><span id="pct">0</span><i>%</i></span>
</div>
<div class="track">
<div class="fill" id="fill"></div>
</div>
<p class="detail" id="detail">Waiting for the game to hand over&hellip;</p>
</section>
</main>
<footer class="dock">
<div class="tip fade-up delay-4">
<span class="tip-label">Tip</span>
<span class="tip-body" id="tip">Your account and your character are separate — one login can hold your identity for good.</span>
</div>
<div class="badge fade-up delay-4">
<span class="dot"></span>
<span>192.227.168.73<i>:30120</i></span>
</div>
</footer>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,346 @@
/* FiveRP — loading screen.
Design system: /opt/fivem/server-data/CLAUDE.md
This page owns the whole screen (no game behind it), so unlike the in-game
panels it is allowed an opaque background and real shadows. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: #f5f5f7;
--bg-deep: #e8e8ed;
--surface: rgba(255, 255, 255, 0.72);
--surface-solid: #ffffff;
--fg: #1d1d1f;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong: #0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
'Liberation Mono', 'DejaVu Sans Mono', monospace;
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
color-scheme: light;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
cursor: none;
}
/* ---------------------------------------------------------------------- sky */
/* Four wide, heavily blurred washes drifting on long loops. Kept pale so the
page still reads as white — colour is a hint, not a gradient poster. */
.sky { position: fixed; inset: -20%; z-index: 0; filter: blur(90px); }
.blob {
position: absolute;
display: block;
border-radius: 50%;
will-change: transform;
}
.blob-a {
width: 46vw; height: 46vw; top: 4%; left: 8%;
background: radial-gradient(circle, rgba(0,113,227,0.30), rgba(0,113,227,0) 70%);
animation: driftA 34s var(--ease) infinite alternate;
}
.blob-b {
width: 40vw; height: 40vw; top: 32%; left: 52%;
background: radial-gradient(circle, rgba(90,200,250,0.32), rgba(90,200,250,0) 70%);
animation: driftB 41s var(--ease) infinite alternate;
}
.blob-c {
width: 34vw; height: 34vw; top: 56%; left: 18%;
background: radial-gradient(circle, rgba(201,182,255,0.28), rgba(201,182,255,0) 70%);
animation: driftC 47s var(--ease) infinite alternate;
}
.blob-d {
width: 30vw; height: 30vw; top: 6%; left: 68%;
background: radial-gradient(circle, rgba(255,214,165,0.30), rgba(255,214,165,0) 70%);
animation: driftD 38s var(--ease) infinite alternate;
}
@keyframes driftA { to { transform: translate3d(9vw, 7vh, 0) scale(1.14); } }
@keyframes driftB { to { transform: translate3d(-11vw, -6vh, 0) scale(1.08); } }
@keyframes driftC { to { transform: translate3d(7vw, -9vh, 0) scale(1.18); } }
@keyframes driftD { to { transform: translate3d(-8vw, 8vh, 0) scale(1.10); } }
/* A milky sheet over the colour: this is what turns four blobs into frosted
white rather than a lava lamp. */
.veil {
position: fixed;
inset: 0;
z-index: 1;
background:
radial-gradient(110% 85% at 50% 42%, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0.72) 46%, rgba(255,255,255,0.88) 100%),
linear-gradient(180deg, rgba(245,245,247,0.20), rgba(232,232,237,0.42));
}
/* -------------------------------------------------------------------- stage */
.stage {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 32px 96px;
text-align: center;
}
/* App-icon style badge: the one saturated object on an otherwise white page. */
.mark {
width: 84px;
height: 84px;
display: grid;
place-items: center;
border-radius: 24px;
background-image: linear-gradient(160deg, #35a0ff 0%, var(--accent) 52%, #0059b8 100%);
margin-bottom: 26px;
}
.mark-glyph {
font-size: 27px;
font-weight: 800;
letter-spacing: -0.045em;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.22);
}
.eyebrow {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.20em;
text-transform: uppercase;
color: var(--fg-tertiary);
margin-bottom: 14px;
}
.wordmark {
font-size: clamp(52px, 6.4vw, 84px);
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
color: var(--fg);
margin-bottom: 16px;
}
.tagline {
font-size: 14px;
font-weight: 400;
color: var(--fg-secondary);
margin-bottom: 46px;
}
/* -------------------------------------------------------------------- meter */
/* Left-aligned: the phase and detail lines change constantly, and centred text
that reflows on every update reads as jitter. */
.meter { width: min(560px, 84vw); text-align: left; }
.meter-top {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12px;
}
.phase {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--fg-secondary);
}
.pct {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.045em;
color: var(--fg);
font-variant-numeric: tabular-nums;
}
.pct i {
font-style: normal;
font-size: 15px;
font-weight: 600;
color: var(--fg-tertiary);
margin-left: 3px;
}
.track {
position: relative;
height: 8px;
border-radius: 999px;
/* Inset rather than flat grey: on a near-white page a 7%-black bar reads as
a smudge, a hairline-bordered groove reads as a control. */
background: rgba(0, 0, 0, 0.055);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
border-radius: 999px;
background-image: linear-gradient(90deg, #5ac8fa, var(--accent));
transition: width 520ms var(--ease);
}
/* Travelling highlight so a stalled bar still looks alive. */
.fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg,
rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%);
transform: translateX(-100%);
animation: sweep 1.9s var(--ease) infinite;
}
@keyframes sweep { to { transform: translateX(100%); } }
.detail {
margin-top: 14px;
height: 14px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0;
color: var(--fg-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity .3s ease;
}
/* --------------------------------------------------------------------- dock */
.dock {
position: fixed;
z-index: 2;
left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 32px;
}
.tip {
display: flex;
align-items: center;
gap: 12px;
max-width: 62vw;
padding: 12px 18px 12px 12px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line-strong);
}
.tip-label {
flex: none;
padding: 5px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.tip-body {
font-size: 12.5px;
font-weight: 400;
line-height: 1.5;
color: var(--fg-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity .45s ease, transform .45s var(--ease);
}
.tip-body.is-swapping { opacity: 0; transform: translateY(-6px); }
.badge {
flex: none;
display: flex;
align-items: center;
gap: 9px;
padding: 11px 18px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line-strong);
font-family: var(--mono);
font-size: 11.5px;
color: var(--fg-secondary);
letter-spacing: 0;
}
.badge i { font-style: normal; color: var(--fg-tertiary); }
.badge .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--success, #34c759);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .45; } }
/* ------------------------------------------------------------------- motion */
.fade-up { animation: fadeUp .7s cubic-bezier(.22,1,.36,1) both; }
.delay-1 { animation-delay: .07s; }
.delay-2 { animation-delay: .14s; }
.delay-3 { animation-delay: .21s; }
.delay-4 { animation-delay: .30s; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}
-24
View File
@@ -1,24 +0,0 @@
-- ---------------------------------------------------------------------------
-- Client mirror of this player's character state.
-- Read-only: the server pushes it, nothing here writes back.
-- ---------------------------------------------------------------------------
Core = Core or {}
local character = nil
RegisterNetEvent('rp:core:character', function(data)
character = data
TriggerEvent('rp:core:characterChanged', character)
end)
--- Current character, or nil while the player is still at the auth screens.
function Core.Character()
return character
end
function Core.HasCharacter()
return character ~= nil
end
exports('getCharacter', Core.Character)
-32
View File
@@ -1,32 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_core'
description 'Framework core: config, validation, player registry, request bridge'
author 'Los Santos RP'
version '1.0.0'
shared_scripts {
'shared/config.lua',
'shared/util.lua',
'shared/appearance.lua',
}
server_scripts {
'@rp_db/lib/db.lua',
'server/crypto.js',
'server/player.lua',
}
client_scripts {
'client/state.lua',
}
-- Consumed by other resources via '@rp_core/...'
files {
'shared/config.lua',
'shared/util.lua',
'shared/appearance.lua',
'lib/callbacks_client.lua',
'lib/callbacks_server.lua',
}
@@ -1,46 +0,0 @@
-- ---------------------------------------------------------------------------
-- Client half of the request/response bridge.
-- Include with: client_scripts { '@rp_core/lib/callbacks_client.lua', ... }
--
-- Usage (inside a thread):
-- local result, err = Core.Callback('rp_session', 'auth:login', { ... })
--
-- Reply events are namespaced by the *calling* resource, so two resources
-- can never resolve each other's tokens.
-- ---------------------------------------------------------------------------
Core = Core or {}
local RES = GetCurrentResourceName()
local pending = {}
local seq = 0
RegisterNetEvent('rp:cb:res:' .. RES, function(token, result, err)
local p = pending[token]
if not p then return end -- already timed out
pending[token] = nil
p:resolve({ result = result, err = err })
end)
--- Blocking request to a server callback. Returns result, err.
--- Always returns; a lost reply surfaces as an error rather than a hang.
function Core.Callback(targetResource, name, payload, timeoutMs)
seq = seq + 1
local token = seq
local p = promise.new()
pending[token] = p
TriggerServerEvent('rp:cb:' .. targetResource, RES, name, token, payload)
CreateThread(function()
Wait(timeoutMs or 15000)
local waiting = pending[token]
if waiting then
pending[token] = nil
waiting:resolve({ result = nil, err = 'no response from ' .. targetResource .. '/' .. name })
end
end)
local r = Citizen.Await(p)
return r.result, r.err
end
@@ -1,78 +0,0 @@
-- ---------------------------------------------------------------------------
-- Server half of the request/response bridge.
-- Include with: server_scripts { '@rp_core/lib/callbacks_server.lua', ... }
--
-- Core.RegisterCallback('auth:login', function(src, payload)
-- return result, err -- err ~= nil is reported to the client
-- end)
--
-- Handlers run in their own thread so they may block on the database.
-- Every handler is rate limited per player; a client that floods is ignored
-- rather than allowed to queue unbounded database work.
-- ---------------------------------------------------------------------------
Core = Core or {}
local RES = GetCurrentResourceName()
local handlers = {}
local buckets = {} -- [src] = { tokens = n, last = ms }
local BUCKET_MAX = 15 -- burst
local BUCKET_RATE = 5 -- refilled per second
local function allow(src)
local now = GetGameTimer()
local b = buckets[src]
if not b then
b = { tokens = BUCKET_MAX, last = now }
buckets[src] = b
end
local elapsed = (now - b.last) / 1000
b.last = now
b.tokens = math.min(BUCKET_MAX, b.tokens + elapsed * BUCKET_RATE)
if b.tokens < 1 then return false end
b.tokens = b.tokens - 1
return true
end
AddEventHandler('playerDropped', function()
buckets[source] = nil
end)
function Core.RegisterCallback(name, fn)
if handlers[name] then
print(('^3[rp_core]^7 callback %q registered twice in %s'):format(name, RES))
end
handlers[name] = fn
end
RegisterNetEvent('rp:cb:' .. RES, function(fromResource, name, token, payload)
local src = source
-- A client controls fromResource/token, so they are only ever echoed back to
-- that same client. They are never used to look anything up on the server.
if type(fromResource) ~= 'string' or type(name) ~= 'string' then return end
local reply = function(result, err)
TriggerClientEvent('rp:cb:res:' .. fromResource, src, token, result, err)
end
if not allow(src) then
return reply(nil, 'slow down')
end
local fn = handlers[name]
if not fn then
print(('^3[rp_core]^7 %s: unknown callback %q from %d'):format(RES, name, src))
return reply(nil, 'unknown request')
end
CreateThread(function()
local ok, result, err = pcall(fn, src, payload)
if not ok then
print(('^1[rp_core]^7 callback %q errored: %s'):format(name, tostring(result)))
return reply(nil, 'internal error')
end
reply(result, err)
end)
end)
-62
View File
@@ -1,62 +0,0 @@
// ---------------------------------------------------------------------------
// Password hashing for rp_auth.
//
// scrypt with per-password random salt. The cost parameters are stored inside
// the hash string, so they can be raised later without invalidating existing
// accounts - verify() always uses the parameters the hash was made with.
//
// Format: scrypt$N$r$p$<salt b64>$<derived key b64>
// ---------------------------------------------------------------------------
const crypto = require('crypto');
const N = 16384; // CPU/memory cost
const R = 8; // block size
const P = 1; // parallelisation
const KEYLEN = 64;
const MAXMEM = 96 * 1024 * 1024; // scrypt needs ~128*N*r bytes = 16MB here
function derive(password, salt, n, r, p, cb) {
crypto.scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: MAXMEM }, cb);
}
global.exports('hashPassword', (password, cb) => {
if (typeof password !== 'string' || password.length === 0 || password.length > 200) {
return cb(null, 'invalid password');
}
const salt = crypto.randomBytes(16);
derive(password, salt, N, R, P, (err, dk) => {
if (err) return cb(null, err.message);
cb(`scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${dk.toString('base64')}`, null);
});
});
global.exports('verifyPassword', (password, stored, cb) => {
if (typeof password !== 'string' || typeof stored !== 'string') return cb(false, null);
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return cb(false, 'unrecognised hash format');
const n = parseInt(parts[1], 10);
const r = parseInt(parts[2], 10);
const p = parseInt(parts[3], 10);
if (!n || !r || !p) return cb(false, 'corrupt hash parameters');
let salt, expected;
try {
salt = Buffer.from(parts[4], 'base64');
expected = Buffer.from(parts[5], 'base64');
} catch (e) {
return cb(false, 'corrupt hash encoding');
}
derive(password, salt, n, r, p, (err, dk) => {
if (err) return cb(false, err.message);
// constant time: a wrong password must not be distinguishable by timing
const ok = dk.length === expected.length && crypto.timingSafeEqual(dk, expected);
cb(ok, null);
});
});
// Opaque tokens (character session handles, drop ids, ...)
global.exports('randomToken', (bytes) => crypto.randomBytes(Math.min(Math.max(bytes || 16, 8), 64)).toString('hex'));
-264
View File
@@ -1,264 +0,0 @@
-- ---------------------------------------------------------------------------
-- Authoritative player registry.
--
-- While a player is online their state lives in this table and nowhere else.
-- Balance changes are applied in memory *synchronously* (no yield between
-- read and write, so two concurrent handlers cannot both spend the same
-- money) and then mirrored to MariaDB asynchronously alongside a row in
-- `transactions`, which is the audit trail.
-- ---------------------------------------------------------------------------
Core = Core or {}
local players = {} -- [source] = player table
local byChar = {} -- [character id] = source
-- ---------------------------------------------------------------------------
-- Lookup
-- ---------------------------------------------------------------------------
function Core.Get(src) return players[src] end
function Core.GetByChar(charId) return players[byChar[charId] or -1] end
function Core.All() return players end
local function requireChar(src)
local p = players[src]
if not p or not p.char then return nil end
return p
end
-- ---------------------------------------------------------------------------
-- Lifecycle
-- ---------------------------------------------------------------------------
--- Called by rp_session once credentials have been accepted.
function Core.AttachAccount(src, account)
players[src] = {
source = src,
license = account.license,
account = { id = account.id, username = account.username, role = account.role },
char = nil,
spawned = false,
joinedAt = os.time(),
}
return players[src]
end
--- Called once a character has been chosen. `row` is the DB row.
function Core.AttachCharacter(src, row)
local p = players[src]
if not p then return nil end
p.char = {
id = row.id,
firstName = row.first_name,
lastName = row.last_name,
dob = row.dob,
gender = row.gender,
backstory = row.backstory,
cash = math.floor(tonumber(row.cash) or 0),
bank = math.floor(tonumber(row.bank) or 0),
health = tonumber(row.health) or 200,
armour = tonumber(row.armour) or 0,
job = row.job or 'unemployed',
jobGrade = tonumber(row.job_grade) or 0,
playtime = tonumber(row.playtime) or 0,
appearance = row.appearance and json.decode(row.appearance) or nil,
position = row.position and json.decode(row.position) or nil,
needs = row.needs and json.decode(row.needs) or { hunger = 100, thirst = 100, stress = 0 },
}
p.sessionStart = os.time()
byChar[row.id] = src
Core.PushCharacter(src)
Core.PublishState(src)
return p.char
end
function Core.Detach(src)
local p = players[src]
if not p then return end
if p.char then byChar[p.char.id] = nil end
players[src] = nil
end
-- ---------------------------------------------------------------------------
-- Synchronisation
-- ---------------------------------------------------------------------------
--- Private state: only the owning client receives this.
function Core.PushCharacter(src)
local p = requireChar(src)
if not p then return end
TriggerClientEvent('rp:core:character', src, p.char)
end
--- Public state: what every other client is allowed to know about this player.
function Core.PublishState(src)
local p = requireChar(src)
if not p then return end
local st = Player(src).state
st:set('rp:name', p.char.firstName .. ' ' .. p.char.lastName, true)
st:set('rp:job', p.char.job, true)
st:set('rp:charId', p.char.id, true)
end
-- ---------------------------------------------------------------------------
-- Money
-- ---------------------------------------------------------------------------
local VALID_ACCOUNTS = { cash = true, bank = true }
local function journal(charId, kind, delta, balanceAfter, reason)
DB.TransactionAsync({
{
query = ('UPDATE characters SET %s = ? WHERE id = ?'):format(kind),
values = { balanceAfter, charId },
},
{
query = 'INSERT INTO transactions (character_id, account_kind, delta, balance_after, reason) VALUES (?, ?, ?, ?, ?)',
values = { charId, kind, delta, balanceAfter, reason },
},
}, function(ok, err)
if not ok then
print(('^1[rp_core]^7 failed to journal %s %+d for character %d: %s')
:format(kind, delta, charId, tostring(err)))
end
end)
end
--- Returns true on success. Amounts are always positive integers.
function Core.AddMoney(src, kind, amount, reason)
local p = requireChar(src)
if not p or not VALID_ACCOUNTS[kind] then return false end
amount = math.floor(tonumber(amount) or 0)
if amount <= 0 then return false end
p.char[kind] = p.char[kind] + amount -- no yield: cannot interleave
local after = p.char[kind]
Core.PushCharacter(src)
journal(p.char.id, kind, amount, after, reason or 'unspecified')
return true
end
--- Returns false (and changes nothing) when the player cannot afford it.
function Core.RemoveMoney(src, kind, amount, reason)
local p = requireChar(src)
if not p or not VALID_ACCOUNTS[kind] then return false end
amount = math.floor(tonumber(amount) or 0)
if amount <= 0 then return false end
if p.char[kind] < amount then return false end
p.char[kind] = p.char[kind] - amount
local after = p.char[kind]
Core.PushCharacter(src)
journal(p.char.id, kind, -amount, after, reason or 'unspecified')
return true
end
function Core.GetMoney(src, kind)
local p = requireChar(src)
if not p or not VALID_ACCOUNTS[kind] then return 0 end
return p.char[kind]
end
-- ---------------------------------------------------------------------------
-- Persistence
-- ---------------------------------------------------------------------------
--- Reads position and health from the server's own copy of the entity rather
--- than asking the client, so a modified client cannot lie about either.
local function snapshot(p)
local ped = GetPlayerPed(p.source)
if ped and ped ~= 0 and DoesEntityExist(ped) then
local c = GetEntityCoords(ped)
local h = GetEntityHeading(ped)
if c and c.x and not (c.x == 0.0 and c.y == 0.0) then
p.char.position = { x = c.x, y = c.y, z = c.z, h = h }
end
local hp = GetEntityHealth(ped)
if hp and hp > 0 then p.char.health = hp end
end
if p.sessionStart then
local now = os.time()
p.char.playtime = p.char.playtime + (now - p.sessionStart)
p.sessionStart = now
end
end
function Core.Save(src, reason)
local p = requireChar(src)
if not p or not p.spawned then return end
snapshot(p)
local c = p.char
DB.UpdateAsync([[
UPDATE characters
SET cash = ?, bank = ?, health = ?, armour = ?, job = ?, job_grade = ?,
position = ?, needs = ?, appearance = ?, playtime = ?, last_played_at = NOW()
WHERE id = ?
]], {
c.cash, c.bank, c.health, c.armour, c.job, c.jobGrade,
json.encode(c.position or {}), json.encode(c.needs or {}),
c.appearance and json.encode(c.appearance) or DB.NULL,
c.playtime, c.id,
}, function(_, err)
if err then
print(('^1[rp_core]^7 save failed for character %d (%s): %s'):format(c.id, tostring(reason), err))
end
end)
end
function Core.SaveAll(reason)
local n = 0
for src in pairs(players) do
if players[src].char and players[src].spawned then
Core.Save(src, reason)
n = n + 1
end
end
return n
end
CreateThread(function()
local interval = (Config.Session.autosaveSeconds or 300) * 1000
while true do
Wait(interval)
local n = Core.SaveAll('autosave')
if n > 0 then print(('^5[rp_core]^7 autosaved %d character(s)'):format(n)) end
end
end)
AddEventHandler('playerDropped', function(reason)
local src = source
Core.Save(src, 'disconnect: ' .. tostring(reason))
Core.Detach(src)
end)
AddEventHandler('onResourceStop', function(res)
if res ~= GetCurrentResourceName() then return end
local n = Core.SaveAll('resource stop')
print(('^5[rp_core]^7 saving %d character(s) on shutdown'):format(n))
end)
-- ---------------------------------------------------------------------------
-- Exports for the other resources. None of these yield.
-- ---------------------------------------------------------------------------
exports('getPlayer', function(src) return players[src] end)
exports('getCharacter', function(src) local p = players[src]; return p and p.char or nil end)
exports('attachAccount', Core.AttachAccount)
exports('attachCharacter',Core.AttachCharacter)
exports('detach', Core.Detach)
exports('addMoney', Core.AddMoney)
exports('removeMoney', Core.RemoveMoney)
exports('getMoney', Core.GetMoney)
exports('save', Core.Save)
exports('pushCharacter', Core.PushCharacter)
exports('publishState', Core.PublishState)
exports('setSpawned', function(src, v)
local p = players[src]
if p then p.spawned = v and true or false end
end)
@@ -1,185 +0,0 @@
-- ---------------------------------------------------------------------------
-- Freemode ped appearance model.
--
-- The client builds a ped from this table and the server stores it verbatim,
-- so it is sanitised here - once - and both sides use the same code. Anything
-- the client sends that is not described below is dropped.
-- ---------------------------------------------------------------------------
Appearance = {}
Appearance.MODELS = {
m = 'mp_m_freemode_01',
f = 'mp_f_freemode_01',
}
-- SetPedHeadBlendData: 46 heritage faces per parent.
Appearance.PARENT_MAX = 45
-- SetPedFaceFeature indices, in the order the creator shows them.
Appearance.FEATURES = {
{ id = 0, label = 'Nose width' },
{ id = 1, label = 'Nose height' },
{ id = 2, label = 'Nose length' },
{ id = 3, label = 'Nose bridge' },
{ id = 4, label = 'Nose tip' },
{ id = 5, label = 'Nose shift' },
{ id = 6, label = 'Brow height' },
{ id = 7, label = 'Brow depth' },
{ id = 8, label = 'Cheekbone height' },
{ id = 9, label = 'Cheekbone width' },
{ id = 10, label = 'Cheek width' },
{ id = 11, label = 'Eye opening' },
{ id = 12, label = 'Lip thickness' },
{ id = 13, label = 'Jaw width' },
{ id = 14, label = 'Jaw length' },
{ id = 15, label = 'Chin height' },
{ id = 16, label = 'Chin length' },
{ id = 17, label = 'Chin width' },
{ id = 18, label = 'Chin dimple' },
{ id = 19, label = 'Neck thickness' },
}
-- SetPedHeadOverlay slots that the creator exposes.
Appearance.OVERLAYS = {
{ id = 2, key = 'eyebrows', label = 'Eyebrows', max = 33, tint = 'hair' },
{ id = 1, key = 'beard', label = 'Facial hair', max = 28, tint = 'hair' },
{ id = 0, key = 'blemishes', label = 'Blemishes', max = 23, tint = false },
{ id = 3, key = 'ageing', label = 'Ageing', max = 14, tint = false },
{ id = 6, key = 'complexion',label = 'Complexion', max = 11, tint = false },
{ id = 7, key = 'sundamage', label = 'Sun damage', max = 10, tint = false },
{ id = 9, key = 'freckles', label = 'Freckles', max = 17, tint = false },
{ id = 5, key = 'blush', label = 'Blush', max = 6, tint = 'makeup' },
{ id = 8, key = 'lipstick', label = 'Lipstick', max = 9, tint = 'makeup' },
{ id = 4, key = 'makeup', label = 'Make-up', max = 74, tint = 'makeup' },
}
-- Clothing components the creator exposes (0-11 exist; these are the useful ones).
Appearance.COMPONENTS = {
{ id = 11, key = 'jacket', label = 'Top' },
{ id = 8, key = 'undershirt',label = 'Undershirt' },
{ id = 4, key = 'legs', label = 'Legs' },
{ id = 6, key = 'shoes', label = 'Shoes' },
{ id = 3, key = 'torso', label = 'Arms' },
{ id = 1, key = 'mask', label = 'Mask' },
{ id = 9, key = 'vest', label = 'Vest' },
{ id = 7, key = 'accessory', label = 'Accessory' },
{ id = 10, key = 'decal', label = 'Decal' },
{ id = 5, key = 'bag', label = 'Bag' },
}
Appearance.PROPS = {
{ id = 0, key = 'hat', label = 'Hat' },
{ id = 1, key = 'glasses', label = 'Glasses' },
{ id = 2, key = 'ear', label = 'Earrings' },
}
local function num(v, lo, hi, fallback)
v = tonumber(v)
if not v or v ~= v then return fallback end -- also rejects NaN
if v < lo then return lo end
if v > hi then return hi end
return v
end
local function int(v, lo, hi, fallback)
return math.floor(num(v, lo, hi, fallback) + 0.0)
end
function Appearance.Default(gender)
local a = {
model = (gender == 'f') and 'f' or 'm',
parents = { father = 0, mother = 21, shapeMix = 0.5, skinMix = 0.5 },
features = {},
overlays = {},
hair = { style = 0, colour = 0, highlight = 0 },
eyeColour = 0,
components = {},
props = {},
}
for _, f in ipairs(Appearance.FEATURES) do a.features[tostring(f.id)] = 0.0 end
for _, o in ipairs(Appearance.OVERLAYS) do
a.overlays[o.key] = { index = -1, opacity = 1.0, colour = 0 }
end
for _, c in ipairs(Appearance.COMPONENTS) do
a.components[c.key] = { drawable = 0, texture = 0 }
end
for _, p in ipairs(Appearance.PROPS) do
a.props[p.key] = { drawable = -1, texture = 0 }
end
-- a plain default outfit rather than the naked base ped
a.components.jacket = { drawable = 15, texture = 0 }
a.components.undershirt = { drawable = 15, texture = 0 }
a.components.torso = { drawable = 15, texture = 0 }
a.components.legs = { drawable = 21, texture = 0 }
a.components.shoes = { drawable = 34, texture = 0 }
return a
end
--- Returns a clean appearance table built only from known keys.
function Appearance.Sanitise(input)
local gender = (type(input) == 'table' and input.model == 'f') and 'f' or 'm'
local out = Appearance.Default(gender)
if type(input) ~= 'table' then return out end
if type(input.parents) == 'table' then
out.parents.father = int(input.parents.father, 0, Appearance.PARENT_MAX, 0)
out.parents.mother = int(input.parents.mother, 0, Appearance.PARENT_MAX, 21)
out.parents.shapeMix = num(input.parents.shapeMix, 0.0, 1.0, 0.5)
out.parents.skinMix = num(input.parents.skinMix, 0.0, 1.0, 0.5)
end
if type(input.features) == 'table' then
for _, f in ipairs(Appearance.FEATURES) do
local k = tostring(f.id)
out.features[k] = num(input.features[k], -1.0, 1.0, 0.0)
end
end
if type(input.overlays) == 'table' then
for _, o in ipairs(Appearance.OVERLAYS) do
local given = input.overlays[o.key]
if type(given) == 'table' then
out.overlays[o.key] = {
index = int(given.index, -1, o.max, -1),
opacity = num(given.opacity, 0.0, 1.0, 1.0),
colour = int(given.colour, 0, 63, 0),
}
end
end
end
if type(input.hair) == 'table' then
out.hair.style = int(input.hair.style, 0, 80, 0)
out.hair.colour = int(input.hair.colour, 0, 63, 0)
out.hair.highlight = int(input.hair.highlight, 0, 63, 0)
end
out.eyeColour = int(input.eyeColour, 0, 31, 0)
if type(input.components) == 'table' then
for _, c in ipairs(Appearance.COMPONENTS) do
local given = input.components[c.key]
if type(given) == 'table' then
out.components[c.key] = {
drawable = int(given.drawable, 0, 511, 0),
texture = int(given.texture, 0, 63, 0),
}
end
end
end
if type(input.props) == 'table' then
for _, p in ipairs(Appearance.PROPS) do
local given = input.props[p.key]
if type(given) == 'table' then
out.props[p.key] = {
drawable = int(given.drawable, -1, 255, -1),
texture = int(given.texture, 0, 63, 0),
}
end
end
end
return out
end
-99
View File
@@ -1,99 +0,0 @@
-- ---------------------------------------------------------------------------
-- Shared configuration. Loaded on both server and client.
-- ---------------------------------------------------------------------------
Config = {}
Config.ServerName = 'LOS SANTOS'
Config.ServerTag = 'ROLEPLAY'
Config.MaxChars = 3
-- Shown on the loading screen while the world streams in.
Config.Rules = {
{ title = 'Stay in character', body = 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
{ title = 'Value your life', body = 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
{ title = 'No random deathmatch', body = 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
{ title = 'Do not power game', body = 'Give people a fair chance to react. Winning is not the point of a scene.' },
{ title = 'Leave the scene alive', body = 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
}
-- Spawn choices offered after character select. Coordinates are real map
-- locations; `cam` is where the establishing shot sits before it flies down.
Config.SpawnPoints = {
{
id = 'legion',
label = 'Legion Square',
area = 'Downtown',
blurb = 'Concrete, noise and opportunity. Everything starts here eventually.',
coords = vector4(195.12, -933.75, 30.69, 145.0),
cam = { pos = vector3(214.0, -880.0, 90.0), look = vector3(195.0, -933.0, 32.0) },
map = { x = 0.512, y = 0.548 },
},
{
id = 'vespucci',
label = 'Vespucci Beach',
area = 'West coast',
blurb = 'Sand, boardwalk hustlers and the smell of two-stroke engines.',
coords = vector4(-1223.45, -1490.32, 4.38, 125.0),
cam = { pos = vector3(-1250.0, -1440.0, 60.0), look = vector3(-1223.0, -1490.0, 6.0) },
map = { x = 0.318, y = 0.700 },
},
{
id = 'sandy',
label = 'Sandy Shores',
area = 'Blaine County',
blurb = 'Dust, trailers and people who would rather not be found.',
coords = vector4(1961.29, 3740.68, 32.34, 300.0),
cam = { pos = vector3(1995.0, 3700.0, 80.0), look = vector3(1961.0, 3740.0, 34.0) },
map = { x = 0.760, y = 0.245 },
},
{
id = 'paleto',
label = 'Paleto Bay',
area = 'North coast',
blurb = 'A town small enough that everyone will know your name by Friday.',
coords = vector4(-108.71, 6467.32, 31.63, 225.0),
cam = { pos = vector3(-160.0, 6420.0, 80.0), look = vector3(-108.0, 6467.0, 33.0) },
map = { x = 0.470, y = 0.075 },
},
{
id = 'mirror',
label = 'Mirror Park',
area = 'East Vinewood',
blurb = 'Quiet streets, loud neighbours, and rent you cannot quite afford.',
coords = vector4(1050.12, -720.45, 57.05, 90.0),
cam = { pos = vector3(1100.0, -680.0, 100.0), look = vector3(1050.0, -720.0, 58.0) },
map = { x = 0.640, y = 0.505 },
},
}
-- Establishing shots used behind the login / character screens. Each entry is
-- a slow dolly from `from` to `to` while looking at `look`.
Config.CinematicShots = {
{ from = vector3(-1900.0, -600.0, 130.0), to = vector3(-1700.0, -500.0, 120.0), look = vector3(-1450.0, -400.0, 60.0) },
{ from = vector3(-75.0, -818.0, 320.0), to = vector3(30.0, -700.0, 290.0), look = vector3(-200.0, -1000.0, 60.0) },
{ from = vector3(1200.0, -1600.0, 160.0),to = vector3(1000.0, -1450.0, 150.0), look = vector3(500.0, -1200.0, 60.0) },
{ from = vector3(-1550.0, -1450.0, 90.0),to = vector3(-1400.0, -1550.0, 80.0), look = vector3(-1100.0, -1700.0, 20.0) },
{ from = vector3(730.0, 1200.0, 400.0), to = vector3(640.0, 1100.0, 380.0), look = vector3(300.0, 800.0, 200.0) },
}
-- Where a character stands while being edited or previewed. Flat, empty apron
-- at the airport: always streamed, no props to clip through, and dark enough
-- at the overridden clock time to light the ped cleanly.
Config.CreatorScene = {
ped = vector4(-1037.20, -2738.60, 20.17, 328.0),
}
Config.Money = {
startingCash = 500,
startingBank = 2500,
}
Config.Session = {
-- how long a client may sit at the auth screen before it is dropped
authTimeoutMs = 300000,
-- failed logins allowed per identity within the window
maxAttempts = 6,
attemptWindowS = 900,
autosaveSeconds = 300,
}
-111
View File
@@ -1,111 +0,0 @@
-- ---------------------------------------------------------------------------
-- Small shared helpers. Kept deliberately thin.
-- ---------------------------------------------------------------------------
Util = {}
function Util.trim(s)
if type(s) ~= 'string' then return '' end
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end
function Util.clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
function Util.round(v, places)
local m = 10 ^ (places or 0)
return math.floor(v * m + 0.5) / m
end
--- Deep copy; cycles are not expected in config/state tables.
function Util.copy(t)
if type(t) ~= 'table' then return t end
local out = {}
for k, v in pairs(t) do out[k] = Util.copy(v) end
return out
end
function Util.count(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
--- Group separated money, e.g. 1234567 -> "1,234,567"
function Util.money(n)
local s = tostring(math.floor(math.abs(n or 0)))
local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse()
out = out:gsub('^,', '')
return (n or 0) < 0 and ('-' .. out) or out
end
--- Names are stored capitalised regardless of how they were typed.
function Util.properName(s)
s = Util.trim(s):lower()
return (s:gsub("([^%s'-]+)", function(word)
return word:sub(1, 1):upper() .. word:sub(2)
end))
end
--- Validation shared by client (live feedback) and server (enforcement), so
--- the two can never disagree about what is acceptable.
Rules = {}
Rules.username = {
min = 3, max = 20,
pattern = '^[%w_]+$',
hint = '3-20 characters, letters, numbers and underscore only',
}
Rules.password = {
min = 8, max = 72,
hint = 'at least 8 characters, with a letter and a number',
}
Rules.name = {
min = 2, max = 20,
pattern = "^[%a][%a'%-]*$",
hint = "letters, apostrophes and hyphens",
}
function Rules.checkUsername(v)
v = Util.trim(v or '')
if #v < Rules.username.min then return false, 'Too short - ' .. Rules.username.hint end
if #v > Rules.username.max then return false, 'Too long - ' .. Rules.username.hint end
if not v:match(Rules.username.pattern) then return false, 'Only letters, numbers and underscore' end
return true
end
function Rules.checkPassword(v)
v = v or ''
if #v < Rules.password.min then return false, 'At least 8 characters' end
if #v > Rules.password.max then return false, 'Too long' end
if not v:match('%a') then return false, 'Needs at least one letter' end
if not v:match('%d') then return false, 'Needs at least one number' end
return true
end
function Rules.checkName(v)
v = Util.trim(v or '')
if #v < Rules.name.min then return false, 'Too short' end
if #v > Rules.name.max then return false, 'Too long' end
if not v:match(Rules.name.pattern) then return false, "Letters, ' and - only" end
return true
end
--- Date of birth: accepts YYYY-MM-DD, must be a real date and an adult age.
function Rules.checkDob(v)
v = Util.trim(v or '')
local y, m, d = v:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
if not y then return false, 'Use YYYY-MM-DD' end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if m < 1 or m > 12 then return false, 'Month must be 01-12' end
local mdays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
if (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0 then mdays[2] = 29 end
if d < 1 or d > mdays[m] then return false, 'That day does not exist' end
if y < 1930 or y > 2010 then return false, 'Year must be between 1930 and 2010' end
return true
end
-15
View File
@@ -1,15 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_db'
description 'MariaDB client speaking the MySQL wire protocol directly - no external packages'
author 'Los Santos RP'
version '1.0.0'
server_scripts {
'server/mysql.js',
}
files {
'lib/db.lua',
}
-87
View File
@@ -1,87 +0,0 @@
-- ---------------------------------------------------------------------------
-- DB - Lua front end for rp_db.
--
-- Loaded *into* the consuming resource with:
-- server_scripts { '@rp_db/lib/db.lua', 'server/whatever.lua' }
--
-- so that Citizen.Await suspends the caller's own coroutine instead of trying
-- to yield across a cross-resource export call.
--
-- Every function comes in two flavours:
-- DB.Query(sql, params) -> rows, err (blocking, inside a thread)
-- DB.QueryAsync(sql, params, cb) -> cb(rows, err) (callback, anywhere)
--
-- Values are always passed as `?` parameters. Never build SQL by concatenation.
-- Use DB.NULL where you need a literal SQL NULL (Lua nil cannot survive a table).
-- ---------------------------------------------------------------------------
DB = {}
DB.NULL = 'RP_NULL'
local backend = exports.rp_db
local function async(method)
return function(sql, params, cb)
backend[method](backend, sql, params or {}, cb)
end
end
local function blocking(method)
return function(sql, params)
local p = promise.new()
backend[method](backend, sql, params or {}, function(result, err)
-- wrapped: a promise resolved with nil would never wake the coroutine
p:resolve({ result = result, err = err })
end)
local r = Citizen.Await(p)
if r.err then return nil, r.err end
return r.result
end
end
--- Returns all rows as an array of tables (empty table when nothing matched).
DB.Query = blocking('query')
--- Returns the first row as a table, or nil.
DB.Single = blocking('single')
--- Returns the first column of the first row, or nil.
DB.Scalar = blocking('scalar')
--- Returns the AUTO_INCREMENT id produced by an INSERT.
DB.Insert = blocking('insert')
--- Returns the number of rows changed by an UPDATE/DELETE.
DB.Update = blocking('update')
DB.QueryAsync = async('query')
DB.SingleAsync = async('single')
DB.ScalarAsync = async('scalar')
DB.InsertAsync = async('insert')
DB.UpdateAsync = async('update')
--- All statements commit together or none of them do.
--- statements: { { query = 'UPDATE ..', values = { 1, 2 } }, ... }
function DB.Transaction(statements)
local p = promise.new()
backend:transaction(statements, function(ok, err)
p:resolve({ ok = ok, err = err })
end)
local r = Citizen.Await(p)
if not r.ok then return false, r.err end
return true
end
function DB.TransactionAsync(statements, cb)
backend:transaction(statements, cb)
end
--- Blocks until the connection pool has at least one live connection.
--- Returns false if the database never came up within `timeout` ms.
function DB.WaitReady(timeout)
local waited, step = 0, 100
timeout = timeout or 30000
while not backend:isReady() do
if waited >= timeout then return false end
Citizen.Wait(step)
waited = waited + step
end
return true
end
-746
View File
@@ -1,746 +0,0 @@
// ---------------------------------------------------------------------------
// rp_db - a MySQL/MariaDB client written for this server.
//
// Speaks the MySQL client/server protocol directly over a TCP socket: framing,
// handshake, mysql_native_password / caching_sha2_password (fast path), and
// the text result-set protocol. No external packages - only node built-ins.
//
// Deliberate choices:
// * CLIENT_MULTI_STATEMENTS is NOT negotiated, so a stacked-query injection
// ("'; DROP TABLE ..") is rejected by the server even if escaping failed.
// * CLIENT_LOCAL_FILES is NOT negotiated, so the server can never ask us to
// read a local file.
// * sql_mode is pinned at connect time, which also guarantees backslash
// escaping semantics that format() relies on.
// ---------------------------------------------------------------------------
const net = require('net');
const crypto = require('crypto');
// -- capability flags --------------------------------------------------------
const CAP = {
LONG_PASSWORD: 0x00000001,
FOUND_ROWS: 0x00000002,
LONG_FLAG: 0x00000004,
CONNECT_WITH_DB: 0x00000008,
LOCAL_FILES: 0x00000080,
PROTOCOL_41: 0x00000200,
TRANSACTIONS: 0x00002000,
SECURE_CONNECTION: 0x00008000,
MULTI_STATEMENTS: 0x00010000,
MULTI_RESULTS: 0x00020000,
PLUGIN_AUTH: 0x00080000,
CONNECT_ATTRS: 0x00100000,
PLUGIN_AUTH_LENENC: 0x00200000,
};
const CLIENT_CAPS =
CAP.LONG_PASSWORD | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 |
CAP.TRANSACTIONS | CAP.SECURE_CONNECTION | CAP.PLUGIN_AUTH | CAP.PLUGIN_AUTH_LENENC;
const COM = { QUIT: 0x01, QUERY: 0x03, PING: 0x0e };
// MySQL column type codes we care about when coercing text-protocol values.
const T_INT = new Set([1, 2, 3, 8, 9, 13]); // TINY SHORT LONG LONGLONG INT24 YEAR
const T_FLOAT = new Set([0, 4, 5, 246]); // DECIMAL FLOAT DOUBLE NEWDECIMAL
// ---------------------------------------------------------------------------
// Buffer helpers
// ---------------------------------------------------------------------------
class Reader {
constructor(buf) { this.b = buf; this.o = 0; }
u8() { return this.b[this.o++]; }
u16() { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
u32() { const v = this.b.readUInt32LE(this.o); this.o += 4; return v; }
skip(n) { this.o += n; return this; }
bytes(n) { const v = this.b.subarray(this.o, this.o + n); this.o += n; return v; }
nulStr() {
let i = this.b.indexOf(0, this.o);
if (i < 0) i = this.b.length;
const s = this.b.toString('utf8', this.o, i);
this.o = i + 1;
return s;
}
// length-encoded integer; returns null for the 0xfb NULL marker
lenenc() {
const f = this.b[this.o++];
if (f < 0xfb) return f;
if (f === 0xfb) return null;
if (f === 0xfc) { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
if (f === 0xfd) { const v = this.b.readUIntLE(this.o, 3); this.o += 3; return v; }
const v = this.b.readBigUInt64LE(this.o); this.o += 8;
return Number(v);
}
lenencStr() {
const n = this.lenenc();
if (n === null) return null;
return this.bytes(n).toString('utf8');
}
remaining() { return this.b.length - this.o; }
}
class Writer {
constructor() { this.parts = []; }
u8(v) { const b = Buffer.alloc(1); b[0] = v; this.parts.push(b); return this; }
u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); this.parts.push(b); return this; }
u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); this.parts.push(b); return this; }
zeros(n) { this.parts.push(Buffer.alloc(n)); return this; }
raw(b) { this.parts.push(b); return this; }
nulStr(s) { this.parts.push(Buffer.from(s, 'utf8')); return this.u8(0); }
lenencBuf(b) {
if (b.length < 0xfb) this.u8(b.length);
else if (b.length < 0x10000) { this.u8(0xfc).u16(b.length); }
else { const t = Buffer.alloc(4); t.writeUIntLE(b.length, 0, 3); this.u8(0xfd).raw(t.subarray(0, 3)); }
return this.raw(b);
}
done() { return Buffer.concat(this.parts); }
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
const sha1 = (d) => crypto.createHash('sha1').update(d).digest();
const sha256 = (d) => crypto.createHash('sha256').update(d).digest();
function xorBuf(a, b) {
const out = Buffer.alloc(a.length);
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length];
return out;
}
// SHA1(pw) XOR SHA1( scramble + SHA1(SHA1(pw)) )
function authNative(password, scramble) {
if (!password) return Buffer.alloc(0);
const s1 = sha1(Buffer.from(password, 'utf8'));
const s2 = sha1(s1);
return xorBuf(s1, sha1(Buffer.concat([scramble, s2])));
}
// SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) + scramble ) [fast path only]
function authCachingSha2(password, scramble) {
if (!password) return Buffer.alloc(0);
const s1 = sha256(Buffer.from(password, 'utf8'));
const s2 = sha256(s1);
return xorBuf(s1, sha256(Buffer.concat([s2, scramble])));
}
function authResponse(plugin, password, scramble) {
switch (plugin) {
case 'mysql_native_password': return authNative(password, scramble);
case 'caching_sha2_password': return authCachingSha2(password, scramble);
case 'mysql_clear_password': return Buffer.concat([Buffer.from(password, 'utf8'), Buffer.alloc(1)]);
default: return null; // unsupported -> connection fails loudly
}
}
// ---------------------------------------------------------------------------
// Value escaping. Every query goes through format(); values are never
// concatenated into SQL anywhere else in this codebase.
// ---------------------------------------------------------------------------
const ESCAPE_MAP = {
'\0': '\\0', '\b': '\\b', '\t': '\\t', '\n': '\\n',
'\r': '\\r', '\x1a': '\\Z', '"': '\\"', "'": "\\'", '\\': '\\\\',
};
function escapeString(s) {
return "'" + s.replace(/[\0\b\t\n\r\x1a"'\\]/g, (c) => ESCAPE_MAP[c]) + "'";
}
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function escapeValue(v) {
if (v === null || v === undefined) return 'NULL';
switch (typeof v) {
case 'boolean': return v ? '1' : '0';
case 'number':
if (!Number.isFinite(v)) throw new Error('cannot bind non-finite number');
return String(v);
case 'bigint': return String(v);
case 'string': return escapeString(v);
case 'object':
if (v instanceof Date) {
return escapeString(
`${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())} ` +
`${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}`
);
}
if (Buffer.isBuffer(v)) return 'x' + escapeString(v.toString('hex'));
if (Array.isArray(v)) return v.map(escapeValue).join(', ');
return escapeString(JSON.stringify(v)); // plain objects -> JSON columns
default:
throw new Error('cannot bind value of type ' + typeof v);
}
}
// Replace `?` placeholders, skipping any that appear inside string/identifier
// literals or comments so that user data containing '?' can never shift binding.
function format(sql, params) {
if (!params || (Array.isArray(params) && params.length === 0)) return sql;
const list = Array.isArray(params) ? params : [params];
let out = '';
let pi = 0;
let i = 0;
const n = sql.length;
while (i < n) {
const c = sql[i];
if (c === "'" || c === '"' || c === '`') {
const quote = c;
out += c; i++;
while (i < n) {
if (sql[i] === '\\' && quote !== '`') { out += sql[i] + (sql[i + 1] || ''); i += 2; continue; }
if (sql[i] === quote) {
if (sql[i + 1] === quote) { out += quote + quote; i += 2; continue; } // doubled quote
out += quote; i++; break;
}
out += sql[i]; i++;
}
continue;
}
if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
if (c === '#') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
if (c === '/' && sql[i + 1] === '*') {
out += '/*'; i += 2;
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) out += sql[i++];
out += '*/'; i += 2; continue;
}
if (c === '?') {
if (pi >= list.length) throw new Error('not enough parameters for query');
out += escapeValue(list[pi++]);
i++; continue;
}
out += c; i++;
}
if (pi !== list.length) throw new Error(`parameter count mismatch (query takes ${pi}, got ${list.length})`);
return out;
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
const MAX_PAYLOAD = 0xffffff;
class Connection {
constructor(opts, id) {
this.opts = opts;
this.id = id;
this.socket = null;
this.buf = Buffer.alloc(0);
this.seq = 0;
this.state = 'offline';
this.busy = false;
this.pinned = false; // reserved by a transaction; pump() must skip it
this.pending = null; // {resolve, reject, sql}
this.rs = null; // in-flight result set
this.multi = null; // reassembly buffer for >16MB payloads
this.serverCaps = 0;
this.onIdle = null;
this.retryDelay = 1000;
}
log(msg) { console.log(`^5[rp_db]^7 conn#${this.id} ${msg}`); }
err(msg) { console.log(`^1[rp_db]^7 conn#${this.id} ${msg}`); }
connect() {
if (this.state !== 'offline') return;
this.state = 'connecting';
this.buf = Buffer.alloc(0);
this.seq = 0;
const sock = net.createConnection({ host: this.opts.host, port: this.opts.port });
this.socket = sock;
sock.setNoDelay(true);
sock.on('data', (d) => {
this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d;
this.drainPackets();
});
sock.on('error', (e) => this.fail(e.message));
sock.on('close', () => { if (this.state !== 'offline') this.fail('connection closed'); });
}
fail(reason) {
const wasReady = this.state === 'ready' || this.state === 'query';
this.state = 'offline';
if (this.socket) { this.socket.destroy(); this.socket = null; }
if (this.pending) {
const p = this.pending; this.pending = null; this.busy = false;
p.reject(new Error(reason));
}
this.busy = false;
this.pinned = false; // a dropped connection must not stay reserved
this.rs = null;
if (wasReady) this.err(`lost connection: ${reason}`);
else this.err(`connect failed: ${reason}`);
// exponential backoff, capped
setTimeout(() => this.connect(), this.retryDelay);
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
}
// Split the stream into protocol packets, reassembling 16MB-split payloads.
drainPackets() {
for (;;) {
if (this.buf.length < 4) return;
const len = this.buf.readUIntLE(0, 3);
if (this.buf.length < 4 + len) return;
const seq = this.buf[3];
const payload = this.buf.subarray(4, 4 + len);
this.buf = this.buf.subarray(4 + len);
this.seq = (seq + 1) & 0xff;
if (len === MAX_PAYLOAD) { // more to come
this.multi = this.multi ? Buffer.concat([this.multi, payload]) : payload;
continue;
}
let full = payload;
if (this.multi) { full = Buffer.concat([this.multi, payload]); this.multi = null; }
try { this.onPacket(full); }
catch (e) { this.err(`packet handling error: ${e.message}`); this.fail(e.message); return; }
}
}
send(payload) {
if (!this.socket) return;
const chunks = [];
let off = 0;
do {
const slice = payload.subarray(off, off + MAX_PAYLOAD);
const head = Buffer.alloc(4);
head.writeUIntLE(slice.length, 0, 3);
head[3] = this.seq & 0xff;
this.seq = (this.seq + 1) & 0xff;
chunks.push(head, slice);
off += slice.length;
} while (off < payload.length);
this.socket.write(Buffer.concat(chunks));
}
sendCommand(cmd, text) {
this.seq = 0; // every command starts a new sequence
const body = text ? Buffer.from(text, 'utf8') : Buffer.alloc(0);
this.send(Buffer.concat([Buffer.from([cmd]), body]));
}
onPacket(p) {
switch (this.state) {
case 'connecting': return this.onHandshake(p);
case 'auth': return this.onAuthResult(p);
case 'init': return this.onInitResult(p);
case 'query': return this.onQueryPacket(p);
default:
this.err(`unexpected packet in state ${this.state}`);
}
}
// -- handshake -------------------------------------------------------------
onHandshake(p) {
const r = new Reader(p);
const protocol = r.u8();
if (protocol === 0xff) return this.fail(this.readError(p).message);
if (protocol !== 10) return this.fail(`unsupported protocol version ${protocol}`);
this.serverVersion = r.nulStr();
r.u32(); // connection id
const scramble1 = r.bytes(8);
r.skip(1); // filler
let caps = r.u16();
let scramble2 = Buffer.alloc(0);
let plugin = 'mysql_native_password';
if (r.remaining() > 0) {
r.u8(); // charset
r.u16(); // status flags
caps |= r.u16() << 16;
const authDataLen = r.u8();
r.skip(10);
if (caps & CAP.SECURE_CONNECTION) {
const n = Math.max(13, authDataLen - 8);
scramble2 = r.bytes(n);
// trailing NUL is part of the padding, not the scramble
if (scramble2.length && scramble2[scramble2.length - 1] === 0) {
scramble2 = scramble2.subarray(0, scramble2.length - 1);
}
}
if (caps & CAP.PLUGIN_AUTH) plugin = r.nulStr();
}
this.serverCaps = caps >>> 0;
this.scramble = Buffer.concat([scramble1, scramble2]);
const resp = authResponse(plugin, this.opts.password, this.scramble);
if (resp === null) return this.fail(`server requested unsupported auth plugin '${plugin}'`);
const w = new Writer();
w.u32(CLIENT_CAPS).u32(MAX_PAYLOAD).u8(45 /* utf8mb4_general_ci */).zeros(23);
w.nulStr(this.opts.user);
w.lenencBuf(resp);
w.nulStr(this.opts.database);
w.nulStr(plugin);
this.authPlugin = plugin;
this.state = 'auth';
this.send(w.done());
}
onAuthResult(p) {
const marker = p[0];
if (marker === 0x00) return this.afterAuth();
if (marker === 0xff) return this.fail(this.readError(p).message);
if (marker === 0xfe) { // AuthSwitchRequest
const r = new Reader(p); r.u8();
const plugin = r.nulStr();
let scramble = r.bytes(r.remaining());
if (scramble.length && scramble[scramble.length - 1] === 0) scramble = scramble.subarray(0, scramble.length - 1);
const resp = authResponse(plugin, this.opts.password, scramble);
if (resp === null) return this.fail(`server switched to unsupported auth plugin '${plugin}'`);
this.scramble = scramble;
this.authPlugin = plugin;
return this.send(resp);
}
if (marker === 0x01) { // caching_sha2 extra auth data
const status = p[1];
if (status === 0x03) return; // fast auth ok -> OK packet follows
if (status === 0x04) {
return this.fail(
'caching_sha2_password requires a full authentication exchange (TLS or the ' +
'server public key), which this driver does not implement. Give the database ' +
"user mysql_native_password, e.g. ALTER USER .. IDENTIFIED VIA mysql_native_password."
);
}
return this.fail(`unexpected auth continuation 0x${status.toString(16)}`);
}
this.fail(`unexpected auth response 0x${marker.toString(16)}`);
}
// Pin session settings, then announce readiness.
afterAuth() {
this.retryDelay = 1000;
this.state = 'init';
this.sendCommand(
COM.QUERY,
"SET NAMES utf8mb4, sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', " +
"time_zone='+00:00', autocommit=1"
);
}
onInitResult(p) {
if (p[0] === 0xff) return this.fail(this.readError(p).message);
this.state = 'ready';
this.busy = false;
this.log(`ready (server ${this.serverVersion}, auth ${this.authPlugin})`);
if (this.onIdle) this.onIdle(this);
}
// -- errors ----------------------------------------------------------------
readError(p) {
const r = new Reader(p);
r.u8();
const code = r.u16();
let sqlState = '';
if (p[3] === 0x23) { r.skip(1); sqlState = r.bytes(5).toString('ascii'); }
const message = p.subarray(r.o).toString('utf8');
const e = new Error(`${message} (errno ${code}${sqlState ? ', sqlstate ' + sqlState : ''})`);
e.errno = code; e.sqlState = sqlState;
return e;
}
readOk(p) {
const r = new Reader(p);
r.u8();
const affectedRows = r.lenenc() || 0;
const insertId = r.lenenc() || 0;
return { affectedRows, insertId };
}
isEof(p) { return p[0] === 0xfe && p.length < 9; }
// -- queries ---------------------------------------------------------------
query(sql) {
return new Promise((resolve, reject) => {
if (this.state !== 'ready') return reject(new Error('connection not ready'));
this.busy = true;
this.state = 'query';
this.pending = { resolve, reject, sql };
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
this.sendCommand(COM.QUERY, sql);
});
}
finish(value, error) {
const p = this.pending;
this.pending = null;
this.rs = null;
this.state = 'ready';
this.busy = false;
if (p) { error ? p.reject(error) : p.resolve(value); }
if (this.onIdle) this.onIdle(this);
}
onQueryPacket(p) {
const rs = this.rs;
if (rs.stage === 'head') {
if (p[0] === 0x00) return this.finish(this.readOk(p));
if (p[0] === 0xff) return this.finish(null, this.readError(p));
if (p[0] === 0xfb) return this.finish(null, new Error('LOCAL INFILE requested but not enabled'));
const r = new Reader(p);
rs.colCount = r.lenenc();
rs.stage = 'columns';
return;
}
if (rs.stage === 'columns') {
if (this.isEof(p)) { rs.stage = 'rows'; return; }
const r = new Reader(p);
r.lenencStr(); r.lenencStr(); r.lenencStr(); r.lenencStr(); // catalog, schema, table, org_table
const name = r.lenencStr();
r.lenencStr(); // org_name
r.lenenc(); // length of fixed fields
r.u16(); // charset
r.u32(); // column length
const type = r.u8();
rs.columns.push({ name, type });
return;
}
// stage === 'rows'
if (p[0] === 0xff) return this.finish(null, this.readError(p));
if (this.isEof(p)) return this.finish(rs.rows);
const r = new Reader(p);
const row = {};
for (let i = 0; i < rs.colCount; i++) {
const col = rs.columns[i];
const raw = r.lenencStr();
row[col.name] = raw === null ? null : coerce(raw, col.type);
}
rs.rows.push(row);
}
ping() {
if (this.state !== 'ready') return;
this.busy = true;
this.state = 'query';
this.pending = {
resolve: () => {}, reject: (e) => this.err(`ping failed: ${e.message}`), sql: 'PING',
};
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
this.sendCommand(COM.PING, '');
}
}
// Text protocol hands us strings; give Lua sensible types back.
function coerce(raw, type) {
if (T_INT.has(type)) {
const n = Number(raw);
return Number.isSafeInteger(n) ? n : raw; // keep huge BIGINTs exact as strings
}
if (T_FLOAT.has(type)) {
const n = Number(raw);
return Number.isNaN(n) ? raw : n;
}
return raw; // strings, dates, JSON, blobs
}
// ---------------------------------------------------------------------------
// Pool
// ---------------------------------------------------------------------------
class Pool {
constructor(opts) {
this.opts = opts;
this.queue = [];
this.conns = [];
for (let i = 0; i < opts.size; i++) {
const c = new Connection(opts, i + 1);
c.onIdle = () => this.pump();
this.conns.push(c);
}
}
start() {
this.conns.forEach((c, i) => setTimeout(() => c.connect(), i * 120));
setInterval(() => {
for (const c of this.conns) if (c.state === 'ready' && !c.busy) c.ping();
}, 30000);
}
get ready() { return this.conns.some((c) => c.state === 'ready'); }
free() { return this.conns.find((c) => c.state === 'ready' && !c.busy && !c.pinned); }
pump() {
while (this.queue.length) {
const conn = this.free();
if (!conn) return;
const job = this.queue.shift();
clearTimeout(job.timer);
if (job.settled) continue;
job.settled = true;
conn.query(job.sql).then(job.resolve, job.reject);
}
}
exec(sql) {
return new Promise((resolve, reject) => {
const job = { sql, resolve, reject, settled: false };
// Each job owns its own deadline, so a query still fails loudly when the
// database is down and no connection ever becomes free to trigger pump().
job.timer = setTimeout(() => {
if (job.settled) return;
job.settled = true;
const i = this.queue.indexOf(job);
if (i >= 0) this.queue.splice(i, 1);
reject(new Error('timed out waiting for a database connection'));
}, this.opts.queueTimeout);
this.queue.push(job);
this.pump();
});
}
// Pins one connection for the whole transaction so no other query can
// interleave between START TRANSACTION and COMMIT.
async transaction(statements) {
const held = await this.acquire();
try {
await held.query('START TRANSACTION');
for (const st of statements) await held.query(st);
await held.query('COMMIT');
return true;
} catch (e) {
try { await held.query('ROLLBACK'); } catch (_) { /* connection already gone */ }
throw e;
} finally {
held.release();
}
}
acquire() {
return new Promise((resolve, reject) => {
const started = Date.now();
const attempt = () => {
const conn = this.free();
if (conn) {
conn.pinned = true;
return resolve({
query: (sql) => conn.query(sql),
release: () => { conn.pinned = false; this.pump(); },
});
}
if (Date.now() - started > this.opts.queueTimeout) {
return reject(new Error('timed out waiting for a database connection'));
}
setTimeout(attempt, 25);
};
attempt();
});
}
}
// ---------------------------------------------------------------------------
// FiveM bridge - exposes the pool to the Lua resources as exports.
// ---------------------------------------------------------------------------
// Lua cannot put nil inside a table without creating a hole, so callers pass
// this sentinel when they need a real SQL NULL. rp_db exposes it as DB.NULL.
const NULL_SENTINEL = 'RP_NULL';
function unsentinel(v) { return v === NULL_SENTINEL ? null : v; }
function normalizeParams(p) {
if (p === null || p === undefined) return [];
if (Array.isArray(p)) return p.map(unsentinel);
if (typeof p === 'object') {
const keys = Object.keys(p);
if (keys.length === 0) return []; // Lua {} arrives as an empty map
if (keys.every((k) => /^[0-9]+$/.test(k))) { // Lua array table
return keys.sort((a, b) => a - b).map((k) => unsentinel(p[k]));
}
return [p]; // a real object -> JSON column
}
return [unsentinel(p)];
}
const pool = new Pool({
host: GetConvar('rp_db_host', '127.0.0.1'),
port: parseInt(GetConvar('rp_db_port', '3306'), 10),
user: GetConvar('rp_db_user', 'rp'),
password: GetConvar('rp_db_pass', ''),
database: GetConvar('rp_db_name', 'rp'),
size: parseInt(GetConvar('rp_db_pool', '4'), 10),
queueTimeout: 20000,
});
// Run a query and hand the result to a Lua callback as (result, err).
// The SQL *template* is logged on failure, never the formatted text, so bound
// values (password hashes and the like) stay out of the console.
function run(sql, params, cb, shape) {
const done = (result, err) => {
if (err) {
console.log(`^1[rp_db]^7 query failed: ${err}`);
console.log(`^1[rp_db]^7 in: ${String(sql).trim().slice(0, 200)}`);
}
if (typeof cb === 'function') {
try { cb(result, err || null); }
catch (e) { console.log(`^1[rp_db]^7 callback threw: ${e.message}`); }
}
};
let text;
try { text = format(sql, normalizeParams(params)); }
catch (e) { return done(null, e.message); }
pool.exec(text).then(
(res) => { try { done(shape(res), null); } catch (e) { done(null, e.message); } },
(e) => done(null, e.message)
);
}
const rowsOf = (res) => (Array.isArray(res) ? res : []);
global.exports('query', (sql, params, cb) => run(sql, params, cb, (r) => r));
global.exports('single', (sql, params, cb) => run(sql, params, cb, (r) => rowsOf(r)[0] || null));
global.exports('scalar', (sql, params, cb) => run(sql, params, cb, (r) => {
const row = rowsOf(r)[0];
if (!row) return null;
const k = Object.keys(row)[0];
return k === undefined ? null : row[k];
}));
global.exports('insert', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.insertId) || 0));
global.exports('update', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.affectedRows) || 0));
// statements: array of {query = ..., values = {...}} tables from Lua
global.exports('transaction', (statements, cb) => {
let list;
try {
const arr = Array.isArray(statements) ? statements : Object.values(statements || {});
list = arr.map((s) => format(s.query, normalizeParams(s.values)));
} catch (e) {
console.log(`^1[rp_db]^7 transaction rejected: ${e.message}`);
if (typeof cb === 'function') cb(false, e.message);
return;
}
pool.transaction(list).then(
() => { if (typeof cb === 'function') cb(true, null); },
(e) => {
console.log(`^1[rp_db]^7 transaction rolled back: ${e.message}`);
if (typeof cb === 'function') cb(false, e.message);
}
);
});
global.exports('isReady', () => pool.ready);
global.exports('nullSentinel', () => NULL_SENTINEL);
pool.start();
-11
View File
@@ -1,11 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_dbtest'
description 'Self-test for the rp_db driver. Not started in production.'
version '1.0.0'
server_scripts {
'@rp_db/lib/db.lua',
'server/test.lua',
}
-122
View File
@@ -1,122 +0,0 @@
-- Self-test for the hand-written MySQL driver. Run with: ensure rp_dbtest
local passed, failed = 0, 0
local function check(name, ok, detail)
if ok then
passed = passed + 1
print(('^2 PASS^7 %s'):format(name))
else
failed = failed + 1
print(('^1 FAIL^7 %s^1 %s^7'):format(name, detail or ''))
end
end
CreateThread(function()
print('^5[dbtest]^7 waiting for the pool...')
if not DB.WaitReady(30000) then
print('^1[dbtest] database never became ready^7')
return
end
-- 1. round trip through the text protocol -------------------------------
local two = DB.Scalar('SELECT 1 + 1')
check('scalar arithmetic', two == 2, ('got %s (%s)'):format(tostring(two), type(two)))
local row = DB.Single('SELECT ? AS s, ? AS n, ? AS f', { 'hello', 42, 1.5 })
check('typed columns', row and row.s == 'hello' and row.n == 42 and row.f == 1.5,
row and json.encode(row) or 'nil')
-- 2. hostile strings must survive verbatim ------------------------------
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
local nasty = [[O'Brien \ "quoted" ]] .. '\n\t' .. [[ 100% ` ; -- /* */ ?]]
local id = DB.Insert(
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
{ 'ztest_a', nasty })
check('insert returns id', type(id) == 'number' and id > 0, tostring(id))
local back = DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id })
check('hostile string round trip', back == nasty,
('stored %q'):format(tostring(back)))
-- 3. a parameter can never become SQL -----------------------------------
local inject = "'); DROP TABLE accounts; -- "
DB.Insert('INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
{ 'ztest_b', inject })
local stillThere = DB.Scalar(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'accounts'")
check('injection is inert', stillThere == 1, 'accounts table missing!')
local storedInject = DB.Scalar("SELECT password_hash FROM accounts WHERE username = 'ztest_b'")
check('injection stored literally', storedInject == inject, tostring(storedInject))
-- 4. question marks inside literals must not shift binding ---------------
local q = DB.Single("SELECT 'what? really?' AS lit, ? AS bound", { 'bound-value' })
check('literal ? not treated as placeholder',
q and q.lit == 'what? really?' and q.bound == 'bound-value',
q and json.encode(q) or 'nil')
-- 5. NULL handling -------------------------------------------------------
DB.Update('UPDATE accounts SET last_ip = ? WHERE id = ?', { DB.NULL, id })
local isNull = DB.Scalar('SELECT last_ip IS NULL FROM accounts WHERE id = ?', { id })
check('DB.NULL writes a real NULL', isNull == 1, tostring(isNull))
local nilRow = DB.Single('SELECT last_ip FROM accounts WHERE id = ?', { id })
check('NULL reads back as nil', nilRow ~= nil and nilRow.last_ip == nil,
nilRow and json.encode(nilRow) or 'nil')
-- 6. unicode -------------------------------------------------------------
local uni = 'Ünïcødé — 日本語 — 🚓'
DB.Update('UPDATE accounts SET password_hash = ? WHERE id = ?', { uni, id })
check('utf8mb4 round trip', DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id }) == uni)
-- 7. empty result sets ---------------------------------------------------
local none = DB.Query('SELECT * FROM accounts WHERE id = ?', { -1 })
check('empty select returns empty table', type(none) == 'table' and #none == 0)
check('missing single returns nil', DB.Single('SELECT * FROM accounts WHERE id = ?', { -1 }) == nil)
-- 8. transactions --------------------------------------------------------
local ok = DB.Transaction({
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'admin', id } },
{ query = 'UPDATE accounts SET banned = ? WHERE id = ?', values = { 1, id } },
})
local after = DB.Single('SELECT role, banned FROM accounts WHERE id = ?', { id })
check('transaction commits', ok and after.role == 'admin' and after.banned == 1,
json.encode(after or {}))
local bad, err = DB.Transaction({
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'mod', id } },
{ query = 'UPDATE accounts SET nonexistent_column = 1 WHERE id = ?', values = { id } },
})
local rolled = DB.Scalar('SELECT role FROM accounts WHERE id = ?', { id })
check('failed transaction rolls back', bad == false and rolled == 'admin',
('role is now %s'):format(tostring(rolled)))
-- 9. errors surface instead of failing silently --------------------------
local res, qerr = DB.Query('SELECT * FROM table_that_does_not_exist')
check('bad query returns an error', res == nil and type(qerr) == 'string' and #qerr > 0,
tostring(qerr))
-- 10. wrong parameter count is caught, not guessed -----------------------
local _, cerr = DB.Query('SELECT ?, ?', { 1 })
check('parameter count mismatch reported', cerr ~= nil, tostring(cerr))
-- 11. a large payload exercises multi-packet framing in both directions --
local big = string.rep('x', 700000)
check('700KB payload round trip', DB.Scalar('SELECT ? AS big', { big }) == big)
-- 12. concurrency: many queries in flight across the pool ----------------
local done, results = 0, {}
for i = 1, 40 do
DB.ScalarAsync('SELECT ?', { i }, function(v) done = done + 1; results[i] = v end)
end
local waited = 0
while done < 40 and waited < 10000 do Wait(50); waited = waited + 50 end
local allGood = done == 40
for i = 1, 40 do if results[i] ~= i then allGood = false end end
check('40 concurrent queries all answered correctly', allGood, ('done=%d'):format(done))
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
print(('^5[dbtest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
if failed == 0 then print('^2[dbtest] driver OK^7') else print('^1[dbtest] DRIVER HAS PROBLEMS^7') end
end)
-27
View File
@@ -1,27 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_loading'
description 'Loading screen: procedural night-city flyover, real load progress, synthesised ambience'
author 'Los Santos RP'
version '1.0.0'
loadscreen 'html/index.html'
-- The screen stays up after the game has finished loading so the client can
-- hand over to the in-game camera without a black frame in between.
loadscreen_manual_shutdown 'yes'
-- Needed for the volume fader to be usable.
loadscreen_cursor 'yes'
files {
'html/index.html',
'html/tokens.css',
'html/app.css',
'html/app.js',
'html/fonts/archivo-var.woff2',
'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
-325
View File
@@ -1,325 +0,0 @@
/* ---------------------------------------------------------------------------
Loading screen.
Layout: the city runs full bleed; the record sits in a rail on the left,
held there by a gradient scrim rather than a panel, so the two never feel
like separate windows.
--------------------------------------------------------------------------- */
#sky, .grain, .scrim, .vignette {
position: fixed;
inset: 0;
pointer-events: none;
}
#sky { width: 100%; height: 100%; display: block; }
.scrim {
background: linear-gradient(
90deg,
rgba(8, 9, 11, 0.97) 0%,
rgba(8, 9, 11, 0.93) 34%,
rgba(8, 9, 11, 0.72) 58%,
rgba(8, 9, 11, 0.15) 82%,
rgba(8, 9, 11, 0) 100%
);
}
.vignette {
background: radial-gradient(120% 90% at 62% 45%, transparent 40%, rgba(0, 0, 0, 0.55) 100%);
}
/* film grain, drawn once as an SVG turbulence tile */
.grain {
opacity: 0.16;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
animation: grainshift 640ms steps(2) infinite;
}
@keyframes grainshift {
0% { transform: translate(0, 0); }
50% { transform: translate(-3px, 2px); }
100% { transform: translate(2px, -2px); }
}
/* --- rail ---------------------------------------------------------------- */
.rail {
position: relative;
z-index: 2;
width: var(--sheet);
height: 100%;
padding: var(--gut-y) var(--gut-x);
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: clamp(20px, 2.4vh, 34px);
}
/* staged entrance; each element declares its own delay */
.reveal {
opacity: 0;
transform: translateY(10px);
animation: rise 900ms var(--ease-out) forwards;
animation-delay: var(--d, 0ms);
}
@keyframes rise {
to { opacity: 1; transform: none; }
}
/* --- file header --------------------------------------------------------- */
.file {
border-top: 1px solid rgba(217,178,60,.42);
padding-top: 14px;
}
.file .serial {
margin-top: 7px;
color: rgba(233,226,204,.62);
}
.file .serial span { color: var(--canary); }
/* --- wordmark ------------------------------------------------------------ */
.brand { align-self: end; }
.wordmark {
font-size: clamp(46px, 5.2vw, 82px);
font-weight: 800;
line-height: 0.86;
letter-spacing: 0.3em;
text-indent: 0.3em; /* keeps the wide tracking optically centred */
opacity: 0;
animation: settle 1500ms var(--ease-out) 180ms forwards;
}
/* the name tightens into place, like a stamp being pressed */
@keyframes settle {
0% { opacity: 0; letter-spacing: 0.3em; text-indent: 0.3em; filter: blur(6px); }
60% { opacity: 1; }
100% { opacity: 1; letter-spacing: -0.028em; text-indent: 0; filter: blur(0); }
}
.tagrow {
display: flex;
align-items: center;
gap: 14px;
margin-top: 16px;
}
.tagline {
font-family: 'Plex Mono', monospace;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.42em;
color: var(--canary);
text-transform: uppercase;
}
.dash {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(217,178,60,.42), transparent);
}
/* --- notices ------------------------------------------------------------- */
.notices { align-self: center; }
.notice-head {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-bottom: 10px;
border-bottom: 1px solid rgba(233,226,204,.14);
}
.notice-index { color: rgba(233,226,204,.40); }
.notice { padding-top: 18px; min-height: 132px; }
.notice-title {
font-size: 21px;
font-weight: 600;
letter-spacing: -0.015em;
margin-bottom: 8px;
}
.notice-body {
font-size: 14.5px;
color: rgba(233,226,204,.62);
max-width: 40ch;
}
.notice.swap .notice-title,
.notice.swap .notice-body { animation: none; }
.notice-title, .notice-body {
animation: noticein 620ms var(--ease-out) both;
}
.notice-body { animation-delay: 70ms; }
@keyframes noticein {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: none; }
}
/* --- progress ------------------------------------------------------------ */
.load { align-self: end; }
.loadhead {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 11px;
gap: 16px;
}
.step {
color: rgba(233,226,204,.62);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pct {
font-family: 'Plex Mono', monospace;
font-weight: 500;
font-size: 28px;
line-height: 1;
color: var(--stock-hi);
font-variant-numeric: tabular-nums;
}
.pct i {
font-style: normal;
font-size: 13px;
color: rgba(233,226,204,.40);
margin-left: 2px;
}
.track {
position: relative;
height: 2px;
background: rgba(233,226,204,.14);
overflow: hidden;
}
.bar {
display: block;
height: 100%;
width: 0%;
background: var(--canary);
transition: width 420ms var(--ease);
}
/* a light runs ahead of the fill so the bar reads as active even when the
game stalls on a long step */
.bar::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 64px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 159, 69, 0.85));
animation: crawl 1900ms linear infinite;
}
@keyframes crawl {
0% { transform: translateX(-64px); opacity: 0; }
35% { opacity: 1; }
100% { transform: translateX(0); opacity: 0; }
}
/* --- volume fader -------------------------------------------------------- */
.fader {
display: flex;
align-items: center;
gap: 14px;
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid rgba(233,226,204,.07);
}
.mute {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid rgba(233,226,204,.14);
color: rgba(233,226,204,.62);
cursor: pointer;
transition: color 180ms var(--ease), border-color 180ms var(--ease), transform 120ms var(--ease);
}
.mute:hover { color: var(--canary); border-color: rgba(217,178,60,.42); }
.mute:active { transform: translateY(1px); }
.mute.off #wave { opacity: 0.18; }
.mute.off { color: rgba(233,226,204,.40); }
input[type='range'] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 22px;
background: transparent;
cursor: pointer;
}
input[type='range']::-webkit-slider-runnable-track {
height: 2px;
background: rgba(233,226,204,.14);
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 3px;
height: 15px;
margin-top: -6.5px;
background: var(--canary);
border: 0;
border-radius: 0;
transition: height 160ms var(--ease), margin-top 160ms var(--ease);
}
input[type='range']:hover::-webkit-slider-thumb,
input[type='range']:focus-visible::-webkit-slider-thumb {
height: 21px;
margin-top: -9.5px;
}
.volval { width: 3ch; text-align: right; color: rgba(233,226,204,.40); }
.audiohint {
margin-top: 10px;
color: var(--canary);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.45; } }
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Once the world is ready the rail steps aside and reveals the live camera.
The page background has to go transparent too, otherwise the fade reveals
nothing but our own backdrop colour. */
body.handover, body.handover .rail { background: transparent !important; }
body.handover .scrim { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover #sky { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover .grain { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover .rail { transition: opacity 900ms var(--ease), transform 1200ms var(--ease); opacity: 0; transform: translateY(-12px); }
/* ---------------------------------------------------------------------------
The loading screen is the only screen with no paper on it, so the three
voices have to be restated in their light-on-dark values.
--------------------------------------------------------------------------- */
.eyebrow { color: rgba(233,226,204,.42); }
.data { color: rgba(233,226,204,.60); }
body { color: var(--stock-hi); }
/* The posted notice is a real notice: a slip of the same stock the rest of the
product is printed on, taped to the window of a city that is still loading. */
.notice {
background: var(--stock);
color: var(--ink);
padding: 15px 17px 16px;
box-shadow: var(--lift-1);
border-left: 3px solid var(--canary);
}
.notice-head { border-bottom: 1px solid var(--rule); padding-bottom: 7px; margin-bottom: 9px; }
.notice-index { color: var(--ink-3); }
.notice-title { color: var(--ink); }
.notice-body { color: var(--ink-2); }
-567
View File
@@ -1,567 +0,0 @@
/* ---------------------------------------------------------------------------
Loading screen behaviour.
Three jobs:
1. draw the city - a procedural night skyline flown over on canvas
2. report progress - bound to the load events the game actually emits
3. play ambience - synthesised in the browser, nothing is downloaded
--------------------------------------------------------------------------- */
'use strict';
const $ = (id) => document.getElementById(id);
/* =========================================================================
1. The city
========================================================================= */
/* Small deterministic PRNG so the skyline is stable across a resize. */
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const SKY = {
canvas: $('sky'),
ctx: null,
layers: [],
w: 0,
h: 0,
dpr: 1,
t0: performance.now(),
};
/* Depth layers, far to near. Every layer is darker than the hazy sky behind
it, which is what makes a silhouette read at all; the far ones are lifted
towards the sky colour to stand in for atmosphere. `base` is the ground line
as a fraction of the viewport, so near towers rise right through the frame.
The difference in scroll rate is what reads as flight. */
const LAYER_SPEC = [
{ depth: 0.00, speed: 4, minW: 28, maxW: 66, minH: 0.09, maxH: 0.21, base: 0.76, tone: '#20263a', lit: 0.16, win: 0.30, haze: 0.17 },
{ depth: 0.25, speed: 11, minW: 36, maxW: 90, minH: 0.13, maxH: 0.30, base: 0.83, tone: '#171c2b', lit: 0.20, win: 0.42, haze: 0.14 },
{ depth: 0.50, speed: 24, minW: 50, maxW: 122, minH: 0.19, maxH: 0.42, base: 0.91, tone: '#10141f', lit: 0.24, win: 0.58, haze: 0.10 },
{ depth: 0.75, speed: 50, minW: 74, maxW: 178, minH: 0.28, maxH: 0.62, base: 1.02, tone: '#090c14', lit: 0.26, win: 0.74, haze: 0.06 },
{ depth: 1.00, speed: 100, minW: 104, maxW: 262, minH: 0.44, maxH: 0.98, base: 1.20, tone: '#04060b', lit: 0.19, win: 0.90, haze: 0.00 },
];
function buildLayer(spec, index, w, h) {
const cv = document.createElement('canvas');
cv.width = Math.ceil(w * 2);
cv.height = h;
const c = cv.getContext('2d');
const rand = rng(9173 + index * 7717);
const base = h * spec.base;
const towers = [];
let x = -60;
while (x < cv.width + 60) {
const bw = spec.minW + rand() * (spec.maxW - spec.minW);
const bh = h * (spec.minH + rand() * (spec.maxH - spec.minH));
const top = base - bh;
c.fillStyle = spec.tone;
c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), Math.ceil(h - top));
/* a hairline of sodium bounce along the roof edge */
if (rand() < 0.55) {
c.fillStyle = `rgba(255,159,69,${0.05 + spec.glow * 0.16})`;
c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), 1);
}
/* windows */
const cell = 6 + spec.depth * 8;
const pad = Math.max(2, cell * 0.34);
for (let wy = top + pad * 2; wy < base - pad; wy += cell) {
for (let wx = x + pad; wx < x + bw - pad; wx += cell) {
if (rand() > spec.lit) continue;
const warm = rand();
/* mostly sodium/tungsten, a few cold offices; distant windows are
dimmed so they read as depth rather than as static */
const col = warm < 0.72
? `rgba(255,${150 + Math.floor(rand() * 50)},${70 + Math.floor(rand() * 40)},${(0.35 + rand() * 0.5) * spec.win})`
: `rgba(190,205,225,${(0.18 + rand() * 0.3) * spec.win})`;
c.fillStyle = col;
c.fillRect(Math.round(wx), Math.round(wy), Math.max(1, cell * 0.34), Math.max(1, cell * 0.42));
}
}
/* tall towers get an aviation light */
if (bh > h * 0.5 && rand() < 0.5) {
towers.push({ x: x + bw / 2, y: top - 2, phase: rand() * Math.PI * 2 });
}
x += bw + 2 + rand() * 16;
}
return { cv, spec, towers, base };
}
function resizeSky() {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
SKY.dpr = dpr;
SKY.w = window.innerWidth;
SKY.h = window.innerHeight;
SKY.canvas.width = Math.floor(SKY.w * dpr);
SKY.canvas.height = Math.floor(SKY.h * dpr);
SKY.ctx = SKY.canvas.getContext('2d');
SKY.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
SKY.layers = LAYER_SPEC.map((s, i) => buildLayer(s, i, SKY.w, SKY.h));
}
function drawSky(now) {
const { ctx, w, h } = SKY;
if (!ctx) return;
const t = (now - SKY.t0) / 1000;
/* Sky: light pollution rather than stars. It has to stay brighter than the
buildings all the way down, otherwise there is no silhouette to see. */
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0.00, '#080b14');
g.addColorStop(0.34, '#121627');
g.addColorStop(0.60, '#23202f');
g.addColorStop(0.78, '#412c27');
g.addColorStop(0.90, '#6b4527');
g.addColorStop(1.00, '#8a5726');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
/* the haze dome over downtown */
const halo = ctx.createRadialGradient(w * 0.6, h * 0.9, 0, w * 0.6, h * 0.9, h * 0.85);
halo.addColorStop(0, 'rgba(255,159,69,0.34)');
halo.addColorStop(0.45, 'rgba(255,140,60,0.12)');
halo.addColorStop(1, 'rgba(255,140,60,0)');
ctx.fillStyle = halo;
ctx.fillRect(0, 0, w, h);
/* a slow descent: everything drifts down and grows a touch over time */
const descend = Math.sin(t * 0.06) * 10 + t * 0.9;
for (const layer of SKY.layers) {
const { cv, spec } = layer;
const span = cv.width / 2;
let ox = -((t * spec.speed) % span);
const oy = descend * (0.25 + spec.depth * 0.9);
ctx.globalAlpha = 1;
ctx.drawImage(cv, ox, oy, cv.width, h);
/* aviation lights blink on their own phase */
for (const tw of layer.towers) {
const blink = Math.sin(t * 1.5 + tw.phase);
if (blink < 0.72) continue;
const px = tw.x + ox;
const py = tw.y + oy - h * 0.06;
ctx.fillStyle = `rgba(226,72,52,${(blink - 0.72) / 0.28})`;
ctx.beginPath();
ctx.arc(px, py, 1.6, 0, Math.PI * 2);
ctx.fill();
if (px > span) {
ctx.beginPath();
ctx.arc(px - span, py, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
/* Aerial perspective: a warm veil laid over everything drawn so far, so
each nearer layer sits in front of progressively more atmosphere. */
if (spec.haze > 0) {
ctx.fillStyle = `rgba(104,72,50,${spec.haze})`;
ctx.fillRect(0, 0, w, h);
}
}
ctx.globalAlpha = 1;
}
/* =========================================================================
2. Progress, bound to the game's own load events
========================================================================= */
const Progress = {
shown: 0,
target: 0,
step: 'Waiting for the game',
set(fraction, step) {
if (typeof fraction === 'number' && isFinite(fraction)) {
/* never let it walk backwards - it reads as a fault */
this.target = Math.max(this.target, Math.min(1, Math.max(0, fraction)));
}
if (step) this.step = step;
},
tick() {
/* ease towards the real figure so long steps still feel alive */
this.shown += (this.target - this.shown) * 0.08;
const pct = Math.min(100, Math.round(this.shown * 100));
$('bar').style.width = (this.shown * 100).toFixed(2) + '%';
$('pct').firstChild.nodeValue = String(pct);
if ($('step').textContent !== this.step) $('step').textContent = this.step;
},
};
/* Turns the game's internal init-function names into something a player can read. */
const STEP_NAMES = {
MAP: 'Loading the map',
BEFORE_MAP_LOADED: 'Preparing the world',
AFTER_MAP_LOADED: 'Placing the world',
SESSION_INIT: 'Joining the session',
INIT_BEFORE_MAP_LOADED: 'Starting up',
INIT_AFTER_MAP_LOADED: 'Finishing up',
INIT_SESSION: 'Joining the session',
};
window.addEventListener('message', (event) => {
const d = event.data || {};
switch (d.eventName) {
case 'loadProgress':
Progress.set(d.loadFraction);
break;
case 'startInitFunction':
Progress.set(null, STEP_NAMES[d.type] || 'Starting up');
break;
case 'initFunctionInvoking':
if (typeof d.idx === 'number' && typeof d.count === 'number' && d.count > 0) {
Progress.set(null, `${STEP_NAMES[d.type] || 'Starting up'} ${d.idx}/${d.count}`);
}
break;
case 'startDataFileEntries':
Progress.set(null, `Streaming ${d.count} asset packs`);
break;
case 'performMapLoadFunction':
Progress.set(null, 'Building the map');
break;
case 'startWarning':
case 'onLogLine':
if (d.message) Progress.set(null, String(d.message).slice(0, 70));
break;
/* --- our own messages, sent from rp_ui once the client is running --- */
case 'rp:rules':
if (Array.isArray(d.rules) && d.rules.length) {
Notices.load(d.rules);
}
break;
case 'rp:server':
if (d.name) document.querySelector('.wordmark').textContent = d.name;
if (d.serial) $('serial').textContent = d.serial;
if (d.status) $('hostcount').textContent = d.status;
break;
case 'rp:progress':
Progress.set(d.fraction, d.step);
break;
/* the world is ready: dissolve into the live camera behind us */
case 'rp:handover':
Progress.set(1, 'Ready');
document.body.classList.add('handover');
break;
}
});
/* =========================================================================
3. Posted notices
========================================================================= */
const Notices = {
items: [
{ title: 'Stay in character', body: 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
{ title: 'Value your life', body: 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
{ title: 'No random deathmatch', body: 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
{ title: 'Do not power game', body: 'Give people a fair chance to react. Winning is not the point of a scene.' },
{ title: 'Leave the scene alive', body: 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
],
i: 0,
timer: null,
load(items) {
this.items = items;
this.i = 0;
this.render();
this.schedule();
},
render() {
const item = this.items[this.i % this.items.length];
const title = $('noticeTitle');
const body = $('noticeBody');
/* restart the entry animation by reflowing the nodes */
title.style.animation = 'none';
body.style.animation = 'none';
void title.offsetWidth;
title.style.animation = '';
body.style.animation = '';
title.textContent = item.title;
body.textContent = item.body;
$('noticeIndex').textContent = `${(this.i % this.items.length) + 1} / ${this.items.length}`;
},
schedule() {
clearInterval(this.timer);
this.timer = setInterval(() => {
this.i += 1;
this.render();
}, 7600);
},
};
/* =========================================================================
4. Ambience - synthesised, never downloaded
========================================================================= */
class Ambience {
constructor() {
this.ctx = null;
this.master = null;
this.volume = 0.45;
this.muted = false;
this.started = false;
}
/* A short noise burst with exponential decay makes a serviceable hall. */
makeImpulse(seconds, decay) {
const rate = this.ctx.sampleRate;
const len = Math.floor(rate * seconds);
const buf = this.ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const data = buf.getChannelData(ch);
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return buf;
}
noiseBuffer(seconds) {
const rate = this.ctx.sampleRate;
const buf = this.ctx.createBuffer(1, rate * seconds, rate);
const d = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < d.length; i++) {
/* brown-ish noise: closer to distant traffic than white hiss */
const white = Math.random() * 2 - 1;
last = (last + 0.02 * white) / 1.02;
d[i] = last * 3.2;
}
return buf;
}
start() {
if (this.started) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor();
this.started = true;
const ctx = this.ctx;
this.master = ctx.createGain();
this.master.gain.value = 0;
this.master.connect(ctx.destination);
const verb = ctx.createConvolver();
verb.buffer = this.makeImpulse(4.2, 2.6);
const verbGain = ctx.createGain();
verbGain.gain.value = 0.55;
verb.connect(verbGain).connect(this.master);
const dry = ctx.createGain();
dry.gain.value = 0.75;
dry.connect(this.master);
const bus = ctx.createGain();
bus.connect(dry);
bus.connect(verb);
/* --- the pad: a minor triad that never quite resolves --------------- */
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 420;
lp.Q.value = 0.7;
lp.connect(bus);
const lfo = ctx.createOscillator();
lfo.frequency.value = 0.031;
const lfoAmt = ctx.createGain();
lfoAmt.gain.value = 210;
lfo.connect(lfoAmt).connect(lp.frequency);
lfo.start();
[55, 82.41, 110, 130.81, 164.81].forEach((freq, i) => {
const osc = ctx.createOscillator();
osc.type = i % 2 ? 'sine' : 'triangle';
osc.frequency.value = freq;
osc.detune.value = (i - 2) * 4;
const g = ctx.createGain();
g.gain.value = 0.16 / (1 + i * 0.35);
/* each voice breathes on its own slow cycle */
const breath = ctx.createOscillator();
breath.frequency.value = 0.017 + i * 0.009;
const breathAmt = ctx.createGain();
breathAmt.gain.value = g.gain.value * 0.6;
breath.connect(breathAmt).connect(g.gain);
breath.start();
osc.connect(g).connect(lp);
osc.start();
});
/* --- distant traffic ------------------------------------------------ */
const noise = ctx.createBufferSource();
noise.buffer = this.noiseBuffer(8);
noise.loop = true;
const nf = ctx.createBiquadFilter();
nf.type = 'bandpass';
nf.frequency.value = 240;
nf.Q.value = 0.55;
const ng = ctx.createGain();
ng.gain.value = 0.09;
noise.connect(nf).connect(ng).connect(bus);
noise.start();
const wind = ctx.createOscillator();
wind.frequency.value = 0.043;
const windAmt = ctx.createGain();
windAmt.gain.value = 130;
wind.connect(windAmt).connect(nf.frequency);
wind.start();
/* --- a siren, far away, every now and then -------------------------- */
const siren = () => {
if (!this.ctx || this.ctx.state === 'closed') return;
const t = ctx.currentTime;
const o = ctx.createOscillator();
o.type = 'triangle';
const g = ctx.createGain();
const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
g.gain.value = 0;
o.frequency.setValueAtTime(620, t);
o.frequency.linearRampToValueAtTime(880, t + 0.7);
o.frequency.linearRampToValueAtTime(620, t + 1.4);
g.gain.linearRampToValueAtTime(0.014, t + 0.6);
g.gain.linearRampToValueAtTime(0.0, t + 2.6);
if (pan) {
pan.pan.value = Math.random() * 1.6 - 0.8;
o.connect(g).connect(pan).connect(verb);
} else {
o.connect(g).connect(verb);
}
o.start(t);
o.stop(t + 2.8);
setTimeout(siren, 24000 + Math.random() * 40000);
};
setTimeout(siren, 12000 + Math.random() * 12000);
this.applyVolume(1.8);
}
/* perceptual curve: a linear fader sounds wrong */
applyVolume(rampSeconds) {
if (!this.master) return;
const target = this.muted ? 0 : Math.pow(this.volume, 2.2) * 0.9;
const now = this.ctx.currentTime;
this.master.gain.cancelScheduledValues(now);
this.master.gain.setValueAtTime(this.master.gain.value, now);
this.master.gain.linearRampToValueAtTime(target, now + (rampSeconds || 0.12));
}
setVolume(v) {
this.volume = Math.min(1, Math.max(0, v));
if (this.volume > 0) this.muted = false;
this.applyVolume();
}
toggleMute() {
this.muted = !this.muted;
this.applyVolume();
return this.muted;
}
}
const ambience = new Ambience();
function wireAudio() {
const vol = $('vol');
const volval = $('volval');
const mute = $('mute');
const hint = $('audiohint');
const saved = parseInt(window.localStorage.getItem('rp.volume') || '45', 10);
vol.value = String(isFinite(saved) ? saved : 45);
volval.textContent = vol.value;
ambience.volume = Number(vol.value) / 100;
const tryStart = () => {
ambience.start();
if (ambience.ctx && ambience.ctx.state === 'suspended') {
hint.hidden = false;
return false;
}
hint.hidden = true;
return true;
};
/* Autoplay may be blocked; the first input of any kind releases it. */
if (!tryStart()) {
const release = () => {
if (ambience.ctx) ambience.ctx.resume();
hint.hidden = true;
window.removeEventListener('pointerdown', release);
window.removeEventListener('keydown', release);
};
window.addEventListener('pointerdown', release);
window.addEventListener('keydown', release);
}
vol.addEventListener('input', () => {
volval.textContent = vol.value;
ambience.setVolume(Number(vol.value) / 100);
mute.classList.toggle('off', Number(vol.value) === 0);
window.localStorage.setItem('rp.volume', vol.value);
});
mute.addEventListener('click', () => {
const muted = ambience.toggleMute();
mute.classList.toggle('off', muted);
});
}
/* =========================================================================
boot
========================================================================= */
function serial() {
const n = Math.floor(Math.random() * 9000 + 1000);
const m = Math.floor(Math.random() * 900 + 100);
return `${new Date().getFullYear()}-${n}${m}`;
}
function frame(now) {
drawSky(now);
Progress.tick();
requestAnimationFrame(frame);
}
window.addEventListener('resize', resizeSky);
document.addEventListener('DOMContentLoaded', () => {
$('serial').textContent = serial();
resizeSky();
Notices.render();
Notices.schedule();
wireAudio();
requestAnimationFrame(frame);
});
Binary file not shown.
Binary file not shown.
-72
View File
@@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Los Santos Roleplay</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
</head>
<body>
<!-- procedural night-city flyover; no external assets -->
<canvas id="sky" aria-hidden="true"></canvas>
<div class="grain" aria-hidden="true"></div>
<div class="scrim" aria-hidden="true"></div>
<div class="vignette" aria-hidden="true"></div>
<main class="rail">
<header class="file reveal" style="--d:0ms">
<div class="eyebrow">City of Los Santos &middot; Office of Records</div>
<div class="serial data">
FILE <span id="serial">0000-0000</span> &nbsp;/&nbsp; RESIDENCY APPLICATION
</div>
</header>
<section class="brand">
<h1 class="wordmark" id="wordmark">LOS SANTOS</h1>
<div class="tagrow reveal" style="--d:520ms">
<span class="tagline">ROLEPLAY</span>
<span class="dash" aria-hidden="true"></span>
<span class="data" id="hostcount">Connecting</span>
</div>
</section>
<section class="notices reveal" style="--d:760ms" aria-live="polite">
<div class="notice-head">
<span class="eyebrow">Posted notice</span>
<span class="notice-index data" id="noticeIndex">1 / 5</span>
</div>
<div class="notice" id="notice">
<h2 class="notice-title" id="noticeTitle">Stay in character</h2>
<p class="notice-body" id="noticeBody">Loading the city.</p>
</div>
</section>
<footer class="load reveal" style="--d:940ms">
<div class="loadhead">
<span class="step data" id="step">Waiting for the game</span>
<span class="pct" id="pct">0<i>%</i></span>
</div>
<div class="track"><i class="bar" id="bar"></i></div>
<div class="fader">
<button class="mute" id="mute" type="button" aria-label="Mute ambience">
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
<path id="spk" d="M3 6h2.5L9 3v10L5.5 10H3z" fill="currentColor"/>
<path id="wave" d="M11 5.6a3.4 3.4 0 0 1 0 4.8M12.9 3.7a6 6 0 0 1 0 8.6"
fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</button>
<label class="sr-only" for="vol">Ambience volume</label>
<input id="vol" type="range" min="0" max="100" value="45" step="1">
<span class="volval data" id="volval">45</span>
</div>
<p class="audiohint data" id="audiohint" hidden>Click anywhere to start the ambience</p>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
-182
View File
@@ -1,182 +0,0 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is a set of documents from the city's Office of Vital Records,
lying on a dark desk while the city runs on behind them. Paper is the only
surface; the game is the room the desk is in. Nothing here is a dark glass
panel, because a city clerk does not hand you one.
Three typographic voices, and the rule between them is literal:
preprinted Archivo, expanded, caps what the form was printed with
typed IBM Plex Mono what somebody entered on it
prose IBM Plex Sans plain-English notes in the margin
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: block;
}
@font-face {
font-family: 'Plex Sans';
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* --- the room ---------------------------------------------------------- */
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
--desk: #1a1b16; /* desk surface where the lamp reaches it */
--lamp: rgba(224, 196, 128, 0.10);
/* --- the paper --------------------------------------------------------- */
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
--stock-3: #b3a888; /* deepest fold */
/* --- what is written on it --------------------------------------------- */
--ink: #191c18; /* ballpoint black with a green cast */
--ink-2: #4e5449; /* second-rank text */
--ink-3: #7d8175; /* captions, disabled, ruled lines */
/* --- the three official inks, each with exactly one job ---------------- */
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
--canary-dp: #a8801d;
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
/* rules printed on the form */
--rule: rgba(25, 28, 24, 0.22);
--rule-soft: rgba(25, 28, 24, 0.11);
--rule-firm: rgba(25, 28, 24, 0.55);
/* paper fibre, laid over every sheet at low opacity */
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
/* a sheet of paper casts a real shadow onto the desk */
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
--sheet: clamp(360px, 27vw, 460px);
--sheet-w: clamp(440px, 34vw, 580px);
--gut-x: clamp(26px, 2.4vw, 40px);
--gut-y: clamp(22px, 2.2vw, 34px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--room);
color: var(--ink);
font-family: 'Plex Sans', system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------------------------------------------------------------------------
The three voices.
--------------------------------------------------------------------------- */
/* preprinted: everything the form arrived with */
.pp,
.eyebrow,
.head,
label,
.btn,
.tab,
.ctrl-label,
.charname,
.spawnname,
.stagechip span {
font-family: 'Archivo', system-ui, sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* typed: everything a person put on the form */
.data,
.typed,
input,
textarea,
.ctrl-val,
.stepval,
.charmeta,
.charmoney,
.counter,
.stagechip em,
.filetag {
font-family: 'Plex Mono', ui-monospace, monospace;
font-variation-settings: normal;
text-transform: none;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
/* prose: the plain-English notes, the only voice allowed sentence case */
.sub,
.hint,
.spawnblurb,
.switch {
font-family: 'Plex Sans', system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
}
.eyebrow {
font-size: 10px;
font-variation-settings: 'wdth' 104, 'wght' 600;
letter-spacing: 0.19em;
color: var(--ink-3);
}
.data {
font-size: 11.5px;
color: var(--ink-2);
}
.rule { height: 1px; background: var(--rule); border: 0; }
/* Focus is always the canary, always visible, and never subtle. */
:focus-visible {
outline: 2px solid var(--canary-dp);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
-17
View File
@@ -1,17 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_selftest'
description 'Exercises the account/character data path and proves it survives a restart'
version '1.0.0'
shared_scripts {
'@rp_core/shared/config.lua',
'@rp_core/shared/util.lua',
'@rp_core/shared/appearance.lua',
}
server_scripts {
'@rp_db/lib/db.lua',
'server/test.lua',
}
-144
View File
@@ -1,144 +0,0 @@
-- ---------------------------------------------------------------------------
-- End-to-end check of the account and character data path.
--
-- The first run creates a marker account. Every later run finds it already
-- there and verifies it came back byte-for-byte, which is what "persists
-- between restarts" actually means. Run `selftest_reset` to clear it.
-- ---------------------------------------------------------------------------
local MARKER = 'zz_selftest'
local PASSWORD = 'correct horse 7'
local passed, failed = 0, 0
local function check(name, ok, detail)
if ok then
passed = passed + 1
print(('^2 PASS^7 %s'):format(name))
else
failed = failed + 1
print(('^1 FAIL^7 %s ^1%s^7'):format(name, detail or ''))
end
end
local function hash(pw)
local p = promise.new()
exports.rp_core:hashPassword(pw, function(h, err) p:resolve({ h = h, err = err }) end)
local r = Citizen.Await(p)
return r.h, r.err
end
local function verify(pw, stored)
local p = promise.new()
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
return Citizen.Await(p).ok
end
CreateThread(function()
if not DB.WaitReady(30000) then
print('^1[selftest] database never became ready^7')
return
end
print('^5[selftest]^7 ---- account + character data path ----')
-- 1. password hashing ----------------------------------------------------
local h, herr = hash(PASSWORD)
check('scrypt hash produced', type(h) == 'string' and h:sub(1, 7) == 'scrypt$', tostring(herr))
check('correct password verifies', verify(PASSWORD, h))
check('wrong password rejected', not verify(PASSWORD .. 'x', h))
check('empty password rejected', not verify('', h))
local h2 = hash(PASSWORD)
check('same password hashes differently (salted)', h ~= h2)
check('second hash also verifies', verify(PASSWORD, h2))
check('corrupt hash rejected safely', not verify(PASSWORD, 'not-a-hash'))
-- 2. the account row ------------------------------------------------------
local existing = DB.Single('SELECT id, password_hash, created_at FROM accounts WHERE username = ?', { MARKER })
local firstRun = existing == nil
local accountId
if firstRun then
accountId = DB.Insert(
'INSERT INTO accounts (username, password_hash, license) VALUES (?, ?, ?)',
{ MARKER, h, 'selftest-license' })
check('account created', type(accountId) == 'number' and accountId > 0)
print('^3[selftest]^7 marker account created - restart the server to prove persistence')
else
accountId = existing.id
check('account survived restart', true)
check('stored hash still verifies after restart', verify(PASSWORD, existing.password_hash))
print(('^5[selftest]^7 marker account has existed since %s'):format(tostring(existing.created_at)))
end
-- 3. the character row, including the appearance JSON ---------------------
local appearance = Appearance.Sanitise({
model = 'f',
parents = { father = 12, mother = 33, shapeMix = 0.62, skinMix = 0.25 },
hair = { style = 14, colour = 9, highlight = 2 },
features = { ['0'] = 0.4, ['13'] = -0.75 },
overlays = { eyebrows = { index = 5, opacity = 0.8, colour = 3 } },
components = { jacket = { drawable = 42, texture = 3 } },
})
local charId = DB.Scalar('SELECT id FROM characters WHERE account_id = ? AND deleted = 0 LIMIT 1', { accountId })
if not charId then
charId = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance)
VALUES (?, 0, ?, ?, ?, 'f', ?, ?)
]], { accountId, 'Zztest', 'Persistence', '1991-04-18',
'Created by the self test.', json.encode(appearance) })
check('character created', type(charId) == 'number' and charId > 0)
else
check('character survived restart', true)
end
local row = DB.Single('SELECT * FROM characters WHERE id = ?', { charId })
check('character reads back', row ~= nil)
if row then
local back = json.decode(row.appearance)
check('appearance JSON round trip', json.encode(Appearance.Sanitise(back)) == json.encode(appearance),
'appearance differs after a round trip')
check('float precision kept', math.abs(back.parents.shapeMix - 0.62) < 0.0001,
tostring(back.parents.shapeMix))
check('negative feature kept', math.abs(back.features['13'] + 0.75) < 0.0001,
tostring(back.features['13']))
check('starting balances applied', tonumber(row.cash) == Config.Money.startingCash
and tonumber(row.bank) == Config.Money.startingBank,
('cash=%s bank=%s'):format(tostring(row.cash), tostring(row.bank)))
end
-- 4. the constraints the session code relies on ---------------------------
local dupe, dupeErr = DB.Insert(
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', { MARKER, h })
check('duplicate username refused by the database', dupe == nil, tostring(dupe))
local dupeName = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
VALUES (?, 1, 'Zztest', 'Persistence', '1991-04-18', 'f')
]], { accountId })
check('duplicate character name refused by the database', dupeName == nil)
local orphan = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
VALUES (999999, 0, 'Zzorphan', 'Nobody', '1991-04-18', 'm')
]], {})
check('character cannot reference a missing account', orphan == nil)
print(('^5[selftest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
if failed == 0 then
print(firstRun and '^2[selftest] data path OK (first run)^7' or '^2[selftest] data path OK and persistent^7')
else
print('^1[selftest] PROBLEMS FOUND^7')
end
end)
RegisterCommand('selftest_reset', function()
CreateThread(function()
DB.Update('DELETE FROM accounts WHERE username = ?', { MARKER }) -- characters cascade
print('^3[selftest]^7 marker account removed')
end)
end, true)
-19
View File
@@ -1,19 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_session'
description 'Connection gate, accounts, characters and spawn'
author 'Los Santos RP'
version '1.0.0'
shared_scripts {
'@rp_core/shared/config.lua',
'@rp_core/shared/util.lua',
'@rp_core/shared/appearance.lua',
}
server_scripts {
'@rp_db/lib/db.lua',
'@rp_core/lib/callbacks_server.lua',
'server/main.lua',
}
-438
View File
@@ -1,438 +0,0 @@
-- ---------------------------------------------------------------------------
-- rp_session - everything between "player opened the server" and "player is
-- standing in the world": ban gate, accounts, characters, spawn.
--
-- The client drives the UI, but every decision is made here. The client may
-- only ever ask; it can never assert that it is logged in, that a character
-- belongs to it, or where it spawned.
-- ---------------------------------------------------------------------------
local sessions = {} -- [src] = { license, ip, accountId, username, role, stage }
-- This build of FXServer ships no `hardcap` resource (it only advertises the
-- name in /info.json), and the bundled `chat` and `monitor` resources are not
-- started, so capacity has to be enforced here - see the deferral below.
-- Kept as a guard in case a future artefact does ship one.
CreateThread(function()
if GetResourceState('hardcap') == 'started' then
StopResource('hardcap')
print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource')
end
end)
local STAGE_AUTH = 'auth'
local STAGE_CHARS = 'characters'
local STAGE_SPAWN = 'spawn'
local STAGE_LIVE = 'live'
-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------
local function licenseOf(src)
for i = 0, GetNumPlayerIdentifiers(src) - 1 do
local id = GetPlayerIdentifier(src, i)
if id and id:sub(1, 8) == 'license:' then return id:sub(9) end
end
return nil
end
local function hashPassword(pw)
local p = promise.new()
exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end)
local r = Citizen.Await(p)
return r.hash, r.err
end
local function verifyPassword(pw, stored)
local p = promise.new()
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
local r = Citizen.Await(p)
if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end
return r.ok == true
end
local function recordAttempt(identity, ip, success)
DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)',
{ identity, ip or DB.NULL, success and 1 or 0 })
end
--- True when this identity has burned through its allowance recently.
local function throttled(identity)
local n = DB.Scalar(
'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)',
{ identity, Config.Session.attemptWindowS })
return (tonumber(n) or 0) >= Config.Session.maxAttempts
end
local function characterRows(accountId)
return DB.Query([[
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
cash, bank, job, job_grade, playtime, last_played_at
FROM characters
WHERE account_id = ? AND deleted = 0
ORDER BY slot ASC, id ASC
]], { accountId }) or {}
end
--- Trim a DB row down to what the character-select screen is allowed to see.
local function characterCard(row)
return {
id = row.id,
firstName = row.first_name,
lastName = row.last_name,
dob = tostring(row.dob):sub(1, 10),
gender = row.gender,
backstory = row.backstory,
cash = math.floor(tonumber(row.cash) or 0),
bank = math.floor(tonumber(row.bank) or 0),
job = row.job,
playtime = math.floor(tonumber(row.playtime) or 0),
lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil,
appearance= row.appearance and json.decode(row.appearance) or nil,
}
end
local function sessionOf(src, requiredStage)
local s = sessions[src]
if not s then return nil, 'no session' end
if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end
return s
end
-- ---------------------------------------------------------------------------
-- connection gate
-- ---------------------------------------------------------------------------
AddEventHandler('playerConnecting', function(_, _, deferrals)
local src = source
deferrals.defer()
Wait(0)
local license = licenseOf(src)
if not license then
return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.')
end
-- Capacity. This replaces the stock `hardcap` resource, which is stopped in
-- server.cfg so that everything running here is our own code.
local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48
local online = #GetPlayers() -- the joining player is not counted yet
if online >= maxClients then
return deferrals.done(
('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients))
end
deferrals.update('Checking your record...')
if not DB.WaitReady(15000) then
return deferrals.done('The server database is not available right now. Try again in a minute.')
end
local ban = DB.Single([[
SELECT id, ban_reason, ban_until
FROM accounts
WHERE license = ? AND banned = 1
AND (ban_until IS NULL OR ban_until > NOW())
LIMIT 1
]], { license })
if ban then
local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently'
return deferrals.done(('You are banned%s.\n\nReason: %s')
:format(until_, ban.ban_reason or 'no reason recorded'))
end
-- clear bans that have run out so the account is usable again
DB.UpdateAsync([[
UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL
WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW()
]], { license })
sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH }
deferrals.done()
end)
AddEventHandler('playerDropped', function()
sessions[source] = nil
end)
-- ---------------------------------------------------------------------------
-- callbacks: authentication
-- ---------------------------------------------------------------------------
Core.RegisterCallback('session:state', function(src)
local s = sessions[src]
if not s then return nil, 'no session' end
return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('auth:register', function(src, data)
local s, e = sessionOf(src, STAGE_AUTH)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local username = Util.trim(data.username or '')
local password = data.password or ''
local ok, why = Rules.checkUsername(username)
if not ok then return nil, why end
ok, why = Rules.checkPassword(password)
if not ok then return nil, why end
if throttled(s.license) then
return nil, 'Too many attempts. Wait a few minutes and try again.'
end
local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username })
if taken then
recordAttempt(s.license, s.ip, false)
return nil, 'That username is already taken'
end
local hash, herr = hashPassword(password)
if not hash then
print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr)))
return nil, 'Could not create the account, try again'
end
local id, ierr = DB.Insert(
'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)',
{ username, hash, s.license, s.ip or DB.NULL })
if not id then
-- unique key race: someone registered the same name a moment ago
print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr)))
return nil, 'That username is already taken'
end
recordAttempt(s.license, s.ip, true)
s.accountId = id
s.username = username
s.role = 'player'
s.stage = STAGE_CHARS
exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license })
print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src)))
return { username = username, characters = {}, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('auth:login', function(src, data)
local s, e = sessionOf(src, STAGE_AUTH)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local username = Util.trim(data.username or '')
local password = data.password or ''
if username == '' or password == '' then return nil, 'Enter your username and password' end
if throttled(s.license) then
return nil, 'Too many failed attempts. Wait a few minutes and try again.'
end
local acc = DB.Single(
'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1',
{ username })
-- Always run a verification so a missing username and a wrong password take
-- the same amount of time and cannot be told apart from outside.
local stored = acc and acc.password_hash
or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
local good = verifyPassword(password, stored)
if not acc or not good then
recordAttempt(s.license, s.ip, false)
return nil, 'Incorrect username or password'
end
if acc.banned == 1 then
-- re-checked against NOW() so an expired ban does not keep locking them out
local active = DB.Scalar(
'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())',
{ acc.id })
if active == 1 then
return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded')
end
end
recordAttempt(s.license, s.ip, true)
DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?',
{ s.ip or DB.NULL, s.license, acc.id })
s.accountId = acc.id
s.username = acc.username
s.role = acc.role
s.stage = STAGE_CHARS
exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license })
local cards = {}
for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end
print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username))
return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars }
end)
-- ---------------------------------------------------------------------------
-- callbacks: characters
-- ---------------------------------------------------------------------------
Core.RegisterCallback('char:list', function(src)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local cards = {}
for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end
return { characters = cards, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('char:create', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local first = Util.properName(data.firstName or '')
local last = Util.properName(data.lastName or '')
local ok, why = Rules.checkName(first)
if not ok then return nil, 'First name: ' .. why end
ok, why = Rules.checkName(last)
if not ok then return nil, 'Last name: ' .. why end
ok, why = Rules.checkDob(data.dob or '')
if not ok then return nil, 'Date of birth: ' .. why end
local gender = (data.gender == 'f') and 'f' or 'm'
local backstory = Util.trim(data.backstory or '')
if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end
local existing = tonumber(DB.Scalar(
'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0
if existing >= Config.MaxChars then
return nil, ('You already have %d characters'):format(Config.MaxChars)
end
local clash = DB.Scalar(
'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last })
if clash then return nil, 'Someone on this server already has that name' end
local appearance = Appearance.Sanitise(data.appearance)
local id, ierr = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory,
appearance, cash, bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
s.accountId, existing, first, last, data.dob, gender,
backstory ~= '' and backstory or DB.NULL,
json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank,
})
if not id then
print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr)))
return nil, 'Could not create that character'
end
print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id))
local row = DB.Single([[
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
cash, bank, job, job_grade, playtime, last_played_at
FROM characters WHERE id = ?
]], { id })
return { character = characterCard(row) }
end)
Core.RegisterCallback('char:delete', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local id = tonumber(type(data) == 'table' and data.id or nil)
if not id then return nil, 'malformed request' end
-- ownership is checked in the WHERE clause, so a forged id changes nothing
local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0',
{ id, s.accountId })
if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end
print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id))
return { deleted = id }
end)
Core.RegisterCallback('char:select', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local id = tonumber(type(data) == 'table' and data.id or nil)
if not id then return nil, 'malformed request' end
local row = DB.Single([[
SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0
]], { id, s.accountId })
if not row then return nil, 'That character is not yours' end
exports.rp_core:attachCharacter(src, row)
s.stage = STAGE_SPAWN
s.charId = id
local last = row.position and json.decode(row.position) or nil
local spawns = {}
for _, sp in ipairs(Config.SpawnPoints) do
spawns[#spawns + 1] = {
id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map,
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w },
cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z },
look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } },
}
end
return {
character = characterCard(row),
appearance = row.appearance and json.decode(row.appearance) or nil,
spawns = spawns,
lastPosition = last,
}
end)
-- ---------------------------------------------------------------------------
-- callbacks: spawn
-- ---------------------------------------------------------------------------
Core.RegisterCallback('spawn:confirm', function(src, data)
local s, e = sessionOf(src, STAGE_SPAWN)
if not s then return nil, e end
local choice = type(data) == 'table' and data.id or nil
local coords
if choice == 'last' then
local p = exports.rp_core:getCharacter(src)
local last = p and p.position
if last and last.x then
coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 }
end
end
if not coords then
for _, sp in ipairs(Config.SpawnPoints) do
if sp.id == choice then
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
break
end
end
end
if not coords then
local sp = Config.SpawnPoints[1]
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
end
s.stage = STAGE_LIVE
exports.rp_core:setSpawned(src, true)
local char = exports.rp_core:getCharacter(src)
print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName))
TriggerClientEvent('rp:session:spawned', src, coords)
return { coords = coords, character = char }
end)
-188
View File
@@ -1,188 +0,0 @@
-- ---------------------------------------------------------------------------
-- Scripted cameras for the pre-spawn screens.
--
-- One camera object is reused throughout. Everything is driven from a single
-- render thread that eases the live values towards target values, so a change
-- requested by the UI never snaps.
-- ---------------------------------------------------------------------------
Cam = {}
local cam = nil
local mode = 'off' -- 'off' | 'shots' | 'orbit' | 'fly'
local renderThread = false
-- live and target orbit values
local orbit = {
angle = 180.0, targetAngle = 180.0,
radius = 1.6, targetRadius = 1.6,
height = 0.65, targetHeight = 0.65,
focus = nil, -- entity to orbit
pitch = 0.0, targetPitch = 0.0,
}
local shots = { list = {}, index = 0, startedAt = 0, duration = 22000 }
local fly = { from = nil, to = nil, lookFrom = nil, lookTo = nil, startedAt = 0, duration = 3000, done = nil }
local function ensureCam()
if cam and DoesCamExist(cam) then return cam end
cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
SetCamActive(cam, true)
RenderScriptCams(true, false, 0, true, true)
return cam
end
local function lerp(a, b, t) return a + (b - a) * t end
local function vlerp(a, b, t)
return vector3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t))
end
-- smoothstep, so fly-throughs start and end at rest
local function ease(t)
t = math.max(0.0, math.min(1.0, t))
return t * t * (3.0 - 2.0 * t)
end
local function startRenderThread()
if renderThread then return end
renderThread = true
CreateThread(function()
while mode ~= 'off' do
local c = ensureCam()
if mode == 'shots' then
local shot = shots.list[shots.index]
if shot then
local t = (GetGameTimer() - shots.startedAt) / shots.duration
if t >= 1.0 then
shots.index = (shots.index % #shots.list) + 1
shots.startedAt = GetGameTimer()
shot = shots.list[shots.index]
t = 0.0
end
-- a slow linear dolly reads as a helicopter, not a spline
local pos = vlerp(shot.from, shot.to, t)
SetCamCoord(c, pos.x, pos.y, pos.z)
PointCamAtCoord(c, shot.look.x, shot.look.y, shot.look.z)
end
elseif mode == 'orbit' then
orbit.angle = lerp(orbit.angle, orbit.targetAngle, 0.14)
orbit.radius = lerp(orbit.radius, orbit.targetRadius, 0.10)
orbit.height = lerp(orbit.height, orbit.targetHeight, 0.10)
orbit.pitch = lerp(orbit.pitch, orbit.targetPitch, 0.10)
local ent = orbit.focus
if ent and DoesEntityExist(ent) then
local base = GetEntityCoords(ent)
local rad = math.rad(orbit.angle)
local px = base.x + math.sin(rad) * orbit.radius
local py = base.y + math.cos(rad) * orbit.radius
local pz = base.z + orbit.height
SetCamCoord(c, px, py, pz)
PointCamAtCoord(c, base.x, base.y, base.z + orbit.height - orbit.pitch)
end
elseif mode == 'fly' then
local t = ease((GetGameTimer() - fly.startedAt) / fly.duration)
local pos = vlerp(fly.from, fly.to, t)
local look = vlerp(fly.lookFrom, fly.lookTo, t)
SetCamCoord(c, pos.x, pos.y, pos.z)
PointCamAtCoord(c, look.x, look.y, look.z)
if t >= 1.0 then
mode = 'idle'
if fly.done then
local fn = fly.done
fly.done = nil
fn()
end
end
end
Wait(0)
end
renderThread = false
end)
end
--- Slow establishing shots over the city, cycled forever.
function Cam.Shots(list)
shots.list = list
shots.index = 1
shots.startedAt = GetGameTimer()
mode = 'shots'
ensureCam()
SetCamFov(cam, 42.0)
startRenderThread()
end
--- Orbit an entity. `snap` places the camera immediately instead of easing in.
function Cam.Orbit(entity, radius, height, angle, snap)
orbit.focus = entity
orbit.targetRadius = radius or orbit.targetRadius
orbit.targetHeight = height or orbit.targetHeight
if angle then orbit.targetAngle = angle end
if snap then
orbit.radius = orbit.targetRadius
orbit.height = orbit.targetHeight
orbit.angle = orbit.targetAngle
end
mode = 'orbit'
ensureCam()
SetCamFov(cam, 34.0)
startRenderThread()
end
--- Framing presets used by the creator's section tabs.
function Cam.OrbitFrame(radius, height, pitch)
orbit.targetRadius = radius
orbit.targetHeight = height
orbit.targetPitch = pitch or 0.0
end
function Cam.Nudge(deltaDegrees)
orbit.targetAngle = orbit.targetAngle + deltaDegrees
end
function Cam.SetAngle(deg)
orbit.targetAngle = deg
end
function Cam.Angle() return orbit.targetAngle end
--- Fly from wherever we are to a point looking at a target, then call `done`.
function Cam.FlyTo(fromPos, fromLook, toPos, toLook, duration, done)
fly.from = fromPos
fly.to = toPos
fly.lookFrom = fromLook
fly.lookTo = toLook
fly.duration = duration or 3000
fly.startedAt = GetGameTimer()
fly.done = done
mode = 'fly'
ensureCam()
startRenderThread()
end
function Cam.SetFov(fov)
if cam and DoesCamExist(cam) then SetCamFov(cam, fov + 0.0) end
end
function Cam.Position()
if cam and DoesCamExist(cam) then return GetCamCoord(cam) end
return GetEntityCoords(PlayerPedId())
end
--- Hand control back to the gameplay camera. With a duration the engine
--- interpolates between the two, which hides the cut entirely.
function Cam.Release(duration)
mode = 'off'
duration = duration or 0
RenderScriptCams(false, duration > 0, duration, true, true)
if cam and DoesCamExist(cam) then
DestroyCam(cam, true)
end
cam = nil
end
-508
View File
@@ -1,508 +0,0 @@
-- ---------------------------------------------------------------------------
-- rp_ui - the pre-spawn experience.
--
-- Owns the stage machine (auth -> characters -> creator -> spawn -> live),
-- the NUI bridge, and the transition into the world. The server decides
-- everything that matters; this file only asks and then animates the answer.
-- ---------------------------------------------------------------------------
local stage = 'boot'
local account = nil
local characters = {}
local draft = nil -- appearance currently being edited
local previewPed = nil
local spawnData = nil -- payload from char:select
local chosenChar = nil
local NEUTRAL = vector3(-1037.0, -2738.0, 20.17) -- flat, always-streamed tarmac
-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------
local function ui(payload)
SendNUIMessage(payload)
end
local function focusUI(on)
SetNuiFocus(on, on)
SetNuiFocusKeepInput(false)
end
local function holdPlayer()
local ped = PlayerPedId()
SetEntityVisible(ped, false, false)
SetEntityCollision(ped, false, false)
FreezeEntityPosition(ped, true)
SetEntityInvincible(ped, true)
SetPlayerControl(PlayerId(), false, 0)
SetPlayerInvincible(PlayerId(), true)
DisplayHud(false)
DisplayRadar(false)
ClearPedTasksImmediately(ped)
end
local function releasePlayer()
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
SetEntityCollision(ped, true, true)
FreezeEntityPosition(ped, false)
SetEntityInvincible(ped, false)
SetPlayerControl(PlayerId(), true, 0)
SetPlayerInvincible(PlayerId(), false)
DisplayHud(true)
DisplayRadar(true)
end
--- Dusk, so the sodium palette of the interface matches the world behind it.
local function setSceneMood(on)
if on then
NetworkOverrideClockTime(20, 40, 0)
SetWeatherTypeNowPersist('EXTRASUNNY')
else
NetworkClearClockTimeOverride()
ClearOverrideWeatherType()
SetWeatherTypeNow('EXTRASUNNY')
end
end
--- Keep the world streamed around a point while no player ped is there.
local function focusOn(pos)
SetFocusPosAndVel(pos.x, pos.y, pos.z, 0.0, 0.0, 0.0)
RequestCollisionAtCoord(pos.x, pos.y, pos.z)
end
local function destroyPreview()
if previewPed and DoesEntityExist(previewPed) then
DeleteEntity(previewPed)
end
previewPed = nil
end
local function setStage(next, data)
stage = next
ui({ action = 'stage', stage = next, data = data })
end
-- ---------------------------------------------------------------------------
-- boot
-- ---------------------------------------------------------------------------
CreateThread(function()
while not NetworkIsSessionStarted() do Wait(50) end
holdPlayer()
setSceneMood(true)
local first = Config.CinematicShots[1]
focusOn(first.from)
SetEntityCoords(PlayerPedId(), NEUTRAL.x, NEUTRAL.y, NEUTRAL.z, false, false, false, false)
Cam.Shots(Config.CinematicShots)
-- give the world a moment to stream in behind the loading screen
SendLoadingScreenMessage(json.encode({
eventName = 'rp:server',
name = Config.ServerName,
status = 'Connected',
}))
SendLoadingScreenMessage(json.encode({ eventName = 'rp:rules', rules = Config.Rules }))
local waited = 0
while waited < 6000 do
SendLoadingScreenMessage(json.encode({
eventName = 'rp:progress',
fraction = 0.85 + (waited / 6000) * 0.15,
step = 'Streaming the city',
}))
Wait(250)
waited = waited + 250
end
SendLoadingScreenMessage(json.encode({ eventName = 'rp:handover' }))
Wait(1500)
ShutdownLoadingScreenNui()
focusUI(true)
setStage('auth', {
serverName = Config.ServerName,
serverTag = Config.ServerTag,
maxChars = Config.MaxChars,
})
end)
-- ---------------------------------------------------------------------------
-- authentication
-- ---------------------------------------------------------------------------
RegisterNUICallback('auth:submit', function(data, cb)
CreateThread(function()
local mode = (data and data.mode == 'register') and 'auth:register' or 'auth:login'
local res, err = Core.Callback('rp_session', mode, {
username = data.username,
password = data.password,
})
if not res then return cb({ ok = false, error = err or 'Something went wrong' }) end
account = { username = res.username, role = res.role }
characters = res.characters or {}
cb({ ok = true, account = account, characters = characters, maxChars = res.maxChars })
end)
end)
-- ---------------------------------------------------------------------------
-- character select
-- ---------------------------------------------------------------------------
--- Stand the highlighted character up so the list is not just text.
RegisterNUICallback('char:preview', function(data, cb)
CreateThread(function()
destroyPreview()
local appearance
for _, c in ipairs(characters) do
if c.id == data.id then appearance = c.appearance end
end
if not appearance then return cb({ ok = false }) end
appearance = Appearance.Sanitise(appearance)
focusOn(Config.CreatorScene.ped)
previewPed = PedBuild.CreatePreview(appearance, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
if previewPed then
PedBuild.Idle(previewPed)
Cam.Orbit(previewPed, 2.4, 0.25, Config.CreatorScene.ped.w + 180.0, true)
Cam.OrbitFrame(2.4, 0.25, 0.35)
end
cb({ ok = previewPed ~= nil })
end)
end)
RegisterNUICallback('char:delete', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'char:delete', { id = data.id })
if not res then return cb({ ok = false, error = err }) end
local list = Core.Callback('rp_session', 'char:list')
characters = (list and list.characters) or {}
destroyPreview()
Cam.Shots(Config.CinematicShots)
cb({ ok = true, characters = characters })
end)
end)
RegisterNUICallback('char:select', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'char:select', { id = data.id })
if not res then return cb({ ok = false, error = err }) end
spawnData = res
chosenChar = res.character
destroyPreview()
-- become the character now, off-screen, so the fly-down reveals them
if res.appearance then
PedBuild.ApplyToPlayer(Appearance.Sanitise(res.appearance))
holdPlayer()
end
cb({ ok = true, spawns = res.spawns, lastPosition = res.lastPosition, character = res.character })
end)
end)
-- ---------------------------------------------------------------------------
-- character creation
-- ---------------------------------------------------------------------------
--- Read the game's own colour tables so the swatches in the UI are the real
--- colours, not an approximation that drifts from what the ped ends up wearing.
local paletteCache = nil
local function palettes()
if paletteCache then return paletteCache end
local hair, makeup = {}, {}
for i = 0, 63 do
local ok, r, g, b = pcall(GetPedHairRgbColor, i)
hair[#hair + 1] = ok and { r = r, g = g, b = b } or { r = 40, g = 40, b = 40 }
local ok2, r2, g2, b2 = pcall(GetPedMakeupRgbColor, i)
makeup[#makeup + 1] = ok2 and { r = r2, g = g2, b = b2 } or { r = 60, g = 40, b = 40 }
end
paletteCache = { hair = hair, makeup = makeup }
return paletteCache
end
--- The creator UI is built from this schema rather than a hardcoded copy, so
--- adding a slider is a one-line change in shared/appearance.lua.
local function pushDraft(cb)
local payload = {
ok = true,
appearance = draft,
limits = PedBuild.Limits(previewPed),
palettes = palettes(),
schema = {
features = Appearance.FEATURES,
overlays = Appearance.OVERLAYS,
components = Appearance.COMPONENTS,
props = Appearance.PROPS,
},
}
if cb then cb(payload) else ui({ action = 'creator:sync', data = payload }) end
end
local function rebuildPreview()
destroyPreview()
focusOn(Config.CreatorScene.ped)
previewPed = PedBuild.CreatePreview(draft, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
if previewPed then
PedBuild.Idle(previewPed)
Cam.Orbit(previewPed, 1.5, 0.62, Config.CreatorScene.ped.w + 180.0, true)
end
end
RegisterNUICallback('creator:start', function(data, cb)
CreateThread(function()
draft = Appearance.Default(data and data.gender or 'm')
rebuildPreview()
pushDraft(cb)
end)
end)
RegisterNUICallback('creator:gender', function(data, cb)
CreateThread(function()
draft = Appearance.Default(data.gender == 'f' and 'f' or 'm')
rebuildPreview()
pushDraft(cb)
end)
end)
RegisterNUICallback('creator:parent', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local key = data.key
if key == 'father' or key == 'mother' then
draft.parents[key] = math.floor(tonumber(data.value) or 0)
elseif key == 'shapeMix' or key == 'skinMix' then
draft.parents[key] = tonumber(data.value) or 0.5
end
SetPedHeadBlendData(previewPed,
draft.parents.father, draft.parents.mother, 0,
draft.parents.father, draft.parents.mother, 0,
draft.parents.shapeMix + 0.0, draft.parents.skinMix + 0.0, 0.0, false)
cb({ ok = true })
end)
RegisterNUICallback('creator:feature', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local id = math.floor(tonumber(data.id) or 0)
local v = math.max(-1.0, math.min(1.0, tonumber(data.value) or 0.0))
draft.features[tostring(id)] = v
SetPedFaceFeature(previewPed, id, v + 0.0)
cb({ ok = true })
end)
RegisterNUICallback('creator:overlay', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local slot
for _, o in ipairs(Appearance.OVERLAYS) do if o.key == data.key then slot = o end end
if not slot then return cb({ ok = false }) end
local ov = draft.overlays[data.key]
if data.index ~= nil then ov.index = math.floor(tonumber(data.index)) end
if data.opacity ~= nil then ov.opacity = tonumber(data.opacity) end
if data.colour ~= nil then ov.colour = math.floor(tonumber(data.colour)) end
local index = (ov.index == nil or ov.index < 0) and 255 or ov.index
SetPedHeadOverlay(previewPed, slot.id, index, ov.opacity + 0.0)
if slot.tint and index ~= 255 then
SetPedHeadOverlayColor(previewPed, slot.id, slot.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
end
cb({ ok = true })
end)
RegisterNUICallback('creator:hair', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
if data.style ~= nil then draft.hair.style = math.floor(tonumber(data.style)) end
if data.colour ~= nil then draft.hair.colour = math.floor(tonumber(data.colour)) end
if data.highlight ~= nil then draft.hair.highlight = math.floor(tonumber(data.highlight)) end
SetPedComponentVariation(previewPed, 2, draft.hair.style, 0, 0)
SetPedHairColor(previewPed, draft.hair.colour, draft.hair.highlight)
cb({ ok = true })
end)
RegisterNUICallback('creator:eyes', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
draft.eyeColour = math.floor(tonumber(data.value) or 0)
SetPedEyeColor(previewPed, draft.eyeColour)
cb({ ok = true })
end)
RegisterNUICallback('creator:component', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local comp
for _, c in ipairs(Appearance.COMPONENTS) do if c.key == data.key then comp = c end end
if not comp then return cb({ ok = false }) end
local cc = draft.components[data.key]
if data.drawable ~= nil then cc.drawable = math.floor(tonumber(data.drawable)) end
if data.texture ~= nil then cc.texture = math.floor(tonumber(data.texture)) end
-- a new drawable usually has a different number of textures
local maxTex = math.max(0, GetNumberOfPedTextureVariations(previewPed, comp.id, cc.drawable) - 1)
if cc.texture > maxTex then cc.texture = 0 end
SetPedComponentVariation(previewPed, comp.id, cc.drawable, cc.texture, 0)
cb({ ok = true, texture = cc.texture, maxTexture = maxTex })
end)
RegisterNUICallback('creator:prop', function(data, cb)
if not draft or not previewPed then return cb({ ok = false }) end
local prop
for _, p in ipairs(Appearance.PROPS) do if p.key == data.key then prop = p end end
if not prop then return cb({ ok = false }) end
local pp = draft.props[data.key]
if data.drawable ~= nil then pp.drawable = math.floor(tonumber(data.drawable)) end
if data.texture ~= nil then pp.texture = math.floor(tonumber(data.texture)) end
if pp.drawable < 0 then
ClearPedProp(previewPed, prop.id)
cb({ ok = true, texture = 0, maxTexture = 0 })
else
local maxTex = math.max(0, GetNumberOfPedPropTextureVariations(previewPed, prop.id, pp.drawable) - 1)
if pp.texture > maxTex then pp.texture = 0 end
SetPedPropIndex(previewPed, prop.id, pp.drawable, pp.texture, true)
cb({ ok = true, texture = pp.texture, maxTexture = maxTex })
end
end)
--- Camera framing per creator section.
local FRAMES = {
heritage = { radius = 1.05, height = 0.66, pitch = 0.02 },
face = { radius = 0.85, height = 0.68, pitch = 0.0 },
hair = { radius = 1.05, height = 0.70, pitch = 0.0 },
body = { radius = 2.60, height = 0.20, pitch = 0.45 },
clothing = { radius = 2.30, height = 0.28, pitch = 0.40 },
identity = { radius = 1.90, height = 0.45, pitch = 0.25 },
}
RegisterNUICallback('creator:frame', function(data, cb)
local f = FRAMES[data.section] or FRAMES.face
Cam.OrbitFrame(f.radius, f.height, f.pitch)
cb({ ok = true })
end)
RegisterNUICallback('creator:rotate', function(data, cb)
Cam.Nudge(tonumber(data.delta) or 0.0)
cb({ ok = true })
end)
RegisterNUICallback('creator:submit', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'char:create', {
firstName = data.firstName,
lastName = data.lastName,
dob = data.dob,
gender = draft and draft.model or 'm',
backstory = data.backstory,
appearance = draft,
})
if not res then return cb({ ok = false, error = err }) end
local list = Core.Callback('rp_session', 'char:list')
characters = (list and list.characters) or {}
destroyPreview()
draft = nil
cb({ ok = true, characters = characters })
end)
end)
RegisterNUICallback('creator:cancel', function(_, cb)
destroyPreview()
draft = nil
Cam.Shots(Config.CinematicShots)
cb({ ok = true })
end)
-- ---------------------------------------------------------------------------
-- spawn
-- ---------------------------------------------------------------------------
RegisterNUICallback('spawn:preview', function(data, cb)
CreateThread(function()
if not spawnData then return cb({ ok = false }) end
for _, sp in ipairs(spawnData.spawns) do
if sp.id == data.id then
local from = Cam.Position()
focusOn(vector3(sp.coords.x, sp.coords.y, sp.coords.z))
Cam.FlyTo(
from, vector3(sp.cam.look.x, sp.cam.look.y, sp.cam.look.z),
vector3(sp.cam.pos.x, sp.cam.pos.y, sp.cam.pos.z),
vector3(sp.coords.x, sp.coords.y, sp.coords.z),
2200
)
break
end
end
cb({ ok = true })
end)
end)
RegisterNUICallback('spawn:confirm', function(data, cb)
CreateThread(function()
local res, err = Core.Callback('rp_session', 'spawn:confirm', { id = data.id })
if not res then return cb({ ok = false, error = err }) end
cb({ ok = true })
focusUI(false)
ui({ action = 'stage', stage = 'flying' })
local c = res.coords
local ped = PlayerPedId()
-- stream the ground before anyone stands on it
focusOn(vector3(c.x, c.y, c.z))
RequestCollisionAtCoord(c.x, c.y, c.z)
local waited = 0
while not HasCollisionLoadedAroundEntity(ped) and waited < 8000 do
RequestCollisionAtCoord(c.x, c.y, c.z)
Wait(50)
waited = waited + 50
end
SetEntityCoordsNoOffset(ped, c.x, c.y, c.z, false, false, false)
SetEntityHeading(ped, c.h or 0.0)
local from = Cam.Position()
Cam.FlyTo(
from, vector3(c.x, c.y, c.z),
vector3(c.x - 2.4, c.y - 2.4, c.z + 1.4), vector3(c.x, c.y, c.z + 0.2),
3400,
function()
SetEntityVisible(ped, true, false)
releasePlayer()
setSceneMood(false)
ClearFocus()
Cam.Release(1400)
ui({ action = 'stage', stage = 'live' })
TriggerEvent('rp:ui:spawned', chosenChar)
end
)
end)
end)
-- ---------------------------------------------------------------------------
-- misc
-- ---------------------------------------------------------------------------
RegisterNUICallback('ui:log', function(data, cb)
print(('^3[rp_ui]^7 %s'):format(tostring(data and data.message)))
cb({ ok = true })
end)
AddEventHandler('onResourceStop', function(res)
if res ~= GetCurrentResourceName() then return end
destroyPreview()
Cam.Release(0)
focusUI(false)
releasePlayer()
setSceneMood(false)
ClearFocus()
end)
-160
View File
@@ -1,160 +0,0 @@
-- ---------------------------------------------------------------------------
-- Building a freemode ped from an appearance table.
--
-- The same code paints the creator preview and the live player, so what you
-- saw while editing is exactly what walks into the world.
-- ---------------------------------------------------------------------------
PedBuild = {}
local NO_OVERLAY = 255 -- the native's "none" value; our model stores -1
local function loadModel(name)
local hash = joaat(name)
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
RequestModel(hash)
local waited = 0
while not HasModelLoaded(hash) and waited < 10000 do
Wait(10)
waited = waited + 10
end
if not HasModelLoaded(hash) then return nil end
return hash
end
PedBuild.LoadModel = loadModel
--- Apply an appearance table to an existing freemode ped.
function PedBuild.Apply(ped, a)
if not ped or not DoesEntityExist(ped) or type(a) ~= 'table' then return end
SetPedHeadBlendData(
ped,
a.parents.father, a.parents.mother, 0,
a.parents.father, a.parents.mother, 0,
a.parents.shapeMix + 0.0, a.parents.skinMix + 0.0, 0.0,
false
)
for _, f in ipairs(Appearance.FEATURES) do
local v = a.features[tostring(f.id)]
if v then SetPedFaceFeature(ped, f.id, v + 0.0) end
end
for _, o in ipairs(Appearance.OVERLAYS) do
local ov = a.overlays[o.key]
if ov then
local index = (ov.index == nil or ov.index < 0) and NO_OVERLAY or ov.index
SetPedHeadOverlay(ped, o.id, index, ov.opacity + 0.0)
if o.tint and index ~= NO_OVERLAY then
-- tint type 1 = hair palette, 2 = make-up palette
SetPedHeadOverlayColor(ped, o.id, o.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
end
end
end
SetPedComponentVariation(ped, 2, a.hair.style, 0, 0)
SetPedHairColor(ped, a.hair.colour, a.hair.highlight)
SetPedEyeColor(ped, a.eyeColour)
for _, c in ipairs(Appearance.COMPONENTS) do
local cc = a.components[c.key]
if cc then SetPedComponentVariation(ped, c.id, cc.drawable, cc.texture, 0) end
end
for _, p in ipairs(Appearance.PROPS) do
local pp = a.props[p.key]
if pp then
if not pp.drawable or pp.drawable < 0 then
ClearPedProp(ped, p.id)
else
SetPedPropIndex(ped, p.id, pp.drawable, pp.texture, true)
end
end
end
end
--- How many variations this particular model actually has, so the creator's
--- sliders stop at real values instead of guessed ones.
function PedBuild.Limits(ped)
local limits = { components = {}, props = {}, overlays = {}, hair = 0 }
if not ped or not DoesEntityExist(ped) then return limits end
limits.hair = math.max(0, GetNumberOfPedDrawableVariations(ped, 2) - 1)
for _, c in ipairs(Appearance.COMPONENTS) do
local drawables = math.max(0, GetNumberOfPedDrawableVariations(ped, c.id) - 1)
local current = GetPedDrawableVariation(ped, c.id)
limits.components[c.key] = {
drawable = drawables,
texture = math.max(0, GetNumberOfPedTextureVariations(ped, c.id, current) - 1),
}
end
for _, p in ipairs(Appearance.PROPS) do
local drawables = math.max(-1, GetNumberOfPedPropDrawableVariations(ped, p.id) - 1)
local current = GetPedPropIndex(ped, p.id)
limits.props[p.key] = {
drawable = drawables,
texture = math.max(0, GetNumberOfPedPropTextureVariations(ped, p.id, current) - 1),
}
end
for _, o in ipairs(Appearance.OVERLAYS) do
limits.overlays[o.key] = o.max
end
return limits
end
--- Create a standalone preview ped (never networked).
function PedBuild.CreatePreview(appearance, coords, heading)
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
local hash = loadModel(modelName)
if not hash then return nil end
local ped = CreatePed(2, hash, coords.x, coords.y, coords.z, heading + 0.0, false, false)
SetModelAsNoLongerNeeded(hash)
SetEntityInvincible(ped, true)
FreezeEntityPosition(ped, true)
SetBlockingOfNonTemporaryEvents(ped, true)
SetPedDefaultComponentVariation(ped)
SetPedCanRagdoll(ped, false)
SetEntityCollision(ped, false, false)
PedBuild.Apply(ped, appearance)
return ped
end
--- Turn the local player into this character.
function PedBuild.ApplyToPlayer(appearance)
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
local hash = loadModel(modelName)
if not hash then return false end
SetPlayerModel(PlayerId(), hash)
SetModelAsNoLongerNeeded(hash)
local ped = PlayerPedId()
SetPedDefaultComponentVariation(ped)
PedBuild.Apply(ped, appearance)
-- freemode peds start with no head blend applied until this settles a frame
Wait(50)
PedBuild.Apply(ped, appearance)
return true
end
--- A calm idle so the preview does not stand like a mannequin.
function PedBuild.Idle(ped)
local dict = 'anim@heists@heist_corona@team_idles@female_a'
RequestAnimDict(dict)
local waited = 0
while not HasAnimDictLoaded(dict) and waited < 3000 do
Wait(10)
waited = waited + 10
end
if HasAnimDictLoaded(dict) then
TaskPlayAnim(ped, dict, 'idle', 2.0, 2.0, -1, 1, 0, false, false, false)
end
end
-33
View File
@@ -1,33 +0,0 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_ui'
description 'Pre-spawn interface: sign in, character creation and placement'
author 'Los Santos RP'
version '1.0.0'
shared_scripts {
'@rp_core/shared/config.lua',
'@rp_core/shared/util.lua',
'@rp_core/shared/appearance.lua',
}
client_scripts {
'@rp_core/lib/callbacks_client.lua',
'client/camera.lua',
'client/ped.lua',
'client/main.lua',
}
ui_page 'html/index.html'
files {
'html/index.html',
'html/tokens.css',
'html/app.css',
'html/app.js',
'html/fonts/archivo-var.woff2',
'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
-820
View File
@@ -1,820 +0,0 @@
/* ---------------------------------------------------------------------------
Los Santos RP - the pre-spawn screens.
Every screen is a sheet of paper from the Office of Vital Records, lying on a
dark desk with the city running behind it. The body stays transparent: the
game is the room, and the paper is the only thing we draw.
--------------------------------------------------------------------------- */
body {
background: transparent;
transition: opacity 420ms var(--ease);
}
body.hidden { opacity: 0; pointer-events: none; }
/* --- the room -------------------------------------------------------------
A lamp over the desk on the left, and the dark closing in everywhere else.
This is what makes an opaque sheet of paper look lit rather than pasted on. */
.edge {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(58% 74% at 22% 46%, var(--lamp) 0%, transparent 62%),
radial-gradient(120% 96% at 26% 50%, transparent 38%, rgba(0,0,0,.58) 100%);
}
.grain {
position: fixed; inset: 0; z-index: 60; pointer-events: none;
opacity: .10; mix-blend-mode: overlay;
background-image: var(--fiber);
background-size: 160px 160px;
}
/* =========================================================================
persistent chrome: the edge of the desk
========================================================================= */
.topbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
display: flex; align-items: center; justify-content: space-between;
gap: 26px;
padding: 20px var(--gut-x) 26px;
background: linear-gradient(to bottom, rgba(6,7,5,.80), rgba(6,7,5,0));
pointer-events: none;
}
.mark { display: flex; align-items: center; gap: 12px; }
.seal { width: 32px; height: 32px; flex: none; opacity: .92; }
.seal-ring { fill: none; stroke: var(--stock-2); stroke-width: 3; }
.seal-ring.thin { stroke-width: 1.4; opacity: .6; }
.seal-ticks {
fill: none; stroke: var(--stock-2); stroke-width: 5; opacity: .5;
stroke-dasharray: 2 9;
}
.seal-star { fill: var(--canary); }
.mark-lines { display: flex; flex-direction: column; line-height: 1.1; }
.mark-name {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 118, 'wght' 800;
font-size: 15px; letter-spacing: .04em; text-transform: uppercase;
color: var(--stock-hi);
}
.mark-tag {
font-family: 'Plex Mono', monospace;
font-size: 9px; letter-spacing: .3em; text-transform: uppercase;
color: var(--canary);
}
/* the routing box: three stations, in the order the file moves through them */
.stages { display: flex; align-items: center; gap: 10px; }
.stagechip {
display: inline-flex; align-items: center; gap: 8px;
background: none; border: 0; padding: 0; cursor: default;
}
.stagechip em {
font-style: normal; font-size: 9px; line-height: 1;
width: 21px; height: 19px; display: grid; place-items: center;
border: 1px solid rgba(222,216,198,.26);
color: rgba(222,216,198,.45);
transition: all 320ms var(--ease);
}
.stagechip span {
font-size: 9.5px; letter-spacing: .17em;
color: rgba(222,216,198,.42);
transition: color 320ms var(--ease);
}
.stagechip.on em {
background: var(--canary); border-color: var(--canary); color: var(--ink);
}
.stagechip.on span { color: var(--stock-hi); }
.stagechip.done em { color: transparent; border-color: rgba(222,216,198,.4); position: relative; }
.stagechip.done em::after {
content: '\00D7';
position: absolute; inset: 0; display: grid; place-items: center;
font-size: 13px; color: var(--canary);
}
.stagelink { width: 26px; height: 1px; background: rgba(222,216,198,.2); }
.filetag { display: flex; align-items: baseline; gap: 8px; font-size: 11px; color: rgba(222,216,198,.55); }
.filetag-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9px; letter-spacing: .19em; text-transform: uppercase;
color: rgba(222,216,198,.35);
}
/* =========================================================================
the sheet
========================================================================= */
.screen {
position: fixed; inset: 0; z-index: 10;
display: flex; align-items: center;
padding: 96px var(--gut-x) var(--gut-y);
}
.screen[hidden] { display: none; }
.rail {
position: relative;
width: var(--sheet);
max-height: 100%;
display: flex; flex-direction: column;
background: var(--stock);
box-shadow: var(--lift-2);
z-index: 2;
}
.rail.wide { width: var(--sheet-w); }
/* paper: fibre, a warmer lit edge on the lamp side, and a shaded far edge */
.rail-scrim {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(to right, transparent 70%, rgba(25,28,24,.10) 100%),
var(--fiber);
background-size: auto, auto, 160px 160px;
opacity: 1;
mix-blend-mode: normal;
}
.rail-scrim::after {
content: ''; position: absolute; inset: 0;
background-image: var(--fiber);
background-size: 150px 150px;
opacity: .05;
mix-blend-mode: multiply;
}
.rail::after {
content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
background: radial-gradient(78% 62% at 16% 6%, rgba(255,240,196,.55) 0%, rgba(255,240,196,0) 64%);
mix-blend-mode: soft-light;
}
.rail-inner {
position: relative; z-index: 1;
padding: 22px var(--gut-x) var(--gut-y);
overflow-y: auto; overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--stock-3) transparent;
}
.rail-inner::-webkit-scrollbar { width: 4px; }
.rail-inner::-webkit-scrollbar-thumb { background: var(--stock-3); }
.rail-inner::-webkit-scrollbar-track { background: transparent; }
/* --- letterhead ----------------------------------------------------------- */
.letterhead {
position: relative; z-index: 1; flex: none;
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
padding: 20px var(--gut-x) 14px;
border-bottom: 2.5px solid var(--ink);
}
.letterhead::after {
content: ''; position: absolute; left: var(--gut-x); right: var(--gut-x); bottom: -5px;
height: 1px; background: var(--ink); opacity: .45;
}
.lh-dept { display: flex; flex-direction: column; gap: 2px; }
.lh-city {
font-family: 'Plex Mono', monospace;
font-size: 9.5px; letter-spacing: .22em; text-transform: uppercase;
color: var(--ink-3);
}
.lh-office {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 116, 'wght' 800;
font-size: 15px; letter-spacing: .015em; text-transform: uppercase;
color: var(--ink); line-height: 1.15;
}
.lh-form {
font-size: 10px; letter-spacing: .1em; color: var(--ink-2);
border: 1px solid var(--rule); padding: 3px 7px; white-space: nowrap;
}
/* --- headings ------------------------------------------------------------- */
.head {
font-size: clamp(23px, 2.05vw, 30px);
font-variation-settings: 'wdth' 108, 'wght' 800;
letter-spacing: .005em; line-height: 1.06;
color: var(--ink);
margin: 12px 0 10px;
text-wrap: balance;
}
.sub {
font-size: 13.5px; line-height: 1.6; color: var(--ink-2);
max-width: 46ch; margin-bottom: 20px;
}
.kicker { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; }
.hair { flex: 1; height: 1px; background: var(--rule); }
/* =========================================================================
form fields - a printed box with its caption punched through the rule
========================================================================= */
.form { display: block; }
.field { position: relative; margin-top: 24px; }
.field:first-child { margin-top: 8px; }
.field label {
position: absolute; top: -7px; left: 11px; z-index: 2;
font-size: 9px; letter-spacing: .17em;
color: var(--ink-2);
background: var(--stock);
padding: 0 6px;
}
.field input,
.field textarea {
width: 100%; display: block;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.34);
color: var(--ink);
font-size: 14px; line-height: 1.4;
padding: 14px 13px 11px;
transition: border-color 200ms var(--ease), background 200ms var(--ease);
}
.field textarea { min-height: 108px; resize: none; padding-top: 15px; }
.field input::placeholder,
.field textarea::placeholder { color: var(--ink-3); opacity: .65; }
.field input:focus,
.field textarea:focus {
outline: none;
border-color: var(--ink);
background: rgba(255,255,255,.6);
}
/* the clerk's tick in the margin of the box */
.field::after {
content: ''; position: absolute; top: 15px; right: 13px;
font-size: 14px; line-height: 1; pointer-events: none;
opacity: 0; transition: opacity 180ms var(--ease);
}
.field.good::after {
content: ''; opacity: 1;
width: 6px; height: 11px; top: 15px; right: 16px;
border: 2px solid var(--verdi); border-top: 0; border-left: 0;
transform: rotate(42deg);
}
.field.bad::after { content: '\00D7'; color: var(--stamp); opacity: 1; }
.field.good input { border-color: var(--verdi); }
.field.bad input { border-color: var(--stamp); }
.field.good .hint { color: var(--verdi); }
.field.bad .hint { color: var(--stamp); }
.hint {
font-size: 11.5px; line-height: 1.45; color: var(--ink-3);
margin-top: 7px;
}
.counter {
font-size: 10.5px; color: var(--ink-3); margin-top: 7px; text-align: right;
}
.withbtn { position: relative; }
.withbtn input { padding-right: 68px; }
.reveal-btn {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: 0; cursor: pointer;
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9.5px; letter-spacing: .15em; text-transform: uppercase;
color: var(--ink-2);
border-bottom: 1px solid var(--rule-firm);
padding-bottom: 1px;
}
.reveal-btn:hover { color: var(--ink); border-bottom-color: var(--ink); }
.collapse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 380ms var(--ease); }
.collapse.open { grid-template-rows: 1fr; }
.collapse-inner { overflow: hidden; min-height: 0; }
.formerror {
margin-top: 16px;
font-family: 'Plex Sans', sans-serif;
font-size: 12.5px; line-height: 1.45;
color: var(--stamp);
border-left: 3px solid var(--stamp);
background: rgba(126,43,38,.07);
padding: 9px 12px;
animation: nudge 380ms var(--ease);
}
/* =========================================================================
buttons
========================================================================= */
.btn {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
font-size: 11px; letter-spacing: .15em;
padding: 15px 24px;
border: 1.4px solid var(--ink);
background: none; color: var(--ink);
cursor: pointer;
transition: color 260ms var(--ease), border-color 260ms var(--ease), opacity 200ms var(--ease);
}
.btn::before {
content: ''; position: absolute; inset: 0; z-index: 0;
background: var(--canary);
transform: scaleX(0); transform-origin: left center;
transition: transform 340ms var(--ease-out);
}
.btn:hover:not(:disabled)::before { transform: scaleX(1); }
.btn-label, .btn-spin { position: relative; z-index: 1; }
.btn.primary { background: var(--ink); color: var(--stock-hi); width: 100%; margin-top: 22px; }
.btn.primary:hover:not(:disabled) { color: var(--ink); }
.btn.ghost { background: rgba(255,255,255,.2); }
.btn.ghost:hover:not(:disabled) { color: var(--ink); }
.btn.danger { border-color: var(--stamp); color: var(--stamp); }
.btn.danger::before { background: var(--stamp); }
.btn.danger:hover:not(:disabled) { color: var(--stock-hi); }
.btn:disabled { opacity: .32; cursor: default; }
.btn-spin { display: none; width: 13px; height: 13px; }
.btn.busy .btn-label { opacity: .35; }
.btn.busy .btn-spin {
display: block;
border: 1.6px solid currentColor; border-top-color: transparent;
border-radius: 50%;
animation: spin 720ms linear infinite;
}
.switch { margin-top: 18px; font-size: 12.5px; color: var(--ink-2); }
.linkbtn {
background: none; border: 0; cursor: pointer; padding: 0;
font-family: inherit; font-size: inherit; color: var(--ink);
border-bottom: 1px solid var(--rule-firm);
}
.linkbtn:hover { border-bottom-color: var(--ink); }
.linkbtn.danger {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase;
color: var(--stamp); border-bottom-color: rgba(126,43,38,.4);
}
.linkbtn.danger:hover { border-bottom-color: var(--stamp); }
/* =========================================================================
02 / the register of persons - ruled index cards in a drawer
========================================================================= */
.charlist { list-style: none; }
.charcard {
position: relative;
padding: 15px 18px 14px 52px;
margin-bottom: 9px;
background: var(--stock-hi);
border: 1px solid var(--rule);
cursor: pointer;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
transition: transform 200ms var(--ease), box-shadow 200ms var(--ease), background 200ms var(--ease);
/* the faint ruling of a real index card */
background-image: repeating-linear-gradient(
to bottom, transparent 0 22px, rgba(46,97,85,.075) 22px 23px);
}
.charcard:hover { transform: translateY(-1px); box-shadow: 0 6px 14px -8px rgba(0,0,0,.55); }
.charslot {
position: absolute; left: 0; top: 0; bottom: 0; width: 36px;
display: grid; place-items: center;
background: var(--stock-2);
border-right: 1px solid var(--rule);
font-size: 10.5px; color: var(--ink-2);
transition: background 200ms var(--ease), color 200ms var(--ease);
}
.charname {
display: block; font-size: 14.5px; letter-spacing: .045em;
color: var(--ink); line-height: 1.2;
}
.charmeta { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 4px; }
.charmoney { font-size: 13px; color: var(--ink); white-space: nowrap; }
.charcard.on {
background-color: #f4f0e4;
border-color: var(--ink);
box-shadow: inset 4px 0 0 var(--canary), 0 8px 18px -8px rgba(0,0,0,.6);
}
.charcard.on .charslot { background: var(--canary); color: var(--ink); }
.charcard.empty {
background-color: transparent; background-image: none;
border: 1.4px dashed var(--rule-firm);
color: var(--ink-2);
justify-content: flex-start;
}
.charcard.empty .charslot { background: transparent; border-right-color: var(--rule-soft); font-size: 15px; }
.charcard.empty .charname { font-size: 11px; color: var(--ink-2); }
.charcard.empty:hover { background-color: rgba(255,255,255,.24); }
.charactions { display: flex; gap: 10px; align-items: stretch; margin-top: 20px; }
.charactions .btn.primary { margin-top: 0; }
.charactions .btn.ghost { width: auto; flex: none; margin-top: 0; }
/* =========================================================================
03 / application for identity
========================================================================= */
.tabs {
position: relative; z-index: 1; flex: none;
display: flex; gap: 2px;
padding: 14px var(--gut-x) 0;
border-bottom: 1.4px solid var(--rule-firm);
}
.tab {
padding: 8px 11px 7px;
font-size: 9.5px; letter-spacing: .12em;
background: var(--stock-2);
border: 1px solid var(--rule); border-bottom: 0;
color: var(--ink-2); cursor: pointer;
margin-bottom: -1.4px;
transition: background 200ms var(--ease), color 200ms var(--ease);
}
.tab:hover { background: var(--stock); color: var(--ink); }
.tab.is-on {
background: var(--stock-hi); color: var(--ink);
border-bottom: 1.4px solid var(--stock-hi);
}
.rail-inner.creator { padding-top: 20px; }
.pane { display: none; }
.pane.is-on {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px 20px;
align-content: start;
}
.pane .sub { grid-column: 1 / -1; margin-bottom: 0; }
.pane .field { grid-column: 1 / -1; }
.ctrl { min-width: 0; }
.ctrl.span2 { grid-column: 1 / -1; }
.ctrl-head {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
margin-bottom: 7px;
}
.ctrl-label { font-size: 9.5px; letter-spacing: .14em; color: var(--ink-2); }
.ctrl-val { font-size: 10.5px; color: var(--ink-3); white-space: nowrap; }
/* a measuring scale with a surveyor's marker riding on it */
.slider {
-webkit-appearance: none; appearance: none;
width: 100%; height: 24px; background: transparent; cursor: pointer;
}
.slider::-webkit-slider-runnable-track {
height: 24px;
background-image:
linear-gradient(var(--rule-firm), var(--rule-firm)),
repeating-linear-gradient(90deg, var(--rule) 0 1px, transparent 1px 10%);
background-size: 100% 1.4px, 100% 7px;
background-position: 0 12px, 0 13px;
background-repeat: no-repeat, repeat-x;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 11px; height: 18px; margin-top: 3px;
background: var(--ink); border: 0;
clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%);
transition: background 160ms var(--ease);
}
.slider:hover::-webkit-slider-thumb { background: var(--canary-dp); }
.stepper {
display: grid; grid-template-columns: 32px 1fr 32px;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.34);
}
.stepper button {
border: 0; background: none; height: 32px; cursor: pointer;
color: var(--ink-2); font-size: 14px; line-height: 1;
transition: background 160ms var(--ease), color 160ms var(--ease);
}
.stepper button:hover { background: var(--ink); color: var(--stock-hi); }
.stepval {
display: grid; place-items: center;
font-size: 11.5px; color: var(--ink);
border-left: 1px solid var(--rule); border-right: 1px solid var(--rule);
overflow: hidden; white-space: nowrap;
}
/* a printed colour chart: a fixed grid, so it can never wrap into orphans */
.swatches {
display: grid; grid-template-columns: repeat(16, 1fr);
grid-auto-rows: 17px;
gap: 1px; padding: 1px;
background: var(--rule-firm);
border: 1px solid var(--rule-firm);
}
.swatch {
border: 0; padding: 0; cursor: pointer;
position: relative; min-width: 0;
transition: transform 140ms var(--ease);
}
.swatch:hover { transform: scale(1.18); z-index: 3; box-shadow: 0 0 0 1px var(--ink); }
.swatch.on {
z-index: 4;
box-shadow: 0 0 0 2px var(--ink), 0 0 0 4px var(--canary);
}
/* the sex field, ticked the way a form is ticked */
.seg { display: flex; gap: 24px; grid-column: 1 / -1; margin-bottom: 2px; }
.seg button {
display: inline-flex; align-items: center; gap: 9px;
background: none; border: 0; cursor: pointer;
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 108, 'wght' 700;
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-2);
}
.seg button::before {
content: ''; width: 15px; height: 15px; flex: none;
display: grid; place-items: center;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.4);
font-size: 13px; line-height: 1; color: var(--stamp);
}
.seg button.on { color: var(--ink); }
.seg button.on::before { content: '\00D7'; border-color: var(--ink); }
.creator-foot {
position: relative; z-index: 1; flex: none;
display: flex; gap: 10px;
padding: 14px var(--gut-x) 18px;
border-top: 1px solid var(--rule);
}
.creator-foot .btn { margin-top: 0; }
.creator-foot .btn.ghost { flex: none; width: auto; }
.creator-foot .btn.primary { flex: 1; }
#screen-creator .formerror { margin: 0 var(--gut-x); }
/* --- the photograph plate ------------------------------------------------
The subject stands inside this frame, and the frame is also the drag
surface, so the thing you aim at is the thing that turns. It is drawn wide
on purpose: the ped is framed generously rather than pinned to a cut-out. */
.turntable {
position: absolute; left: 36%; right: 16%; top: 16%; bottom: 17%;
z-index: 5; cursor: grab;
}
.turntable.dragging { cursor: grabbing; }
.plate-corner {
position: absolute; width: 30px; height: 30px;
border: 2px solid rgba(233,226,204,.72);
transition: border-color 260ms var(--ease);
}
.plate-corner.tl { left: 0; top: 0; border-right: 0; border-bottom: 0; }
.plate-corner.tr { right: 0; top: 0; border-left: 0; border-bottom: 0; }
.plate-corner.bl { left: 0; bottom: 0; border-right: 0; border-top: 0; }
.plate-corner.br { right: 0; bottom: 0; border-left: 0; border-top: 0; }
.turntable.dragging .plate-corner { border-color: var(--canary); }
.plate-cap {
position: absolute; left: 0; right: 0; bottom: -28px;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
}
.plate-cap-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9px; letter-spacing: .22em; text-transform: uppercase;
color: rgba(222,216,198,.5);
}
.turnhint {
display: inline-flex; align-items: center; gap: 8px;
font-family: 'Plex Mono', monospace;
font-size: 10px; letter-spacing: .1em;
color: rgba(222,216,198,.45);
transition: opacity 200ms var(--ease);
}
.turntable.dragging .turnhint { opacity: 0; }
.turnicon {
width: 14px; height: 14px; display: block; flex: none;
border: 1px solid currentColor; border-radius: 50%;
border-right-color: transparent;
transform: rotate(-30deg);
}
/* =========================================================================
04 / notice of placement
========================================================================= */
.spawnlist { list-style: none; border-top: 1px solid var(--rule); }
.spawncard {
position: relative;
padding: 13px 14px 13px 44px;
border-bottom: 1px solid var(--rule);
cursor: pointer;
transition: background 180ms var(--ease);
}
.spawncard:hover { background: rgba(255,255,255,.28); }
.spawncard::before {
content: ''; position: absolute; left: 14px; top: 15px;
width: 15px; height: 15px;
display: grid; place-items: center;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.45);
font-family: 'Plex Sans', sans-serif;
font-size: 13px; line-height: 1; color: var(--stamp);
}
.spawncard.on::before { content: '\00D7'; border-color: var(--ink); }
.spawncard.on { background: rgba(217,178,60,.13); }
.spawnhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.spawnname { font-size: 12.5px; letter-spacing: .07em; color: var(--ink); }
.spawnarea {
font-family: 'Plex Mono', monospace;
font-size: 9.5px; letter-spacing: .11em; text-transform: uppercase;
color: var(--ink-3); white-space: nowrap;
}
.spawnblurb { font-size: 12px; line-height: 1.5; color: var(--ink-2); margin-top: 4px; }
/* --- the plan sheet ------------------------------------------------------- */
.chart {
position: absolute; right: 6%; top: 50%;
transform: translateY(-50%) rotate(.5deg);
width: min(33vw, 58vh); aspect-ratio: 1 / 1;
z-index: 1;
background: var(--stock);
box-shadow: var(--lift-2);
padding: 3%;
}
.chart-paper {
position: absolute; inset: 0; pointer-events: none;
background-image:
radial-gradient(80% 66% at 14% 8%, rgba(255,244,206,.26) 0%, rgba(255,244,206,0) 62%),
var(--fiber);
background-size: auto, 150px 150px;
opacity: .9;
}
.chart svg { position: relative; width: 100%; height: 100%; display: block; }
.chart-frame { fill: none; stroke: var(--rule); stroke-width: 1; }
.chart-ticks { fill: none; stroke: var(--ink); stroke-width: 2.5; opacity: .55; }
#landmass { fill: rgba(46,97,85,.07); stroke: var(--ink-2); stroke-width: 1.4; }
#coastline { fill: none; stroke: var(--ink); stroke-width: 2.6; }
.pins { position: absolute; inset: 3%; pointer-events: none; }
.pin {
position: absolute; transform: translate(-50%, -50%);
display: flex; align-items: center; gap: 7px;
}
.pin.flip { flex-direction: row-reverse; }
.pin-mark {
width: 9px; height: 9px; flex: none;
background: var(--stock);
border: 1.6px solid var(--ink);
transform: rotate(45deg);
transition: all 220ms var(--ease);
}
.pin-label {
font-family: 'Plex Mono', monospace;
font-size: 8.5px; letter-spacing: .12em; text-transform: uppercase;
color: var(--ink-2); white-space: nowrap;
}
.pin.on .pin-mark {
background: var(--canary); border-color: var(--ink);
width: 13px; height: 13px;
box-shadow: 0 0 0 3px rgba(217,178,60,.3);
}
.pin.on .pin-label { color: var(--ink); }
.titleblock {
position: absolute; right: 3%; bottom: 3%;
border: 1.4px solid var(--ink);
background: var(--stock-hi);
min-width: 44%;
}
.tb-row { display: flex; }
.tb-row + .tb-row { border-top: 1px solid var(--rule); }
.tb-k, .tb-v { padding: 4px 7px; font-size: 8px; line-height: 1.3; }
.tb-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 100, 'wght' 600;
letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-3);
border-right: 1px solid var(--rule);
flex: none;
}
.tb-v {
font-family: 'Plex Mono', monospace;
color: var(--ink); flex: 1;
letter-spacing: .04em;
}
.tb-row + .tb-row .tb-k { border-left: 1px solid var(--rule); }
.tb-row + .tb-row .tb-k:first-child { border-left: 0; }
.tb-main .tb-v {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
}
/* =========================================================================
the stamp - the one bold moment, and the only thing that moves loudly
========================================================================= */
.stamp {
position: fixed; left: 17%; top: 53%; z-index: 50;
transform: translate(-50%, -50%) rotate(-8deg) scale(1);
display: none;
flex-direction: column; align-items: center; gap: 3px;
padding: 13px 30px 11px;
border: 4px solid var(--stamp);
color: var(--stamp);
pointer-events: none;
/* real rubber never inks evenly */
-webkit-mask-image: var(--inkmask);
mask-image: var(--inkmask);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.stamp::before {
content: ''; position: absolute; inset: 4px;
border: 1.4px solid var(--stamp);
}
.stamp-word {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 118, 'wght' 800;
font-size: 37px; letter-spacing: .1em; text-transform: uppercase;
line-height: 1;
}
.stamp-sub {
font-family: 'Plex Mono', monospace;
font-size: 8.5px; letter-spacing: .2em; text-transform: uppercase;
opacity: 1;
}
.stamp.show { display: flex; animation: stampdown 1700ms var(--ease-out) forwards; }
@keyframes stampdown {
0% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(2.3); filter: blur(4px); }
22% { opacity: 1; transform: translate(-50%,-50%) rotate(-8deg) scale(.93); filter: blur(0); }
32% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.03); }
42% { transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
78% { opacity: 1; }
100% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
}
/* the desk takes the hit when the stamp lands */
body.struck .rail, body.struck .chart { animation: jolt 260ms var(--ease); }
@keyframes jolt {
0% { transform: translate(0,0); }
18% { transform: translate(-1px, 2px); }
46% { transform: translate(1px, -1px); }
100% { transform: translate(0,0); }
}
/* the plan sheet keeps its rotation while it shakes */
body.struck .chart { animation-name: joltchart; }
@keyframes joltchart {
0% { transform: translateY(-50%) rotate(.5deg); }
18% { transform: translate(-1px, calc(-50% + 2px)) rotate(.5deg); }
46% { transform: translate(1px, calc(-50% - 1px)) rotate(.5deg); }
100% { transform: translateY(-50%) rotate(.5deg); }
}
/* =========================================================================
entrances - one orchestrated move per screen, not seven scattered ones
========================================================================= */
.screen.is-on .rail { animation: sheetdown 520ms var(--ease-out) both; }
.screen.is-on .chart { animation: chartin 620ms var(--ease-out) 120ms both; }
.screen.is-on .turntable { animation: platein 700ms var(--ease-out) 180ms both; }
.screen.is-on .rail-inner,
.screen.is-on .creator-foot { animation: settle 520ms var(--ease-out) 160ms both; }
@keyframes sheetdown {
from { opacity: 0; transform: translateY(-14px) scale(.995); }
to { opacity: 1; transform: none; }
}
@keyframes settle {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
@keyframes chartin {
from { opacity: 0; transform: translateY(-50%) rotate(.5deg) scale(.97); }
to { opacity: 1; transform: translateY(-50%) rotate(.5deg) scale(1); }
}
@keyframes platein {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes nudge {
0% { transform: translateX(0); }
22% { transform: translateX(-5px); }
44% { transform: translateX(4px); }
68% { transform: translateX(-2px); }
100% { transform: translateX(0); }
}
/* Small screens: the sheet takes the width, the plate and plan sheet stand down. */
@media (max-width: 1100px) {
.rail, .rail.wide { width: min(92vw, 560px); }
.chart { display: none; }
.turntable { left: auto; right: 4%; width: 34%; }
}
-965
View File
@@ -1,965 +0,0 @@
/* ---------------------------------------------------------------------------
Pre-spawn interface behaviour.
The client Lua owns the truth about stages; this file renders whatever it is
told and asks for changes. Validation here is for live feedback only - the
server re-checks everything and its answer is the one that counts.
--------------------------------------------------------------------------- */
'use strict';
const $ = (id) => document.getElementById(id);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
async function post(name, data) {
try {
const res = await fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
});
return await res.json();
} catch (err) {
return { ok: false, error: 'The game stopped responding. Try again.' };
}
}
/* Fire-and-forget for high-frequency edits (slider drags). */
function poke(name, data) {
fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
}).catch(() => {});
}
/* =========================================================================
screen manager
========================================================================= */
const SCREENS = ['auth', 'characters', 'creator', 'spawn'];
const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' };
const CHIP_ORDER = ['auth', 'characters', 'spawn'];
let current = null;
function showScreen(name) {
SCREENS.forEach((s) => {
const el = $(`screen-${s}`);
if (!el) return;
if (s === name) {
el.hidden = false;
/* restart the entrance animation */
el.classList.remove('is-on');
void el.offsetWidth;
el.classList.add('is-on');
} else {
el.hidden = true;
el.classList.remove('is-on');
}
});
current = name;
paintChips(CHIP_FOR[name]);
document.body.classList.remove('hidden');
}
function paintChips(activeKey) {
const idx = CHIP_ORDER.indexOf(activeKey);
$$('.stagechip').forEach((chip) => {
const i = CHIP_ORDER.indexOf(chip.dataset.stage);
chip.classList.toggle('on', i === idx);
chip.classList.toggle('done', i < idx);
});
}
/* The stamp is the only loud thing in the interface, so it only ever marks a
real change of state: the file opened, the application approved, the
placement issued. The paper on the desk takes the hit with it. */
function stamp(text) {
const el = $('stamp');
$('stampText').textContent = text;
el.classList.remove('show');
document.body.classList.remove('struck');
void el.offsetWidth;
el.classList.add('show');
setTimeout(() => document.body.classList.add('struck'), 170);
setTimeout(() => document.body.classList.remove('struck'), 600);
}
function busy(btn, on) {
btn.classList.toggle('busy', on);
btn.disabled = on;
}
function showError(el, message) {
if (!message) {
el.hidden = true;
return;
}
el.textContent = message;
el.hidden = false;
/* replay the shake even if the message is unchanged */
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';
}
/* =========================================================================
validation (mirrors shared/util.lua; the server still decides)
========================================================================= */
const check = {
username(v) {
v = (v || '').trim();
if (!v) return [null, 'Pick a username.'];
if (v.length < 3) return [false, 'A bit longer - at least 3 characters.'];
if (v.length > 20) return [false, 'Too long - 20 characters at most.'];
if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.'];
return [true, 'That will do.'];
},
password(v) {
v = v || '';
if (!v) return [null, 'At least 8 characters, with a letter and a number.'];
if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`];
if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.'];
if (!/[0-9]/.test(v)) return [false, 'Add at least one number.'];
return [true, 'Strong enough.'];
},
name(v) {
v = (v || '').trim();
if (!v) return [null, 'Required.'];
if (v.length < 2) return [false, 'Too short.'];
if (v.length > 20) return [false, 'Too long.'];
if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."];
return [true, ''];
},
dob(v) {
v = (v || '').trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
if (!v) return [null, 'YYYY-MM-DD'];
if (!m) return [false, 'Use YYYY-MM-DD.'];
const y = +m[1], mo = +m[2], d = +m[3];
if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.'];
const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.'];
if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.'];
return [true, ''];
},
};
/* Paints a field's state. `state` is true / false / null (neutral). */
function mark(fieldName, state, message) {
const field = document.querySelector(`.field[data-field="${fieldName}"]`);
if (!field) return;
field.classList.toggle('good', state === true);
field.classList.toggle('bad', state === false);
const hint = field.querySelector('.hint');
if (hint && message !== undefined && message !== null) hint.textContent = message;
}
/* =========================================================================
01 / auth
========================================================================= */
const Auth = {
mode: 'register',
init() {
$('switchMode').addEventListener('click', () => this.toggle());
$('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
$('revealPw').addEventListener('click', () => {
const input = $('password');
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
$('revealPw').textContent = show ? 'Hide' : 'Show';
$('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
$('username').addEventListener('input', () => {
const [ok, msg] = check.username($('username').value);
mark('username', ok, msg);
});
$('password').addEventListener('input', () => {
const [ok, msg] = check.password($('password').value);
mark('password', ok, msg);
if (this.mode === 'register' && $('confirm').value) this.checkConfirm();
});
$('confirm').addEventListener('input', () => this.checkConfirm());
},
checkConfirm() {
const a = $('password').value;
const b = $('confirm').value;
if (!b) return mark('confirm', null, 'Both entries must match.');
mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.');
},
toggle() {
this.mode = this.mode === 'register' ? 'login' : 'register';
const reg = this.mode === 'register';
$('authTitle').textContent = reg ? 'Open a file' : 'Return to your file';
$('authSub').textContent = reg
? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.'
: 'Sign in and the city picks up where you left it.';
$('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in';
$('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?';
$('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now';
$('confirmWrap').classList.toggle('open', reg);
$('confirmWrap').setAttribute('aria-hidden', String(!reg));
$('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password');
showError($('authError'), null);
if (!reg) mark('confirm', null, 'Both entries must match.');
},
async submit() {
const username = $('username').value.trim();
const password = $('password').value;
const reg = this.mode === 'register';
const [uOk, uMsg] = check.username(username);
if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; }
if (reg) {
const [pOk, pMsg] = check.password(password);
if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; }
if ($('confirm').value !== password) {
mark('confirm', false, 'These do not match yet.');
$('confirm').focus();
return;
}
} else if (!password) {
mark('password', false, 'Enter your password.');
$('password').focus();
return;
}
const btn = $('authSubmit');
busy(btn, true);
showError($('authError'), null);
const res = await post('auth:submit', { mode: this.mode, username, password });
busy(btn, false);
if (!res.ok) return showError($('authError'), res.error || 'That did not work.');
if (reg) stamp('Filed');
Characters.load(res.characters || [], res.maxChars || 3);
showScreen('characters');
},
};
/* =========================================================================
02 / characters
========================================================================= */
const Characters = {
list: [],
max: 3,
selected: null,
init() {
$('charEnter').addEventListener('click', () => this.enter());
$('charDelete').addEventListener('click', () => this.remove());
},
load(list, max) {
this.list = list || [];
this.max = max || 3;
this.selected = null;
this.render();
},
render() {
const ul = $('charList');
ul.innerHTML = '';
$('charCount').textContent = `${this.list.length} / ${this.max}`;
this.list.forEach((c, i) => {
const li = document.createElement('li');
li.className = 'charcard' + (this.selected === c.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<span class="charslot">0${i + 1}</span>
<span>
<span class="charname"></span>
<span class="charmeta"></span>
</span>
<span class="charmoney"></span>`;
li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`;
li.querySelector('.charmeta').textContent = this.meta(c);
li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`;
const pick = () => this.select(c.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
});
if (this.list.length < this.max) {
const li = document.createElement('li');
li.className = 'charcard empty';
li.tabIndex = 0;
li.innerHTML = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
const go = () => Creator.open();
li.addEventListener('click', go);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
ul.appendChild(li);
}
$('charEnter').disabled = this.selected === null;
$('charDelete').disabled = this.selected === null;
},
meta(c) {
const hours = Math.floor((c.playtime || 0) / 3600);
const played = hours > 0 ? `${hours}h played` : 'never played';
const job = c.job && c.job !== 'unemployed' ? c.job : 'no job';
return `${c.dob} · ${job} · ${played}`;
},
select(id) {
this.selected = id;
this.render();
showError($('charError'), null);
poke('char:preview', { id });
},
async enter() {
if (this.selected === null) return;
const btn = $('charEnter');
busy(btn, true);
const res = await post('char:select', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.');
Spawn.load(res.spawns || [], res.lastPosition, res.character);
showScreen('spawn');
},
async remove() {
if (this.selected === null) return;
const c = this.list.find((x) => x.id === this.selected);
if (!c) return;
const btn = $('charDelete');
/* two-step, in place: the button becomes the confirmation */
if (btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.textContent = `Delete ${c.firstName}?`;
setTimeout(() => {
if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; }
}, 4000);
return;
}
btn.dataset.armed = '0';
btn.textContent = 'Delete';
busy(btn, true);
const res = await post('char:delete', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.');
this.load(res.characters || [], this.max);
},
};
/* =========================================================================
03 / creator
========================================================================= */
const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity'];
const Creator = {
appearance: null,
limits: null,
schema: null,
palettes: null,
section: 'heritage',
init() {
$$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section)));
$('creatorCancel').addEventListener('click', () => this.cancel());
$('creatorPrev').addEventListener('click', () => this.step(-1));
$('creatorNext').addEventListener('click', () => this.step(1));
this.initDrag();
},
async open() {
const res = await post('creator:start', { gender: 'm' });
if (!res.ok) return showError($('charError'), 'Could not start the editor.');
this.absorb(res);
this.section = 'heritage';
this.paintTabs();
this.renderAll();
showScreen('creator');
poke('creator:frame', { section: 'heritage' });
},
absorb(res) {
this.appearance = res.appearance;
this.limits = res.limits;
this.schema = res.schema || this.schema;
this.palettes = res.palettes || this.palettes;
},
go(section) {
this.section = section;
this.paintTabs();
poke('creator:frame', { section });
},
paintTabs() {
$$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section));
$$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section));
const i = SECTIONS.indexOf(this.section);
$('creatorPrev').disabled = i === 0;
$('creatorNext').querySelector('.btn-label').textContent =
i === SECTIONS.length - 1 ? 'Submit application' : 'Next';
},
step(dir) {
const i = SECTIONS.indexOf(this.section);
if (dir > 0 && i === SECTIONS.length - 1) return this.submit();
const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))];
this.go(next);
},
/* --- control builders ------------------------------------------------- */
slider(label, value, min, max, step, format, onInput) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<input class="slider" type="range">`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const input = wrap.querySelector('input');
input.min = min; input.max = max; input.step = step; input.value = value;
val.textContent = format(value);
input.addEventListener('input', () => {
const v = Number(input.value);
val.textContent = format(v);
onInput(v);
});
return wrap;
},
stepper(label, value, min, max, onChange) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<div class="stepper">
<button type="button" aria-label="Previous">&minus;</button>
<span class="stepval"></span>
<button type="button" aria-label="Next">+</button>
</div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const range = wrap.querySelector('.ctrl-val');
const out = wrap.querySelector('.stepval');
let v = value;
let lo = min;
let hi = max;
const paint = () => {
out.textContent = v < 0 ? 'None' : String(v);
range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}${hi}`;
};
const set = (next) => {
if (next < lo) next = hi; // wraps, so browsing a long list is quick
if (next > hi) next = lo;
v = next;
paint();
onChange(v);
};
/* Changing a garment changes how many variants it has; the variant control
is re-bounded in place rather than rebuilt. */
const setBounds = (newMax, newValue) => {
hi = typeof newMax === 'number' ? newMax : hi;
if (typeof newValue === 'number') v = newValue;
if (v > hi) v = lo;
paint();
};
wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1));
wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1));
paint();
return { el: wrap, set, setBounds, get value() { return v; } };
},
swatches(label, palette, value, onPick) {
const wrap = document.createElement('div');
/* A colour chart is 16 columns wide, so it always takes the full form. */
wrap.className = 'ctrl span2';
wrap.innerHTML = `
<div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
<div class="swatches"></div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const row = wrap.querySelector('.swatches');
val.textContent = String(value);
(palette || []).forEach((c, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'swatch' + (i === value ? ' on' : '');
b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`;
b.title = `Colour ${i}`;
b.addEventListener('click', () => {
row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on'));
b.classList.add('on');
val.textContent = String(i);
onPick(i);
});
row.appendChild(b);
});
return wrap;
},
/* --- panes ------------------------------------------------------------ */
renderAll() {
this.renderHeritage();
this.renderFace();
this.renderHair();
this.renderBody();
this.renderClothing();
this.renderIdentity();
},
pane(section) {
const el = document.querySelector(`.pane[data-section="${section}"]`);
el.innerHTML = '';
return el;
},
renderHeritage() {
const p = this.pane('heritage');
const a = this.appearance;
const seg = document.createElement('div');
seg.className = 'seg';
seg.innerHTML = `
<button type="button" data-g="m">Male</button>
<button type="button" data-g="f">Female</button>`;
seg.querySelectorAll('button').forEach((b) => {
b.classList.toggle('on', b.dataset.g === a.model);
b.addEventListener('click', async () => {
if (b.dataset.g === this.appearance.model) return;
const res = await post('creator:gender', { gender: b.dataset.g });
if (!res.ok) return;
this.absorb(res);
this.renderAll();
this.paintTabs();
});
});
p.appendChild(seg);
p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.father = v;
poke('creator:parent', { key: 'father', value: v });
}));
p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.mother = v;
poke('creator:parent', { key: 'mother', value: v });
}));
p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); }));
p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); }));
},
renderFace() {
const p = this.pane('face');
const a = this.appearance;
(this.schema.features || []).forEach((f) => {
const key = String(f.id);
const v = a.features[key] || 0;
p.appendChild(this.slider(f.label, v, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); }));
});
p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => {
a.eyeColour = v;
poke('creator:eyes', { value: v });
}));
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair'));
},
renderHair() {
const p = this.pane('hair');
const a = this.appearance;
const maxHair = (this.limits && this.limits.hair) || 0;
const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => {
a.hair.style = v;
poke('creator:hair', { style: v });
});
p.appendChild(st.el);
p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => {
a.hair.colour = i;
poke('creator:hair', { colour: i });
}));
p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => {
a.hair.highlight = i;
poke('creator:hair', { highlight: i });
}));
/* Only the hair-tinted overlays belong here; skin and make-up sit with
the face, which keeps either pane from becoming a wall of controls. */
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair'));
},
overlayControls(p, list) {
const a = this.appearance;
list.forEach((o) => {
const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 };
const s = this.stepper(o.label, ov.index, -1, o.max, (v) => {
ov.index = v;
poke('creator:overlay', { key: o.key, index: v });
});
p.appendChild(s.el);
p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05,
(v) => `${Math.round(v * 100)}%`,
(v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); }));
if (o.tint) {
const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup;
p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => {
ov.colour = i;
poke('creator:overlay', { key: o.key, colour: i });
}));
}
});
},
renderBody() {
const p = this.pane('body');
const a = this.appearance;
const note = document.createElement('p');
note.className = 'sub';
note.textContent = 'Build is carried by the heritage blend and these two proportions.';
p.appendChild(note);
['13', '19'].forEach((id) => {
const f = (this.schema.features || []).find((x) => String(x.id) === id);
if (!f) return;
p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); }));
});
},
renderClothing() {
const p = this.pane('clothing');
const a = this.appearance;
(this.schema.components || []).forEach((c) => {
const cc = a.components[c.key] || { drawable: 0, texture: 0 };
const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 };
const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => {
cc.texture = v;
poke('creator:component', { key: c.key, texture: v });
});
const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => {
cc.drawable = v;
const res = await post('creator:component', { key: c.key, drawable: v });
if (res && res.ok) {
cc.texture = res.texture;
tex.setBounds(res.maxTexture, res.texture);
}
});
p.appendChild(draw.el);
p.appendChild(tex.el);
});
(this.schema.props || []).forEach((pr) => {
const pp = a.props[pr.key] || { drawable: -1, texture: 0 };
const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 };
const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => {
pp.drawable = v;
poke('creator:prop', { key: pr.key, drawable: v });
});
p.appendChild(s.el);
});
},
renderIdentity() {
const p = this.pane('identity');
p.innerHTML = `
<div class="field" data-field="firstName">
<label for="firstName">First name</label>
<input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
<p class="hint">The name people will call you.</p>
</div>
<div class="field" data-field="lastName">
<label for="lastName">Last name</label>
<input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
<p class="hint">Family name. Must be unique on this server.</p>
</div>
<div class="field" data-field="dob">
<label for="dob">Date of birth</label>
<input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
<p class="hint">YYYY-MM-DD</p>
</div>
<div class="field" data-field="backstory">
<label for="backstory">Backstory</label>
<textarea id="backstory" maxlength="2000"
placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
<p class="counter"><span id="storyCount">0</span> / 2000</p>
</div>`;
$('firstName').addEventListener('input', () => {
const [ok, msg] = check.name($('firstName').value);
mark('firstName', ok, msg || 'The name people will call you.');
});
$('lastName').addEventListener('input', () => {
const [ok, msg] = check.name($('lastName').value);
mark('lastName', ok, msg || 'Family name. Must be unique on this server.');
});
$('dob').addEventListener('input', () => {
const [ok, msg] = check.dob($('dob').value);
mark('dob', ok, msg || 'YYYY-MM-DD');
});
$('backstory').addEventListener('input', () => {
$('storyCount').textContent = String($('backstory').value.length);
});
},
/* --- drag to turn ----------------------------------------------------- */
initDrag() {
const tt = $('turntable');
let dragging = false;
let lastX = 0;
let pending = 0;
let frame = null;
const flush = () => {
frame = null;
if (pending !== 0) {
poke('creator:rotate', { delta: pending });
pending = 0;
}
};
tt.addEventListener('pointerdown', (e) => {
dragging = true;
lastX = e.clientX;
tt.classList.add('dragging');
tt.setPointerCapture(e.pointerId);
});
tt.addEventListener('pointermove', (e) => {
if (!dragging) return;
pending += (e.clientX - lastX) * 0.42;
lastX = e.clientX;
if (!frame) frame = requestAnimationFrame(flush);
});
const stop = (e) => {
if (!dragging) return;
dragging = false;
tt.classList.remove('dragging');
try { tt.releasePointerCapture(e.pointerId); } catch (_) {}
};
tt.addEventListener('pointerup', stop);
tt.addEventListener('pointercancel', stop);
},
async cancel() {
await post('creator:cancel', {});
showScreen('characters');
Characters.render();
},
async submit() {
const first = $('firstName') ? $('firstName').value.trim() : '';
const last = $('lastName') ? $('lastName').value.trim() : '';
const dob = $('dob') ? $('dob').value.trim() : '';
if (this.section !== 'identity') this.go('identity');
const [fOk, fMsg] = check.name(first);
if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; }
const [lOk, lMsg] = check.name(last);
if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; }
const [dOk, dMsg] = check.dob(dob);
if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; }
const btn = $('creatorNext');
busy(btn, true);
showError($('creatorError'), null);
const res = await post('creator:submit', {
firstName: first,
lastName: last,
dob,
backstory: $('backstory').value,
});
busy(btn, false);
if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.');
stamp('Approved');
Characters.load(res.characters || [], Characters.max);
showScreen('characters');
},
};
/* =========================================================================
04 / spawn
========================================================================= */
const Spawn = {
spawns: [],
selected: null,
last: null,
init() {
$('spawnConfirm').addEventListener('click', () => this.confirm());
},
load(spawns, lastPosition, character) {
this.spawns = spawns || [];
this.last = lastPosition || null;
this.selected = null;
if (character) {
$('spawnSub').textContent =
`${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`;
}
this.render();
},
render() {
const ul = $('spawnList');
const pins = $('pins');
ul.innerHTML = '';
pins.innerHTML = '';
const entries = this.spawns.slice();
if (this.last) {
entries.unshift({
id: 'last',
label: 'Where you left off',
area: 'Last known position',
blurb: 'Pick up exactly where this character stopped.',
map: null,
});
}
entries.forEach((sp) => {
const li = document.createElement('li');
li.className = 'spawncard' + (this.selected === sp.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<div class="spawnhead">
<span class="spawnname"></span>
<span class="spawnarea"></span>
</div>
<p class="spawnblurb"></p>`;
li.querySelector('.spawnname').textContent = sp.label;
li.querySelector('.spawnarea').textContent = sp.area;
li.querySelector('.spawnblurb').textContent = sp.blurb;
const pick = () => this.select(sp.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
if (sp.map) {
const pin = document.createElement('div');
pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : '');
pin.style.left = `${sp.map.x * 100}%`;
pin.style.top = `${sp.map.y * 100}%`;
pin.innerHTML = '<i class="pin-mark"></i><span class="pin-label"></span>';
pin.querySelector('.pin-label').textContent = sp.label;
pins.appendChild(pin);
}
});
$('spawnConfirm').disabled = this.selected === null;
},
select(id) {
this.selected = id;
this.render();
showError($('spawnError'), null);
if (id !== 'last') poke('spawn:preview', { id });
},
async confirm() {
if (this.selected === null) return;
const btn = $('spawnConfirm');
busy(btn, true);
const res = await post('spawn:confirm', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.');
stamp('Issued');
},
};
/* =========================================================================
messages from the client script
========================================================================= */
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'stage') {
if (msg.stage === 'auth') {
const d = msg.data || {};
if (d.serverName) $('markName').textContent = d.serverName;
if (d.serverTag) $('markTag').textContent = d.serverTag;
$('fileSerial').textContent = fileSerial();
showScreen('auth');
setTimeout(() => $('username').focus(), 520);
} else if (msg.stage === 'flying' || msg.stage === 'live') {
/* the camera is doing the talking now */
SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; });
document.body.classList.add('hidden');
} else {
showScreen(msg.stage);
}
}
if (msg.action === 'creator:sync' && msg.data) {
Creator.absorb(msg.data);
Creator.renderAll();
}
});
function fileSerial() {
const y = new Date().getFullYear();
const n = String(Math.floor(Math.random() * 900000) + 100000);
return `${y}-${n}`;
}
/* Escape backs out of the creator; nothing else steals keys from the game. */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && current === 'creator') Creator.cancel();
});
document.addEventListener('DOMContentLoaded', () => {
Auth.init();
Characters.init();
Creator.init();
Spawn.init();
Auth.toggle(); /* start on "sign in"; the copy makes registering obvious */
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-294
View File
@@ -1,294 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Los Santos RP</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
</head>
<body class="hidden">
<div class="edge" aria-hidden="true"></div>
<div class="grain" aria-hidden="true"></div>
<!-- ======================= persistent chrome ======================= -->
<header class="topbar">
<div class="mark">
<svg class="seal" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="46" class="seal-ring"/>
<circle cx="50" cy="50" r="37" class="seal-ring thin"/>
<circle cx="50" cy="50" r="41.5" class="seal-ticks"/>
<path class="seal-star" d="M50 30 L54.7 43.8 L69.4 44 L57.6 52.7 L62 66.6
L50 58.2 L38 66.6 L42.4 52.7 L30.6 44 L45.3 43.8 Z"/>
</svg>
<span class="mark-lines">
<span class="mark-name" id="markName">LOS SANTOS</span>
<span class="mark-tag" id="markTag">ROLEPLAY</span>
</span>
</div>
<!-- A real sequence: you cannot be placed before you have an identity, and you
cannot have an identity before the file exists. Printed as a routing box. -->
<nav class="stages" aria-label="Application progress">
<button class="stagechip" data-stage="auth" type="button" disabled>
<em>01</em><span>Credentials</span>
</button>
<i class="stagelink" aria-hidden="true"></i>
<button class="stagechip" data-stage="characters" type="button" disabled>
<em>02</em><span>Identity</span>
</button>
<i class="stagelink" aria-hidden="true"></i>
<button class="stagechip" data-stage="spawn" type="button" disabled>
<em>03</em><span>Placement</span>
</button>
</nav>
<div class="filetag">
<span class="filetag-k">File</span>
<span id="fileSerial">0000-000000</span>
</div>
</header>
<!-- ======================= 01 / auth ======================= -->
<section class="screen" id="screen-auth" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Office of Vital Records</span>
</div>
<div class="lh-form data">VR&#8209;1</div>
</div>
<div class="rail-inner">
<h1 class="head" id="authTitle">Open a file</h1>
<p class="sub" id="authSub">
Everything you do in this city is recorded against this file. Choose a name you will
remember and a password you will not share.
</p>
<form class="form" id="authForm" novalidate>
<div class="field" data-field="username">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username"
spellcheck="false" maxlength="20" placeholder="dlarue">
<p class="hint" id="hint-username">3&ndash;20 characters. Letters, numbers and underscore.</p>
</div>
<div class="field" data-field="password">
<label for="password">Password</label>
<div class="withbtn">
<input id="password" name="password" type="password" autocomplete="current-password"
maxlength="72" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;">
<button class="reveal-btn" id="revealPw" type="button" aria-label="Show password">Show</button>
</div>
<p class="hint" id="hint-password">At least 8 characters, with a letter and a number.</p>
</div>
<!-- only present while registering; height animates, nothing reloads -->
<div class="collapse" id="confirmWrap" aria-hidden="true">
<div class="collapse-inner">
<div class="field" data-field="confirm">
<label for="confirm">Repeat password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password"
maxlength="72" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;">
<p class="hint" id="hint-confirm">Both entries must match.</p>
</div>
</div>
</div>
<p class="formerror" id="authError" role="alert" aria-live="assertive" hidden></p>
<button class="btn primary" id="authSubmit" type="submit">
<span class="btn-label">Open the file</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</form>
<p class="switch">
<span id="switchText">Already have a file?</span>
<button class="linkbtn" id="switchMode" type="button">Sign in instead</button>
</p>
</div>
</div>
</section>
<!-- ======================= 02 / characters ======================= -->
<section class="screen" id="screen-characters" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Register of Persons</span>
</div>
<div class="lh-form data">VR&#8209;4</div>
</div>
<div class="rail-inner">
<h1 class="head">Who are you today?</h1>
<div class="kicker">
<span class="eyebrow">Registered identities</span>
<span class="hair" aria-hidden="true"></span>
<span class="data" id="charCount">0 / 3</span>
</div>
<ul class="charlist" id="charList"></ul>
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
<div class="charactions">
<button class="btn primary" id="charEnter" type="button" disabled>
<span class="btn-label">Enter the city</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
<button class="btn ghost danger" id="charDelete" type="button" disabled>Delete</button>
</div>
</div>
</div>
</section>
<!-- ======================= 03 / creator ======================= -->
<section class="screen" id="screen-creator" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Application for Identity</span>
</div>
<button class="linkbtn danger" id="creatorCancel" type="button">Discard</button>
</div>
<nav class="tabs" id="creatorTabs" aria-label="Editor sections">
<button class="tab is-on" data-section="heritage" type="button">Heritage</button>
<button class="tab" data-section="face" type="button">Face</button>
<button class="tab" data-section="hair" type="button">Hair</button>
<button class="tab" data-section="body" type="button">Body</button>
<button class="tab" data-section="clothing" type="button">Clothing</button>
<button class="tab" data-section="identity" type="button">Identity</button>
</nav>
<div class="rail-inner creator">
<div class="panes" id="creatorPanes">
<div class="pane is-on" data-section="heritage"></div>
<div class="pane" data-section="face"></div>
<div class="pane" data-section="hair"></div>
<div class="pane" data-section="body"></div>
<div class="pane" data-section="clothing"></div>
<div class="pane" data-section="identity"></div>
</div>
</div>
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
<div class="creator-foot">
<button class="btn ghost" id="creatorPrev" type="button">Back</button>
<button class="btn primary" id="creatorNext" type="button">
<span class="btn-label">Next</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</div>
</div>
<!-- The plate the subject is photographed against. It is also the drag
surface, so the frame you aim at is the thing that turns. -->
<div class="turntable" id="turntable" title="Drag to turn">
<span class="plate-corner tl" aria-hidden="true"></span>
<span class="plate-corner tr" aria-hidden="true"></span>
<span class="plate-corner bl" aria-hidden="true"></span>
<span class="plate-corner br" aria-hidden="true"></span>
<div class="plate-cap">
<span class="plate-cap-k">Subject</span>
<span class="turnhint"><span class="turnicon" aria-hidden="true"></span>Drag to turn</span>
</div>
</div>
</section>
<!-- ======================= 04 / spawn ======================= -->
<section class="screen" id="screen-spawn" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Notice of Placement</span>
</div>
<div class="lh-form data">VR&#8209;9</div>
</div>
<div class="rail-inner">
<h1 class="head">Where does the day start?</h1>
<p class="sub" id="spawnSub">Pick a district. You can move once you are on the ground.</p>
<ul class="spawnlist" id="spawnList"></ul>
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
<button class="btn primary" id="spawnConfirm" type="button" disabled>
<span class="btn-label">Place me here</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</div>
</div>
<!-- A plan sheet from the same office: a drawing of the county, not a screenshot. -->
<div class="chart" id="chart">
<div class="chart-paper" aria-hidden="true"></div>
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M50 0 L0 0 0 50" fill="none" stroke="rgba(25,28,24,.10)" stroke-width="1"/>
</pattern>
<pattern id="grid10" width="250" height="250" patternUnits="userSpaceOnUse">
<path d="M250 0 L0 0 0 250" fill="none" stroke="rgba(25,28,24,.18)" stroke-width="1"/>
</pattern>
</defs>
<rect width="1000" height="1000" fill="url(#grid)"/>
<rect width="1000" height="1000" fill="url(#grid10)"/>
<rect class="chart-frame" x="30" y="30" width="940" height="940"/>
<path class="chart-ticks"
d="M30 92 L30 30 L92 30 M908 30 L970 30 L970 92
M970 908 L970 970 L908 970 M92 970 L30 970 L30 908"/>
<path id="landmass"
d="M318 62 L470 44 L604 74 L712 66 L806 128 L858 248 L872 372
L842 468 L868 556 L826 664 L742 742 L648 812 L560 872
L462 918 L392 902 L342 836 L296 742 L262 640 L240 528
L206 424 L196 320 L232 196 L272 108 Z"/>
<!-- the western coastline is the one edge worth drawing emphatically -->
<path id="coastline"
d="M262 640 L240 528 L206 424 L196 320 L232 196 L272 108 L318 62"/>
</svg>
<div class="pins" id="pins"></div>
<!-- every drawing from this office carries a title block -->
<div class="titleblock">
<div class="tb-row tb-main">
<span class="tb-k">Sheet</span>
<span class="tb-v">Los Santos County</span>
</div>
<div class="tb-row">
<span class="tb-k">Scale</span><span class="tb-v">Not to scale</span>
<span class="tb-k">Sheet</span><span class="tb-v">1 of 1</span>
</div>
</div>
</div>
</section>
<!-- the one bold moment: the city stamps your file -->
<div class="stamp" id="stamp" aria-hidden="true">
<span class="stamp-word" id="stampText">Filed</span>
<span class="stamp-sub">Office of Vital Records</span>
</div>
<script src="app.js"></script>
</body>
</html>
-182
View File
@@ -1,182 +0,0 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is a set of documents from the city's Office of Vital Records,
lying on a dark desk while the city runs on behind them. Paper is the only
surface; the game is the room the desk is in. Nothing here is a dark glass
panel, because a city clerk does not hand you one.
Three typographic voices, and the rule between them is literal:
preprinted Archivo, expanded, caps what the form was printed with
typed IBM Plex Mono what somebody entered on it
prose IBM Plex Sans plain-English notes in the margin
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: block;
}
@font-face {
font-family: 'Plex Sans';
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* --- the room ---------------------------------------------------------- */
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
--desk: #1a1b16; /* desk surface where the lamp reaches it */
--lamp: rgba(224, 196, 128, 0.10);
/* --- the paper --------------------------------------------------------- */
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
--stock-3: #b3a888; /* deepest fold */
/* --- what is written on it --------------------------------------------- */
--ink: #191c18; /* ballpoint black with a green cast */
--ink-2: #4e5449; /* second-rank text */
--ink-3: #7d8175; /* captions, disabled, ruled lines */
/* --- the three official inks, each with exactly one job ---------------- */
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
--canary-dp: #a8801d;
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
/* rules printed on the form */
--rule: rgba(25, 28, 24, 0.22);
--rule-soft: rgba(25, 28, 24, 0.11);
--rule-firm: rgba(25, 28, 24, 0.55);
/* paper fibre, laid over every sheet at low opacity */
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
/* a sheet of paper casts a real shadow onto the desk */
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
--sheet: clamp(360px, 27vw, 460px);
--sheet-w: clamp(440px, 34vw, 580px);
--gut-x: clamp(26px, 2.4vw, 40px);
--gut-y: clamp(22px, 2.2vw, 34px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--room);
color: var(--ink);
font-family: 'Plex Sans', system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------------------------------------------------------------------------
The three voices.
--------------------------------------------------------------------------- */
/* preprinted: everything the form arrived with */
.pp,
.eyebrow,
.head,
label,
.btn,
.tab,
.ctrl-label,
.charname,
.spawnname,
.stagechip span {
font-family: 'Archivo', system-ui, sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* typed: everything a person put on the form */
.data,
.typed,
input,
textarea,
.ctrl-val,
.stepval,
.charmeta,
.charmoney,
.counter,
.stagechip em,
.filetag {
font-family: 'Plex Mono', ui-monospace, monospace;
font-variation-settings: normal;
text-transform: none;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
/* prose: the plain-English notes, the only voice allowed sentence case */
.sub,
.hint,
.spawnblurb,
.switch {
font-family: 'Plex Sans', system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
}
.eyebrow {
font-size: 10px;
font-variation-settings: 'wdth' 104, 'wght' 600;
letter-spacing: 0.19em;
color: var(--ink-3);
}
.data {
font-size: 11.5px;
color: var(--ink-2);
}
.rule { height: 1px; background: var(--rule); border: 0; }
/* Focus is always the canary, always visible, and never subtle. */
:focus-visible {
outline: 2px solid var(--canary-dp);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+31 -31
View File
@@ -1,40 +1,40 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Los Santos RP - server configuration # FiveRP - server configuration
# Copy to /opt/fivem/data/server.cfg (install.sh does this for you). # install.sh writes this to server-data/server.cfg with your own values.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
endpoint_add_tcp "0.0.0.0:30120" endpoint_add_tcp "0.0.0.0:30120"
endpoint_add_udp "0.0.0.0:30120" endpoint_add_udp "0.0.0.0:30120"
sv_maxclients 48 set steam_webApiKey "none"
sv_hostname "Los Santos Roleplay" sv_maxclients 32
sets sv_projectName "Los Santos Roleplay" sv_hostname "FiveRP"
sets sv_projectDesc "Serious roleplay. Custom framework, written from scratch." sets sv_projectName "FiveRP"
sets sv_projectDesc "FiveRP Server"
sets tags "roleplay"
sv_enforceGameBuild 2699
set onesync on # Free key from https://keymaster.fivem.net - keep it out of git
sv_enforceGameBuild 3095 sv_licenseKey "cfxk_your_key_here"
sv_scriptHookAllowed 0
# Licence key and database credentials. Mode 600, never echoed.
exec /opt/fivem/data/secrets.cfg
# --- resources -------------------------------------------------------------
# Nothing from cfx-server-data is used. The player cap, chat, spawning and
# session handling are all implemented in the resources below.
ensure rp_db
ensure rp_core
ensure rp_session
ensure rp_loading
ensure rp_ui
# Diagnostics. Safe to leave on; they only touch their own marker rows.
ensure rp_selftest
# Driver test suite, off by default:
# ensure rp_dbtest
# Admin access. Add real identifiers here, e.g.
# add_principal identifier.license:<licence hash> group.admin
# Deliberately empty: a placeholder like identifier.fivem:1 would grant admin
# to whoever actually owns that Cfx account.
add_ace group.admin command allow add_ace group.admin command allow
add_ace group.admin command.quit deny
# oxmysql connection - the password is generated by install.sh
set mysql_connection_string "mysql://fiverp:your_password@localhost/fiverp?charset=utf8mb4"
# Loading screen first, database before anything that queries it
ensure fiverp-loadscreen
ensure oxmysql
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
# basic-gamemode force-respawns on map start, which would drop players into the
# world behind the auth screen. fiverp-auth owns spawning instead.
ensure hardcap
ensure rconlog
ensure fiverp-auth
ensure fiverp-characters
+37
View File
@@ -0,0 +1,37 @@
-- ---------------------------------------------------------------------------
-- FiveRP - database schema
-- mysql fiverp < sql/schema.sql (install.sh applies this for you)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS accounts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(190) NOT NULL,
username VARCHAR(32) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- scrypt, salt and cost live inside the hash
license VARCHAR(64) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_email (email),
UNIQUE KEY uq_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS characters (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id INT UNSIGNED NOT NULL,
slot TINYINT UNSIGNED NOT NULL DEFAULT 1,
first_name VARCHAR(32) NOT NULL,
last_name VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
model VARCHAR(32) NOT NULL DEFAULT 'mp_m_freemode_01',
appearance LONGTEXT DEFAULT NULL, -- JSON, written by fiverp-characters
pos_x FLOAT DEFAULT NULL,
pos_y FLOAT DEFAULT NULL,
pos_z FLOAT DEFAULT NULL,
heading FLOAT DEFAULT NULL,
last_played DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_slot (account_id, slot),
CONSTRAINT fk_char_account FOREIGN KEY (account_id)
REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;