diff --git a/.gitignore b/.gitignore
index fc09f1f..4df07a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,12 @@
-server-data/
-cache/
-*.log
+# Anything holding a licence key or a password
+secrets.cfg
+*.env
.env
-server.cfg.local
+
+# Runtime state, never in the repo
+cache/
+logs/
+*.log
+/server/
+/data/
+/server.cfg
diff --git a/README.md b/README.md
index 3c66f1f..0e02810 100644
--- a/README.md
+++ b/README.md
@@ -4,24 +4,23 @@ An open-source **FiveM roleplay base written completely from scratch** — no ES
> 🇷🇺 Русская версия — ниже / 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.
+
## Why another base?
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.
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.
-## What's in part 1
+## What's in it
-- Clean `fxserver` setup on Ubuntu
-- MariaDB with tables for accounts and characters, hashed passwords
-- Own Lua resource: registration, login, character creation, spawn
-- NUI screens with real design work (not default template UI):
- - cinematic loading screen with music and a real progress bar
- - register / login with live validation
- - character creation with an orbiting camera and live model updates
- - spawn point selection
+- 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 (written by the agent, part 1)
+## 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.
@@ -29,66 +28,121 @@ The whole UI follows one theme the agent picked itself — **Los Santos County r
|---|---|---|
|  |  |  |
-## Requirements
-
-- Ubuntu 22.04 / 24.04, 4 vCPU / 8 GB RAM recommended
-- A free Cfx.re server registration key (`sv_licenseKey`)
-- MariaDB
-
## Install
-Setup instructions land here together with part 1 of the video.
+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).
-## Roadmap
+```sh
+git clone https://github.com/RedlHosting/redl-fivem-rp.git
+cd redl-fivem-rp
+sudo sh install.sh --licence cfxk_your_key_here
+```
-- **Part 1** — foundation: database, registration, login, character creation ← *you are here*
-- **Part 2** — money, jobs, banking
+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 `: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.
+
+Day to day:
+
+```sh
+service fivem start|stop|restart|status # systemd boxes: systemctl ...
+rcon status # send a command to the live console
+tail -f /opt/fivem/logs/server.log
+```
+
+## Layout
+
+ resources/[rp]/ the server: rp_db, rp_core, rp_session, rp_loading, rp_ui, rp_selftest, rp_dbtest
+ etc/schema.sql database schema (accounts, characters, transactions, inventory, vehicles)
+ bin/ supervise.sh (keep-alive + console FIFO), rcon, the init script
+ assets/fonts/ the bundled font subsets — a NUI page has no guaranteed internet
+ server.cfg.example main config
+ etc/secrets.cfg.example
+ docs/SERVER.md how it is put together, resource by resource
+
+`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
+
+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.
+
+- **Part 1** — the foundation: database, registration, login, character creation ← *we are here*
+- **Part 2** — money, jobs, the bank
- **Part 3** — inventory and phone
- **Part 4** — garages and vehicles
-- **Part 5** — admin panel + project website on the same machine
+- **Part 5** — an admin panel and the project website on the same machine
-## How this was built
+Issues and pull requests are welcome — this is meant to be used, not admired.
-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 English, the agent writes the code, fixes its own errors, and restarts the service. The full process is recorded — including the parts where it broke.
+## How it was made
-## License
+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.
-MIT — take it, change it, ship it. Attribution appreciated, not required.
+## Licence
+
+MIT — take it, change it, ship it. Credit is nice but not required.
---
-# 🇷🇺 redl-fivem-rp
+# redl-fivem-rp (RU)
Открытая **ролевая база для FiveM, написанная полностью с нуля** — без ESX, без QBCore, без скачанных с форума скриптов. Каждая строчка здесь написана ИИ-агентом на живом сервере, в кадре, и выложена бесплатно для всех.
+В репозитории лежит сам сервер, ровно в том виде, в каком он работает: ресурсы, схема базы, супервизор, конфиг. `sh install.sh` ставит его на чистую Ubuntu.
+
## Зачем ещё одна база?
Обычно проект начинается с того, что ставится сборка на 200 мегабайт, из которой реально используется процентов пять. Вместе с ней приезжают чужие баги, чужие зависимости, и в итоге у тебя такой же сервер, как ещё у тысячи человек, — и трогать его страшно, потому что непонятно, что отвалится.
Здесь наоборот. Мало кода, он читаемый, и в нём есть только то, что мы попросили. Не понял файл — попросил переписать.
-## Что в первой части
+## Что внутри
-- Чистый `fxserver` на Ubuntu
-- MariaDB, таблицы под аккаунты и персонажей, хешированные пароли
-- Свой ресурс на Lua: регистрация, авторизация, создание персонажа, спавн
-- Экраны на 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, рекомендую 4 vCPU / 8 ГБ RAM
-- Бесплатный ключ регистрации сервера Cfx.re (`sv_licenseKey`)
-- MariaDB
+Весь интерфейс сделан в одной теме, которую агент выбрал сам, — **архив округа Лос-Сантос**: вход это дело о проживании, создание персонажа — анкета учёта личности, выбор спавна — топографическая карта округа.
## Установка
-Инструкция появится здесь вместе с первой частью видео.
+Ubuntu 22.04 / 24.04 (или Debian 12), рекомендую 4 vCPU / 8 ГБ RAM, и бесплатный ключ сервера с [keymaster.fivem.net](https://keymaster.fivem.net).
-## План частей
+```sh
+git clone https://github.com/RedlHosting/redl-fivem-rp.git
+cd redl-fivem-rp
+sudo sh install.sh --licence cfxk_ваш_ключ
+```
+
+Установщик сам скачает рекомендованную сборку FXServer, поставит MariaDB и накатит схему, разложит ресурсы в `/opt/fivem`, напишет `server.cfg` и `secrets.cfg` со сгенерированным паролем базы, поставит сервер в автозапуск и дождётся регистрации в Cfx. Дальше — `connect <ваш ip>:30120`.
+
+Полезные параметры: `--dir`, `--hostname`, `--port`, `--name` (несколько серверов на одной машине), `--db-name`, `--db-user`, `--no-start`. Список — `sh install.sh --help`.
+
+Повседневное:
+
+```sh
+service fivem start|stop|restart|status # на машинах с systemd — systemctl ...
+rcon status # команда в живую консоль сервера
+tail -f /opt/fivem/logs/server.log
+```
+
+## Что где лежит
+
+ resources/[rp]/ сам сервер: rp_db, rp_core, rp_session, rp_loading, rp_ui, rp_selftest, rp_dbtest
+ etc/schema.sql схема базы (accounts, characters, transactions, inventory, vehicles)
+ bin/ supervise.sh (перезапуск + FIFO консоли), rcon, init-скрипт
+ assets/fonts/ вшитые подмножества шрифтов — у страницы NUI нет гарантированного интернета
+ server.cfg.example основной конфиг
+ etc/secrets.cfg.example
+ docs/SERVER.md как всё устроено, ресурс за ресурсом
+
+## Чего ещё нет
+
+Всё до спавна и фундамент под этим — готово. Геймплей сверху — чат, HUD, инвентарь, работы, транспорт, полиция, медицина — нет. Таблицы `characters`, `transactions`, `inventory`, `vehicles` уже заведены, денежный API в `rp_core/server/player.lua` журналируемый и готов.
- **Часть 1** — фундамент: база, регистрация, авторизация, создание персонажа ← *мы здесь*
- **Часть 2** — деньги, работы, банк
@@ -96,6 +150,8 @@ MIT — take it, change it, ship it. Attribution appreciated, not required.
- **Часть 4** — гаражи и транспорт
- **Часть 5** — админка + сайт проекта на этой же машине
+Issues и pull request'ы приветствуются — это сделано, чтобы этим пользовались.
+
## Как это сделано
Всё собирается на VDS [REDL](https://redl.io), где ИИ-агент живёт прямо на сервере с root-доступом. Задача ставится обычными словами, агент пишет код, чинит свои же ошибки и перезапускает сервис. Весь процесс записан — включая те моменты, где всё ломалось.
diff --git a/api/api.py b/api/api.py
deleted file mode 100644
index 19b76e1..0000000
--- a/api/api.py
+++ /dev/null
@@ -1,340 +0,0 @@
-#!/usr/bin/env python3
-"""JustRP Database API — internal HTTP service on port 8787"""
-import json
-import hashlib
-import os
-import socket
-from http.server import HTTPServer, BaseHTTPRequestHandler
-from urllib.parse import urlparse, parse_qs
-
-try:
- import pymysql
- import pymysql.cursors
-except ImportError:
- import subprocess
- subprocess.run(["pip3", "install", "pymysql"], check=True)
- import pymysql
- import pymysql.cursors
-
-# Секреты берутся из окружения. Заведите их перед запуском, например в systemd-юните:
-# Environment=JUSTRP_API_SECRET=... Environment=JUSTRP_DB_PASS=...
-API_SECRET = os.environ.get("JUSTRP_API_SECRET", "change-me")
-DB = {
- "host": os.environ.get("JUSTRP_DB_HOST", "localhost"),
- "user": os.environ.get("JUSTRP_DB_USER", "fivem"),
- "password": os.environ.get("JUSTRP_DB_PASS", "change-me"),
- "database": os.environ.get("JUSTRP_DB_NAME", "fivem"),
- "charset": "utf8mb4",
- "cursorclass": pymysql.cursors.DictCursor,
-}
-
-
-def db():
- return pymysql.connect(**DB)
-
-
-def hash_pw(password: str, salt: str) -> str:
- return hashlib.scrypt(
- password.encode(), salt=salt.encode(), n=16384, r=8, p=1
- ).hex()
-
-
-def new_salt() -> str:
- return os.urandom(16).hex()
-
-
-class Handler(BaseHTTPRequestHandler):
- def log_message(self, fmt, *args):
- pass # silence default logging
-
- def send_json(self, code, data):
- body = json.dumps(data).encode()
- self.send_response(code)
- self.send_header("Content-Type", "application/json")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def auth_check(self):
- return self.headers.get("X-API-Key") == API_SECRET
-
- def read_body(self):
- n = int(self.headers.get("Content-Length", 0))
- if n == 0:
- return {}
- raw = self.rfile.read(n)
- try:
- return json.loads(raw)
- except Exception:
- return {}
-
- def do_POST(self):
- if not self.auth_check():
- return self.send_json(403, {"error": "forbidden"})
- path = urlparse(self.path).path
- body = self.read_body()
-
- if path == "/auth/register":
- self.handle_register(body)
- elif path == "/auth/login":
- self.handle_login(body)
- elif path == "/character/create":
- self.handle_char_create(body)
- elif path == "/character/save":
- self.handle_char_save(body)
- elif path == "/account/ban":
- self.handle_ban(body)
- else:
- self.send_json(404, {"error": "not found"})
-
- def do_GET(self):
- if not self.auth_check():
- return self.send_json(403, {"error": "forbidden"})
- parsed = urlparse(self.path)
- path = parsed.path
- qs = parse_qs(parsed.query)
-
- if path == "/characters":
- self.handle_chars_list(qs)
- elif path == "/character":
- self.handle_char_get(qs)
- elif path == "/account":
- self.handle_account_get(qs)
- else:
- self.send_json(404, {"error": "not found"})
-
- # --- Auth ---
-
- def handle_register(self, body):
- license_ = body.get("license", "").strip()
- username = body.get("username", "").strip()
- password = body.get("password", "")
-
- if not license_ or not username or not password:
- return self.send_json(400, {"success": False, "error": "Missing fields"})
- if len(username) < 3 or len(username) > 20:
- return self.send_json(400, {"success": False, "error": "Username must be 3–20 characters"})
- if len(password) < 6:
- return self.send_json(400, {"success": False, "error": "Password must be at least 6 characters"})
-
- salt = new_salt()
- pw_hash = hash_pw(password, salt)
- stored = f"{salt}:{pw_hash}"
-
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute("SELECT id FROM accounts WHERE username = %s", (username,))
- if cur.fetchone():
- conn.close()
- return self.send_json(409, {"success": False, "error": "Username already taken"})
- cur.execute("SELECT id FROM accounts WHERE license = %s", (license_,))
- if cur.fetchone():
- conn.close()
- return self.send_json(409, {"success": False, "error": "A JustRP account already exists on this device"})
- cur.execute(
- "INSERT INTO accounts (license, username, password_hash) VALUES (%s, %s, %s)",
- (license_, username, stored),
- )
- account_id = cur.lastrowid
- conn.commit()
- conn.close()
- return self.send_json(200, {"success": True, "account_id": account_id, "username": username})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- def handle_login(self, body):
- license_ = body.get("license", "").strip()
- username = body.get("username", "").strip()
- password = body.get("password", "")
-
- if not license_ or not username or not password:
- return self.send_json(400, {"success": False, "error": "Missing fields"})
-
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute(
- "SELECT id, password_hash, banned, ban_reason FROM accounts WHERE username = %s",
- (username,),
- )
- row = cur.fetchone()
- if not row:
- conn.close()
- return self.send_json(401, {"success": False, "error": "Invalid username or password"})
- if row["banned"]:
- conn.close()
- return self.send_json(403, {"success": False, "error": f"Banned: {row['ban_reason'] or 'No reason given'}"})
-
- stored = row["password_hash"]
- salt, expected = stored.split(":", 1)
- actual = hash_pw(password, salt)
- if actual != expected:
- conn.close()
- return self.send_json(401, {"success": False, "error": "Invalid username or password"})
-
- cur.execute("UPDATE accounts SET last_login = NOW() WHERE id = %s", (row["id"],))
- conn.commit()
- conn.close()
- return self.send_json(200, {"success": True, "account_id": row["id"], "username": username})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- # --- Characters ---
-
- def handle_chars_list(self, qs):
- account_id = int(qs.get("account_id", ["0"])[0])
- if not account_id:
- return self.send_json(400, {"error": "missing account_id"})
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute(
- "SELECT * FROM characters WHERE account_id = %s ORDER BY slot",
- (account_id,),
- )
- rows = cur.fetchall()
- conn.close()
- return self.send_json(200, {"success": True, "characters": rows})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- def handle_char_get(self, qs):
- char_id = int(qs.get("id", ["0"])[0])
- if not char_id:
- return self.send_json(400, {"error": "missing id"})
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute("SELECT * FROM characters WHERE id = %s", (char_id,))
- row = cur.fetchone()
- conn.close()
- if not row:
- return self.send_json(404, {"success": False, "error": "Character not found"})
- return self.send_json(200, {"success": True, "character": row})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- def handle_char_create(self, body):
- required = ["account_id", "slot", "firstname", "lastname"]
- for f in required:
- if f not in body:
- return self.send_json(400, {"success": False, "error": f"Missing field: {f}"})
-
- fields = {
- "account_id": body["account_id"],
- "slot": body["slot"],
- "firstname": body["firstname"][:32],
- "lastname": body["lastname"][:32],
- "backstory": body.get("backstory", ""),
- "gender": body.get("gender", 0),
- "face_blend": body.get("face_blend", 0.5),
- "face_shape": body.get("face_shape", 0.5),
- "skin_tone": body.get("skin_tone", 0.5),
- "eye_color": body.get("eye_color", 0),
- "hair_style": body.get("hair_style", 0),
- "hair_color": body.get("hair_color", 0),
- "body_weight": body.get("body_weight", 0.5),
- "outfit_top": body.get("outfit_top", 0),
- "outfit_pants": body.get("outfit_pants", 0),
- "outfit_shoes": body.get("outfit_shoes", 0),
- }
-
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute(
- "SELECT COUNT(*) as cnt FROM characters WHERE account_id = %s",
- (fields["account_id"],),
- )
- if cur.fetchone()["cnt"] >= 3:
- conn.close()
- return self.send_json(400, {"success": False, "error": "Maximum 3 characters per account"})
-
- cols = ", ".join(fields.keys())
- placeholders = ", ".join(["%s"] * len(fields))
- cur.execute(
- f"INSERT INTO characters ({cols}) VALUES ({placeholders})",
- list(fields.values()),
- )
- char_id = cur.lastrowid
- conn.commit()
- conn.close()
- return self.send_json(200, {"success": True, "character_id": char_id})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- def handle_char_save(self, body):
- char_id = body.get("character_id")
- if not char_id:
- return self.send_json(400, {"success": False, "error": "Missing character_id"})
-
- allowed = {
- "last_x", "last_y", "last_z", "last_heading",
- "cash", "bank", "playtime",
- "face_blend", "face_shape", "skin_tone", "eye_color",
- "hair_style", "hair_color", "body_weight",
- "outfit_top", "outfit_pants", "outfit_shoes",
- }
- updates = {k: v for k, v in body.items() if k in allowed}
- if not updates:
- return self.send_json(200, {"success": True})
-
- set_clause = ", ".join(f"{k} = %s" for k in updates)
- values = list(updates.values()) + [char_id]
-
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute(
- f"UPDATE characters SET {set_clause} WHERE id = %s",
- values,
- )
- conn.commit()
- conn.close()
- return self.send_json(200, {"success": True})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- def handle_account_get(self, qs):
- license_ = qs.get("license", [""])[0]
- if not license_:
- return self.send_json(400, {"error": "missing license"})
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute(
- "SELECT id, username, banned, ban_reason FROM accounts WHERE license = %s",
- (license_,),
- )
- row = cur.fetchone()
- conn.close()
- if not row:
- return self.send_json(404, {"success": False, "error": "not found"})
- return self.send_json(200, {"success": True, "account": row})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
- def handle_ban(self, body):
- account_id = body.get("account_id")
- reason = body.get("reason", "No reason given")
- if not account_id:
- return self.send_json(400, {"success": False, "error": "missing account_id"})
- try:
- conn = db()
- with conn.cursor() as cur:
- cur.execute(
- "UPDATE accounts SET banned = 1, ban_reason = %s WHERE id = %s",
- (reason, account_id),
- )
- conn.commit()
- conn.close()
- return self.send_json(200, {"success": True})
- except Exception as e:
- return self.send_json(500, {"success": False, "error": str(e)})
-
-
-if __name__ == "__main__":
- server = HTTPServer(("127.0.0.1", 8787), Handler)
- print("[JustRP API] Listening on 127.0.0.1:8787", flush=True)
- server.serve_forever()
diff --git a/server-code/data/resources/[rp]/rp_loading/html/fonts/archivo.woff2 b/assets/fonts/archivo-100900-latin.woff2
similarity index 100%
rename from server-code/data/resources/[rp]/rp_loading/html/fonts/archivo.woff2
rename to assets/fonts/archivo-100900-latin.woff2
diff --git a/assets/fonts/archivo.css b/assets/fonts/archivo.css
new file mode 100644
index 0000000..eb2e3ad
--- /dev/null
+++ b/assets/fonts/archivo.css
@@ -0,0 +1,30 @@
+/* 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;
+}
diff --git a/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-400.woff2 b/assets/fonts/plex-400-latin.woff2
similarity index 100%
rename from server-code/data/resources/[rp]/rp_loading/html/fonts/plex-400.woff2
rename to assets/fonts/plex-400-latin.woff2
diff --git a/server-code/data/resources/[rp]/rp_loading/html/fonts/plex-500.woff2 b/assets/fonts/plex-500-latin.woff2
similarity index 100%
rename from server-code/data/resources/[rp]/rp_loading/html/fonts/plex-500.woff2
rename to assets/fonts/plex-500-latin.woff2
diff --git a/assets/fonts/plex.css b/assets/fonts/plex.css
new file mode 100644
index 0000000..e1c7016
--- /dev/null
+++ b/assets/fonts/plex.css
@@ -0,0 +1,90 @@
+/* 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;
+}
diff --git a/bin/fivem.init b/bin/fivem.init
new file mode 100755
index 0000000..4932e44
--- /dev/null
+++ b/bin/fivem.init
@@ -0,0 +1,60 @@
+#!/bin/sh
+### BEGIN INIT INFO
+# Provides: fivem
+# Required-Start: $network mariadb
+# Default-Start: 2 3 4 5
+# Short-Description: Los Santos RP FXServer
+### END INIT INFO
+
+NAME=fivem
+DATA=/opt/fivem/data
+SERVER=/opt/fivem/server
+LOG=/opt/fivem/logs/server.log
+SUPPID=/run/$NAME.sup.pid
+CHILDPID=/run/$NAME.child.pid
+
+status() {
+ if [ -f $CHILDPID ] && kill -0 "$(cat $CHILDPID)" 2>/dev/null; then
+ echo "$NAME running (pid $(cat $CHILDPID))"
+ return 0
+ fi
+ echo "$NAME not running"
+ return 3
+}
+
+start() {
+ 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
+ i=0
+ while ! mysqladmin ping >/dev/null 2>&1; do
+ i=$((i+1))
+ [ $i -gt 30 ] && echo "warning: mariadb did not come up" && break
+ sleep 1
+ done
+
+ 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
+ echo "$NAME started"
+}
+
+stop() {
+ [ -f $SUPPID ] && kill -TERM "$(cat $SUPPID)" 2>/dev/null
+ [ -f $CHILDPID ] && kill -TERM "$(cat $CHILDPID)" 2>/dev/null
+ sleep 2
+ [ -f $CHILDPID ] && kill -9 "$(cat $CHILDPID)" 2>/dev/null
+ rm -f $SUPPID $CHILDPID
+ echo "$NAME stopped"
+}
+
+case "$1" in
+ start) start ;;
+ stop) stop ;;
+ restart) stop; start ;;
+ status) status ;;
+ *) echo "usage: $0 {start|stop|restart|status}"; exit 1 ;;
+esac
diff --git a/bin/rcon b/bin/rcon
new file mode 100755
index 0000000..1eb0392
--- /dev/null
+++ b/bin/rcon
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Send a command to the running FXServer console, e.g. rcon refresh
+if [ ! -p /run/fivem.stdin ]; then
+ echo "server is not running (no console pipe)" >&2
+ exit 1
+fi
+printf '%s\n' "$*" > /run/fivem.stdin
diff --git a/bin/supervise.sh b/bin/supervise.sh
new file mode 100755
index 0000000..e616199
--- /dev/null
+++ b/bin/supervise.sh
@@ -0,0 +1,66 @@
+#!/bin/sh
+# ---------------------------------------------------------------------------
+# supervise.sh
+#
+# 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
diff --git a/server-code/README.md b/docs/SERVER.md
similarity index 64%
rename from server-code/README.md
rename to docs/SERVER.md
index 967cbe2..93bbbe2 100644
--- a/server-code/README.md
+++ b/docs/SERVER.md
@@ -27,6 +27,41 @@ resources: `cfx-server-data` is not used at all, and the bundled `chat` and
| `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
diff --git a/server-code/etc/schema.sql b/etc/schema.sql
similarity index 100%
rename from server-code/etc/schema.sql
rename to etc/schema.sql
diff --git a/etc/secrets.cfg.example b/etc/secrets.cfg.example
new file mode 100644
index 0000000..c594916
--- /dev/null
+++ b/etc/secrets.cfg.example
@@ -0,0 +1,12 @@
+# 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"
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..f50dccc
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,236 @@
+#!/bin/sh
+# ---------------------------------------------------------------------------
+# Los Santos RP - installer
+#
+# Puts a working server on a clean Ubuntu/Debian box: FXServer artefacts,
+# MariaDB with the schema, the resources from this repository, the keep-alive
+# supervisor and a boot entry.
+#
+# sudo sh install.sh --licence cfxk_xxxxxxxx
+#
+# Options
+# --licence KEY Cfx.re server key from https://keymaster.fivem.net
+# --dir PATH install root (default /opt/fivem)
+# --hostname NAME server name shown in the list (default from server.cfg.example)
+# --port N game port (default 30120)
+# --name NAME service name, one per server (default fivem)
+# --db-name NAME database (default rp)
+# --db-user USER database user (default rp)
+# --db-pass PASS password for that user (default: generated)
+# --build ID FXServer build to use (default: latest recommended)
+# --no-start install everything, do not start the server
+# ---------------------------------------------------------------------------
+set -e
+
+DIR=/opt/fivem
+LICENCE=""
+HOSTNAME_=""
+BUILD=""
+DB_PASS=""
+DB_NAME=rp
+DB_USER=rp
+PORT=30120
+SVC=fivem
+START=1
+ARTIFACTS="https://runtime.fivem.net/artifacts/fivem/build_proot_linux/master/"
+SRC=$(cd "$(dirname "$0")" && pwd)
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --licence|--license) LICENCE="$2"; shift 2 ;;
+ --dir) DIR="$2"; shift 2 ;;
+ --hostname) HOSTNAME_="$2"; shift 2 ;;
+ --build) BUILD="$2"; shift 2 ;;
+ --db-pass) DB_PASS="$2"; shift 2 ;;
+ --db-name) DB_NAME="$2"; shift 2 ;;
+ --db-user) DB_USER="$2"; shift 2 ;;
+ --port) PORT="$2"; shift 2 ;;
+ --name) SVC="$2"; shift 2 ;;
+ --no-start) START=0; shift ;;
+ -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
+ *) echo "unknown option: $1" >&2; exit 1 ;;
+ esac
+done
+
+say() { printf '\n\033[1;33m==\033[0m %s\n' "$*"; }
+ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
+die() { printf '\n\033[31mfailed:\033[0m %s\n' "$*" >&2; exit 1; }
+
+[ "$(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"
+command -v apt-get >/dev/null || die "this installer is for Debian/Ubuntu"
+
+# --- 1. packages -----------------------------------------------------------
+say "Installing packages"
+export DEBIAN_FRONTEND=noninteractive
+apt-get update -qq
+apt-get install -y -qq curl ca-certificates xz-utils mariadb-server >/dev/null
+ok "curl, xz-utils, mariadb-server"
+
+# --- 2. FXServer artefacts -------------------------------------------------
+say "Fetching FXServer"
+if [ -n "$BUILD" ]; then
+ REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -oE "\./$BUILD-[0-9a-f]+/fx\.tar\.xz" | head -1)
+ [ -n "$REL" ] || die "build $BUILD not found on $ARTIFACTS"
+else
+ REL=$(curl -fsSL "$ARTIFACTS" | tr '>' '\n' | grep -B4 'LATEST RECOMMENDED' \
+ | grep -oE '\./[0-9]+-[0-9a-f]+/fx\.tar\.xz' | head -1)
+ [ -n "$REL" ] || die "could not find the recommended build on $ARTIFACTS"
+fi
+URL="$ARTIFACTS${REL#./}"
+BUILD_ID=$(echo "$REL" | grep -oE '[0-9]+' | head -1)
+
+mkdir -p "$DIR/server" "$DIR/data/resources" "$DIR/bin" "$DIR/logs" "$DIR/etc"
+if [ -x "$DIR/server/run.sh" ] && [ -f "$DIR/server/.build" ] && [ "$(cat "$DIR/server/.build")" = "$BUILD_ID" ]; then
+ ok "build $BUILD_ID already installed"
+else
+ TMP=$(mktemp -d)
+ curl -fL# "$URL" -o "$TMP/fx.tar.xz" || die "download failed: $URL"
+ rm -rf "$DIR/server"; mkdir -p "$DIR/server"
+ tar xJf "$TMP/fx.tar.xz" -C "$DIR/server"
+ rm -rf "$TMP"
+ chmod +x "$DIR/server/run.sh"
+ echo "$BUILD_ID" > "$DIR/server/.build"
+ ok "build $BUILD_ID unpacked into $DIR/server"
+fi
+
+# --- 3. database -----------------------------------------------------------
+say "Preparing the database"
+if [ ! -f /etc/init.d/mariadb ] && [ ! -d /run/systemd/system ]; then
+ die "no init system found for mariadb"
+fi
+if [ -d /run/systemd/system ]; then
+ systemctl enable --now mariadb >/dev/null 2>&1 || true
+else
+ service mariadb start >/dev/null 2>&1 || true
+fi
+i=0
+while ! mysqladmin ping >/dev/null 2>&1; do
+ i=$((i+1)); [ $i -gt 60 ] && die "mariadb did not start"
+ sleep 1
+done
+
+if [ -z "$DB_PASS" ]; then
+ if [ -f "$DIR/data/secrets.cfg" ]; then
+ DB_PASS=$(sed -n 's/^set rp_db_pass "\(.*\)"$/\1/p' "$DIR/data/secrets.cfg" | head -1)
+ fi
+ [ -n "$DB_PASS" ] || DB_PASS=$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | cut -c1-24)
+fi
+
+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 "ALTER USER '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';"
+mysql -e "GRANT SELECT, INSERT, UPDATE, DELETE ON \`$DB_NAME\`.* TO '$DB_USER'@'127.0.0.1'; FLUSH PRIVILEGES;"
+mysql "$DB_NAME" < "$SRC/etc/schema.sql"
+ok "database $DB_NAME, user $DB_USER@127.0.0.1, schema applied"
+
+# --- 4. resources and configuration ---------------------------------------
+say "Installing the server files"
+rm -rf "$DIR/data/resources/[rp]"
+mkdir -p "$DIR/data/resources"
+cp -r "$SRC/resources/[rp]" "$DIR/data/resources/"
+cp "$SRC/bin/supervise.sh" "$DIR/bin/"
+sed "s|/run/fivem.stdin|/run/$SVC.stdin|" "$SRC/bin/rcon" > "$DIR/bin/rcon"
+chmod +x "$DIR/bin/supervise.sh" "$DIR/bin/rcon"
+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
+ ok "server.cfg kept (already present)"
+else
+ sed -e "s|exec /opt/fivem/data/secrets.cfg|exec $DIR/data/secrets.cfg|" \
+ -e "s|0.0.0.0:30120|0.0.0.0:$PORT|g" \
+ "$SRC/server.cfg.example" > "$DIR/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"
+ ok "server.cfg written"
+fi
+
+if [ -z "$LICENCE" ] && [ -f "$DIR/data/secrets.cfg" ]; then
+ LICENCE=$(sed -n 's/^sv_licenseKey "\(.*\)"$/\1/p' "$DIR/data/secrets.cfg" | head -1)
+fi
+cat > "$DIR/data/secrets.cfg" < "/etc/init.d/$SVC"
+chmod +x "/etc/init.d/$SVC"
+
+if [ -d /run/systemd/system ]; then
+ cat > "/etc/systemd/system/$SVC.service" </dev/null 2>&1
+ ok "systemd unit $SVC.service"
+elif [ -d /etc/redl/enabled ]; then
+ # REDL VDS: no systemd, the platform starts whatever is marked here
+ touch "/etc/redl/enabled/$SVC" /etc/redl/enabled/mariadb
+ 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
+ command -v update-rc.d >/dev/null && update-rc.d "$SVC" defaults >/dev/null 2>&1 || true
+ ok "/etc/init.d/$SVC installed"
+fi
+
+# --- 6. start --------------------------------------------------------------
+if [ "$START" = 0 ]; then
+ say "Done (not started, --no-start)"
+ exit 0
+fi
+if [ -z "$LICENCE" ]; then
+ say "Done"
+ 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"
+ exit 0
+fi
+
+say "Starting the server"
+if [ -d /run/systemd/system ]; then systemctl restart "$SVC"; else service "$SVC" restart >/dev/null; fi
+
+i=0
+while [ $i -lt 90 ]; do
+ if grep -q "Authenticated with cfx.re Nucleus" "$DIR/logs/server.log" 2>/dev/null; then
+ 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"
+ IP=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo "")
+ printf '\n connect %s:%s\n logs: tail -f %s\n console: rcon status\n\n' "$IP" "$PORT" "$DIR/logs/server.log"
+ exit 0
+ 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 [ -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"
+ fi
+ i=$((i+1)); sleep 2
+done
+die "the server did not come up in 3 minutes - see $DIR/logs/server.log"
diff --git a/server-code/data/resources/[rp]/rp_core/client/state.lua b/resources/[rp]/rp_core/client/state.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/client/state.lua
rename to resources/[rp]/rp_core/client/state.lua
diff --git a/server-code/data/resources/[rp]/rp_core/fxmanifest.lua b/resources/[rp]/rp_core/fxmanifest.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/fxmanifest.lua
rename to resources/[rp]/rp_core/fxmanifest.lua
diff --git a/server-code/data/resources/[rp]/rp_core/lib/callbacks_client.lua b/resources/[rp]/rp_core/lib/callbacks_client.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/lib/callbacks_client.lua
rename to resources/[rp]/rp_core/lib/callbacks_client.lua
diff --git a/server-code/data/resources/[rp]/rp_core/lib/callbacks_server.lua b/resources/[rp]/rp_core/lib/callbacks_server.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/lib/callbacks_server.lua
rename to resources/[rp]/rp_core/lib/callbacks_server.lua
diff --git a/server-code/data/resources/[rp]/rp_core/server/crypto.js b/resources/[rp]/rp_core/server/crypto.js
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/server/crypto.js
rename to resources/[rp]/rp_core/server/crypto.js
diff --git a/server-code/data/resources/[rp]/rp_core/server/player.lua b/resources/[rp]/rp_core/server/player.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/server/player.lua
rename to resources/[rp]/rp_core/server/player.lua
diff --git a/server-code/data/resources/[rp]/rp_core/shared/appearance.lua b/resources/[rp]/rp_core/shared/appearance.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/shared/appearance.lua
rename to resources/[rp]/rp_core/shared/appearance.lua
diff --git a/server-code/data/resources/[rp]/rp_core/shared/config.lua b/resources/[rp]/rp_core/shared/config.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/shared/config.lua
rename to resources/[rp]/rp_core/shared/config.lua
diff --git a/server-code/data/resources/[rp]/rp_core/shared/util.lua b/resources/[rp]/rp_core/shared/util.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_core/shared/util.lua
rename to resources/[rp]/rp_core/shared/util.lua
diff --git a/server-code/data/resources/[rp]/rp_db/fxmanifest.lua b/resources/[rp]/rp_db/fxmanifest.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_db/fxmanifest.lua
rename to resources/[rp]/rp_db/fxmanifest.lua
diff --git a/server-code/data/resources/[rp]/rp_db/lib/db.lua b/resources/[rp]/rp_db/lib/db.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_db/lib/db.lua
rename to resources/[rp]/rp_db/lib/db.lua
diff --git a/server-code/data/resources/[rp]/rp_db/server/mysql.js b/resources/[rp]/rp_db/server/mysql.js
similarity index 100%
rename from server-code/data/resources/[rp]/rp_db/server/mysql.js
rename to resources/[rp]/rp_db/server/mysql.js
diff --git a/server-code/data/resources/[rp]/rp_dbtest/fxmanifest.lua b/resources/[rp]/rp_dbtest/fxmanifest.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_dbtest/fxmanifest.lua
rename to resources/[rp]/rp_dbtest/fxmanifest.lua
diff --git a/server-code/data/resources/[rp]/rp_dbtest/server/test.lua b/resources/[rp]/rp_dbtest/server/test.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_dbtest/server/test.lua
rename to resources/[rp]/rp_dbtest/server/test.lua
diff --git a/server-code/data/resources/[rp]/rp_loading/fxmanifest.lua b/resources/[rp]/rp_loading/fxmanifest.lua
similarity index 90%
rename from server-code/data/resources/[rp]/rp_loading/fxmanifest.lua
rename to resources/[rp]/rp_loading/fxmanifest.lua
index 6035030..fae2bcb 100644
--- a/server-code/data/resources/[rp]/rp_loading/fxmanifest.lua
+++ b/resources/[rp]/rp_loading/fxmanifest.lua
@@ -20,7 +20,8 @@ files {
'html/tokens.css',
'html/app.css',
'html/app.js',
- 'html/fonts/archivo.woff2',
+ 'html/fonts/archivo-var.woff2',
+ 'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
diff --git a/server-code/data/resources/[rp]/rp_loading/html/app.css b/resources/[rp]/rp_loading/html/app.css
similarity index 78%
rename from server-code/data/resources/[rp]/rp_loading/html/app.css
rename to resources/[rp]/rp_loading/html/app.css
index 6576a72..84a872b 100644
--- a/server-code/data/resources/[rp]/rp_loading/html/app.css
+++ b/resources/[rp]/rp_loading/html/app.css
@@ -46,7 +46,7 @@
.rail {
position: relative;
z-index: 2;
- width: var(--rail);
+ width: var(--sheet);
height: 100%;
padding: var(--gut-y) var(--gut-x);
display: grid;
@@ -68,14 +68,14 @@
/* --- file header --------------------------------------------------------- */
.file {
- border-top: 1px solid var(--sodium-line);
+ border-top: 1px solid rgba(217,178,60,.42);
padding-top: 14px;
}
.file .serial {
margin-top: 7px;
- color: var(--paper-dim);
+ color: rgba(233,226,204,.62);
}
-.file .serial span { color: var(--sodium); }
+.file .serial span { color: var(--canary); }
/* --- wordmark ------------------------------------------------------------ */
@@ -108,13 +108,13 @@
font-size: 12px;
font-weight: 500;
letter-spacing: 0.42em;
- color: var(--sodium);
+ color: var(--canary);
text-transform: uppercase;
}
.dash {
flex: 1;
height: 1px;
- background: linear-gradient(90deg, var(--sodium-line), transparent);
+ background: linear-gradient(90deg, rgba(217,178,60,.42), transparent);
}
/* --- notices ------------------------------------------------------------- */
@@ -126,9 +126,9 @@
justify-content: space-between;
align-items: baseline;
padding-bottom: 10px;
- border-bottom: 1px solid var(--line);
+ border-bottom: 1px solid rgba(233,226,204,.14);
}
-.notice-index { color: var(--muted); }
+.notice-index { color: rgba(233,226,204,.40); }
.notice { padding-top: 18px; min-height: 132px; }
@@ -140,7 +140,7 @@
}
.notice-body {
font-size: 14.5px;
- color: var(--paper-dim);
+ color: rgba(233,226,204,.62);
max-width: 40ch;
}
@@ -168,7 +168,7 @@
gap: 16px;
}
.step {
- color: var(--paper-dim);
+ color: rgba(233,226,204,.62);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -178,27 +178,27 @@
font-weight: 500;
font-size: 28px;
line-height: 1;
- color: var(--paper);
+ color: var(--stock-hi);
font-variant-numeric: tabular-nums;
}
.pct i {
font-style: normal;
font-size: 13px;
- color: var(--muted);
+ color: rgba(233,226,204,.40);
margin-left: 2px;
}
.track {
position: relative;
height: 2px;
- background: var(--line);
+ background: rgba(233,226,204,.14);
overflow: hidden;
}
.bar {
display: block;
height: 100%;
width: 0%;
- background: var(--sodium);
+ background: var(--canary);
transition: width 420ms var(--ease);
}
/* a light runs ahead of the fill so the bar reads as active even when the
@@ -227,7 +227,7 @@
gap: 14px;
margin-top: 26px;
padding-top: 18px;
- border-top: 1px solid var(--line-soft);
+ border-top: 1px solid rgba(233,226,204,.07);
}
.mute {
@@ -236,15 +236,15 @@
display: grid;
place-items: center;
background: transparent;
- border: 1px solid var(--line);
- color: var(--paper-dim);
+ 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(--sodium); border-color: var(--sodium-line); }
+.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: var(--muted); }
+.mute.off { color: rgba(233,226,204,.40); }
input[type='range'] {
flex: 1;
@@ -256,14 +256,14 @@ input[type='range'] {
}
input[type='range']::-webkit-slider-runnable-track {
height: 2px;
- background: var(--line);
+ 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(--sodium);
+ background: var(--canary);
border: 0;
border-radius: 0;
transition: height 160ms var(--ease), margin-top 160ms var(--ease);
@@ -274,11 +274,11 @@ input[type='range']:focus-visible::-webkit-slider-thumb {
margin-top: -9.5px;
}
-.volval { width: 3ch; text-align: right; color: var(--muted); }
+.volval { width: 3ch; text-align: right; color: rgba(233,226,204,.40); }
.audiohint {
margin-top: 10px;
- color: var(--sodium);
+ color: var(--canary);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.45; } }
@@ -300,3 +300,26 @@ 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); }
diff --git a/server-code/data/resources/[rp]/rp_loading/html/app.js b/resources/[rp]/rp_loading/html/app.js
similarity index 100%
rename from server-code/data/resources/[rp]/rp_loading/html/app.js
rename to resources/[rp]/rp_loading/html/app.js
diff --git a/resources/[rp]/rp_loading/html/fonts/archivo-var.woff2 b/resources/[rp]/rp_loading/html/fonts/archivo-var.woff2
new file mode 100644
index 0000000..e2448d0
Binary files /dev/null and b/resources/[rp]/rp_loading/html/fonts/archivo-var.woff2 differ
diff --git a/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 b/resources/[rp]/rp_loading/html/fonts/plex-400.woff2
similarity index 100%
rename from server-code/data/resources/[rp]/rp_ui/html/fonts/plex-400.woff2
rename to resources/[rp]/rp_loading/html/fonts/plex-400.woff2
diff --git a/server-code/data/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 b/resources/[rp]/rp_loading/html/fonts/plex-500.woff2
similarity index 100%
rename from server-code/data/resources/[rp]/rp_ui/html/fonts/plex-500.woff2
rename to resources/[rp]/rp_loading/html/fonts/plex-500.woff2
diff --git a/resources/[rp]/rp_loading/html/fonts/plexsans-var.woff2 b/resources/[rp]/rp_loading/html/fonts/plexsans-var.woff2
new file mode 100644
index 0000000..b757bc5
Binary files /dev/null and b/resources/[rp]/rp_loading/html/fonts/plexsans-var.woff2 differ
diff --git a/server-code/data/resources/[rp]/rp_loading/html/index.html b/resources/[rp]/rp_loading/html/index.html
similarity index 100%
rename from server-code/data/resources/[rp]/rp_loading/html/index.html
rename to resources/[rp]/rp_loading/html/index.html
diff --git a/resources/[rp]/rp_loading/html/tokens.css b/resources/[rp]/rp_loading/html/tokens.css
new file mode 100644
index 0000000..8f0823b
--- /dev/null
+++ b/resources/[rp]/rp_loading/html/tokens.css
@@ -0,0 +1,182 @@
+/* ---------------------------------------------------------------------------
+ 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;
+ }
+}
diff --git a/server-code/data/resources/[rp]/rp_selftest/fxmanifest.lua b/resources/[rp]/rp_selftest/fxmanifest.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_selftest/fxmanifest.lua
rename to resources/[rp]/rp_selftest/fxmanifest.lua
diff --git a/server-code/data/resources/[rp]/rp_selftest/server/test.lua b/resources/[rp]/rp_selftest/server/test.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_selftest/server/test.lua
rename to resources/[rp]/rp_selftest/server/test.lua
diff --git a/server-code/data/resources/[rp]/rp_session/fxmanifest.lua b/resources/[rp]/rp_session/fxmanifest.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_session/fxmanifest.lua
rename to resources/[rp]/rp_session/fxmanifest.lua
diff --git a/server-code/data/resources/[rp]/rp_session/server/main.lua b/resources/[rp]/rp_session/server/main.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_session/server/main.lua
rename to resources/[rp]/rp_session/server/main.lua
diff --git a/server-code/data/resources/[rp]/rp_ui/client/camera.lua b/resources/[rp]/rp_ui/client/camera.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_ui/client/camera.lua
rename to resources/[rp]/rp_ui/client/camera.lua
diff --git a/server-code/data/resources/[rp]/rp_ui/client/main.lua b/resources/[rp]/rp_ui/client/main.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_ui/client/main.lua
rename to resources/[rp]/rp_ui/client/main.lua
diff --git a/server-code/data/resources/[rp]/rp_ui/client/ped.lua b/resources/[rp]/rp_ui/client/ped.lua
similarity index 100%
rename from server-code/data/resources/[rp]/rp_ui/client/ped.lua
rename to resources/[rp]/rp_ui/client/ped.lua
diff --git a/server-code/data/resources/[rp]/rp_ui/fxmanifest.lua b/resources/[rp]/rp_ui/fxmanifest.lua
similarity index 89%
rename from server-code/data/resources/[rp]/rp_ui/fxmanifest.lua
rename to resources/[rp]/rp_ui/fxmanifest.lua
index 0f190e0..952550b 100644
--- a/server-code/data/resources/[rp]/rp_ui/fxmanifest.lua
+++ b/resources/[rp]/rp_ui/fxmanifest.lua
@@ -26,7 +26,8 @@ files {
'html/tokens.css',
'html/app.css',
'html/app.js',
- 'html/fonts/archivo.woff2',
+ 'html/fonts/archivo-var.woff2',
+ 'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
diff --git a/resources/[rp]/rp_ui/html/app.css b/resources/[rp]/rp_ui/html/app.css
new file mode 100644
index 0000000..779191b
--- /dev/null
+++ b/resources/[rp]/rp_ui/html/app.css
@@ -0,0 +1,820 @@
+/* ---------------------------------------------------------------------------
+ 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%; }
+}
diff --git a/server-code/data/resources/[rp]/rp_ui/html/app.js b/resources/[rp]/rp_ui/html/app.js
similarity index 98%
rename from server-code/data/resources/[rp]/rp_ui/html/app.js
rename to resources/[rp]/rp_ui/html/app.js
index 336a290..eb27eb5 100644
--- a/server-code/data/resources/[rp]/rp_ui/html/app.js
+++ b/resources/[rp]/rp_ui/html/app.js
@@ -72,12 +72,18 @@ function paintChips(activeKey) {
});
}
+/* 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) {
@@ -491,7 +497,8 @@ const Creator = {
swatches(label, palette, value, onPick) {
const wrap = document.createElement('div');
- wrap.className = 'ctrl';
+ /* A colour chart is 16 columns wide, so it always takes the full form. */
+ wrap.className = 'ctrl span2';
wrap.innerHTML = `
`;
diff --git a/resources/[rp]/rp_ui/html/fonts/archivo-var.woff2 b/resources/[rp]/rp_ui/html/fonts/archivo-var.woff2
new file mode 100644
index 0000000..e2448d0
Binary files /dev/null and b/resources/[rp]/rp_ui/html/fonts/archivo-var.woff2 differ
diff --git a/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 b/resources/[rp]/rp_ui/html/fonts/plex-400.woff2
new file mode 100644
index 0000000..0804aaf
Binary files /dev/null and b/resources/[rp]/rp_ui/html/fonts/plex-400.woff2 differ
diff --git a/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 b/resources/[rp]/rp_ui/html/fonts/plex-500.woff2
new file mode 100644
index 0000000..090f82f
Binary files /dev/null and b/resources/[rp]/rp_ui/html/fonts/plex-500.woff2 differ
diff --git a/resources/[rp]/rp_ui/html/fonts/plexsans-var.woff2 b/resources/[rp]/rp_ui/html/fonts/plexsans-var.woff2
new file mode 100644
index 0000000..b757bc5
Binary files /dev/null and b/resources/[rp]/rp_ui/html/fonts/plexsans-var.woff2 differ
diff --git a/server-code/data/resources/[rp]/rp_ui/html/index.html b/resources/[rp]/rp_ui/html/index.html
similarity index 55%
rename from server-code/data/resources/[rp]/rp_ui/html/index.html
rename to resources/[rp]/rp_ui/html/index.html
index 81f5543..10ee82f 100644
--- a/server-code/data/resources/[rp]/rp_ui/html/index.html
+++ b/resources/[rp]/rp_ui/html/index.html
@@ -14,10 +14,21 @@
-
LOS SANTOS
-
ROLEPLAY
+
+
+ LOS SANTOS
+ ROLEPLAY
+
+
-
- FILE
0000-000000
+
+ File
+ 0000-000000
-
+
-
-
-
Office of Records
-
+
+
+ City of Los Santos
+ Office of Vital Records
+
VR‑1
+
+
Open a file
Everything you do in this city is recorded against this file. Choose a name you will
@@ -58,7 +73,7 @@
@@ -66,7 +81,7 @@
+ maxlength="72" placeholder="••••••••">
At least 8 characters, with a letter and a number.
@@ -78,7 +93,7 @@
@@ -96,16 +111,25 @@
Already have a file?
-
-
+
+
+
+
+ City of Los Santos
+ Register of Persons
+
+
VR‑4
+
+
+
Who are you today?
Registered identities
@@ -113,44 +137,44 @@
0 / 3
-
Who are you today?
-
+
+
-
+
-
-
-
+
-
-
-
New identity
-
-
+
+
+ City of Los Santos
+ Application for Identity
+
+
-
+
+
-
+
-
+
+
-
Drag to turn
+
+
+
+
+
+ Subject
+ Drag to turn
+
-
+
-
-
-
Placement
-
+
+
+ City of Los Santos
+ Notice of Placement
+
VR‑9
+
+
Where does the day start?
Pick a district. You can move once you are on the ground.
+
+
-
-
+
+
+
+
+
+
+ Sheet
+ Los Santos County
+
+
+ ScaleNot to scale
+ Sheet1 of 1
+
+
-
FILED
+
+ Filed
+ Office of Vital Records
+
-
-
-
-
-
FORM LS-01 · RESIDENCY
-
-
DEPT. RESIDENT AFFAIRS
-
-
-
-
-
-
LOS SANTOS COUNTY
-
ResidentIntake
-
-
Everyone in this county is on file. Open yours, or sign back into the one you already have — the city picks up exactly where you left it.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Password
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Password—
-
-
-
-
-
-
-
-
-
Confirm Password
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
FILE INDEX
-
-
LOS SANTOS COUNTY
-
-
-
-
DEPT. OF RESIDENT AFFAIRS · FILE INDEX
-
SIGNED IN · —
-
-
-
-
Your Records
-
SELECT A RECORD TO RESUME · THREE PER FILE
-
-
-
-
-
-
-
-
-
-
- INTAKE PHOTOGRAPHY · BAY 3
LS-000000
-
-
-
-
-
-
-
-
LOS SANTOS COUNTY
IDENTITY RECORD
-
LS-000000
-
-
UNNAMED
-
NO DECLARATION ON FILE
-
- - SEX
- M
- - BUILD
- MEDIUM
- - COMPLEXION
- III
- - HAIR
- 01 / 01
- - EYES
- 01
- - ATTIRE
- 01·01·01
-
-
-
-
Filed
-
-
-
DRAG TO ROTATE SUBJECT
-
-
-
-
-
FORM LS-02 · IDENTITY RECORD
-
New Record
-
SLOT 1 OF 3
-
-
SEC.1 FACE
-
SEC.2 BUILD
-
SEC.3 ATTIRE
-
SEC.4 NAME
-
-
-
-
-
-
-
-
SUBJECT SEX
-
-
-
-
-
FACIAL STRUCTURE
-
-
-
-
EYE COLOUR
-
-
-
-
-
-
PHYSICAL BUILD
-
-
HAIR STYLE
-
-
HAIR COLOUR
-
-
-
-
-
-
TOP
-
-
LEGWEAR
-
-
FOOTWEAR
-
-
-
-
-
-
LEGAL NAME
-
-
-
STATEMENT OF ARRIVAL
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
FORM LS-03 · POINT OF ENTRY
-
-
LOS SANTOS COUNTY
-
-
-
-
POINT OF ENTRY · COUNTY SURVEY GRID
-
RECORD · —
-
-
-
-
-
-
- SELECT A POINT OF ENTRY
- ON THE COUNTY GRID
-
-
-
ENTRY 01
-
—
-
—
-
- - DISTRICT
- —
- - GRID REF
- —
- - DENSITY
- —
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/[rp]/rp_ui/html/tokens.css b/resources/[rp]/rp_ui/html/tokens.css
new file mode 100644
index 0000000..8f0823b
--- /dev/null
+++ b/resources/[rp]/rp_ui/html/tokens.css
@@ -0,0 +1,182 @@
+/* ---------------------------------------------------------------------------
+ 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;
+ }
+}
diff --git a/resources/justrp/client/main.lua b/resources/justrp/client/main.lua
deleted file mode 100644
index 48efd40..0000000
--- a/resources/justrp/client/main.lua
+++ /dev/null
@@ -1,343 +0,0 @@
--- JustRP — Client-side core
--- Manages NUI screens, cameras, spawning and position sync
-
-local inSession = false
-local charLoaded = false
-local camHandle = nil
-local saveTimer = 0
-
--- ─── NUI helper ─────────────────────────────────────────────────────────────
-
-local function nuiMsg(action, data)
- SendNUIMessage({ action = action, data = data or {} })
-end
-
--- ─── On resource start (loading screen is already showing) ──────────────────
-
-AddEventHandler("onClientMapStart", function()
- Wait(1500) -- give the game a moment to settle
- TriggerServerEvent("justrp:clientReady")
-end)
-
--- ─── Session start from server ───────────────────────────────────────────────
-
-RegisterNetEvent("justrp:startSession")
-AddEventHandler("justrp:startSession", function()
- -- Freeze world while auth is happening
- SetEntityVisible(PlayerPedId(), false, false)
- FreezeEntityPosition(PlayerPedId(), true)
- DisplayHud(false)
- DisplayRadar(false)
-
- -- Shut loading screen, then show auth UI
- Wait(800)
- ShutdownLoadingScreenNui()
- Wait(300)
-
- SetNuiFocus(true, true)
- nuiMsg("showAuth")
- inSession = true
-end)
-
--- ─── Auth results ────────────────────────────────────────────────────────────
-
-RegisterNetEvent("justrp:auth:ok")
-AddEventHandler("justrp:auth:ok", function(accountId, username)
- nuiMsg("authOk", { accountId = accountId, username = username })
- TriggerServerEvent("justrp:chars:load")
-end)
-
-RegisterNetEvent("justrp:auth:fail")
-AddEventHandler("justrp:auth:fail", function(err)
- nuiMsg("authFail", { error = err })
-end)
-
--- ─── Character list from server ──────────────────────────────────────────────
-
-RegisterNetEvent("justrp:chars:list")
-AddEventHandler("justrp:chars:list", function(chars)
- nuiMsg("showChars", { characters = chars })
-end)
-
--- ─── Character creation result ───────────────────────────────────────────────
-
-RegisterNetEvent("justrp:char:created")
-AddEventHandler("justrp:char:created", function(charId)
- -- Reload character list so the new one shows
- TriggerServerEvent("justrp:chars:load")
-end)
-
-RegisterNetEvent("justrp:char:createFail")
-AddEventHandler("justrp:char:createFail", function(err)
- nuiMsg("charCreateFail", { error = err })
-end)
-
--- ─── Character selected → load appearance ────────────────────────────────────
-
-RegisterNetEvent("justrp:char:loaded")
-AddEventHandler("justrp:char:loaded", function(ch)
- -- Apply appearance to player ped
- local ped = PlayerPedId()
- local model = ch.gender == 1 and GetHashKey("mp_f_freemode_01") or GetHashKey("mp_m_freemode_01")
-
- RequestModel(model)
- while not HasModelLoaded(model) do Wait(10) end
- SetPlayerModel(PlayerId(), model)
- SetModelAsNoLongerNeeded(model)
-
- ped = PlayerPedId()
-
- -- Face blend
- SetPedHeadBlendData(
- ped,
- math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
- 0,
- math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
- 0,
- ch.face_blend, ch.face_blend, 0.0, false
- )
-
- -- Skin overlay (skin tone)
- SetPedMicroblendData(ped, ch.skin_tone, ch.skin_tone, ch.skin_tone)
-
- -- Eyes
- SetPedEyeColor(ped, ch.eye_color)
-
- -- Hair
- SetPedComponentVariation(ped, 2, ch.hair_style, 0, 2)
- SetPedHairColor(ped, ch.hair_color, 0)
-
- -- Body shape
- SetPedFaceFeature(ped, 16, (ch.body_weight - 0.5) * 2) -- body weight feature
-
- -- Outfit
- SetPedComponentVariation(ped, 11, ch.outfit_top, 0, 2)
- SetPedComponentVariation(ped, 4, ch.outfit_pants, 0, 2)
- SetPedComponentVariation(ped, 6, ch.outfit_shoes, 0, 2)
-
- -- Tell NUI to show spawn screen
- nuiMsg("showSpawn", {
- name = ch.firstname .. " " .. ch.lastname,
- })
-end)
-
--- ─── Spawn ───────────────────────────────────────────────────────────────────
-
-RegisterNetEvent("justrp:spawn:go")
-AddEventHandler("justrp:spawn:go", function(pos)
- local ped = PlayerPedId()
-
- -- Teleport to position and unfreeze
- SetEntityCoords(ped, pos.x, pos.y, pos.z, false, false, false, true)
- SetEntityHeading(ped, pos.h)
-
- -- Cinematic camera fly-down
- local camX = pos.x + math.sin(math.rad(pos.h)) * 200
- local camY = pos.y + math.cos(math.rad(pos.h)) * 200
- camHandle = CreateCameraWithParams(
- "DEFAULT_SCRIPTED_CAMERA",
- camX, camY, pos.z + 180,
- 0.0, 0.0, 0.0,
- 60.0, false, 0
- )
- SetCamActive(camHandle, true)
- RenderScriptCams(true, true, 1000, true, true)
-
- -- Animate camera flying down to player
- local steps = 80
- Citizen.CreateThread(function()
- for i = 1, steps do
- local t = i / steps
- local ease = 1 - math.pow(1 - t, 3) -- ease out cubic
-
- local cx = camX + (pos.x - camX) * ease
- local cy = camY + (pos.y - camY) * ease
- local cz = (pos.z + 180) + ((pos.z + 2) - (pos.z + 180)) * ease
-
- SetCamCoord(camHandle, cx, cy, cz)
- PointCamAtCoord(camHandle, pos.x, pos.y, pos.z)
- Wait(16)
- end
-
- -- Transition back to player cam
- RenderScriptCams(false, true, 1200, true, true)
- Wait(1300)
- DestroyCam(camHandle, false)
- camHandle = nil
-
- -- Reveal player
- SetEntityVisible(ped, true, false)
- FreezeEntityPosition(ped, false)
- DisplayHud(true)
- DisplayRadar(true)
- SetNuiFocus(false, false)
- nuiMsg("hide")
- charLoaded = true
- end)
-end)
-
--- ─── NUI Callbacks ───────────────────────────────────────────────────────────
-
--- Auth
-RegisterNUICallback("authRegister", function(data, cb)
- TriggerServerEvent("justrp:auth:register", data.username, data.password)
- cb("ok")
-end)
-
-RegisterNUICallback("authLogin", function(data, cb)
- TriggerServerEvent("justrp:auth:login", data.username, data.password)
- cb("ok")
-end)
-
--- Character management
-RegisterNUICallback("charSelect", function(data, cb)
- TriggerServerEvent("justrp:char:select", tonumber(data.id))
- cb("ok")
-end)
-
-RegisterNUICallback("charCreate", function(data, cb)
- TriggerServerEvent("justrp:char:create", data)
- cb("ok")
-end)
-
--- Spawn selection
-RegisterNUICallback("spawnRequest", function(data, cb)
- TriggerServerEvent("justrp:spawn:request", data.location)
- cb("ok")
-end)
-
--- Character creation — camera rotation control
-RegisterNUICallback("rotateCam", function(data, cb)
- if camHandle then
- -- data.delta is horizontal rotation amount
- local heading = GetEntityHeading(PlayerPedId())
- SetEntityHeading(PlayerPedId(), heading - (data.delta or 0) * 0.4)
- end
- cb("ok")
-end)
-
--- ─── Character creation preview camera ───────────────────────────────────────
-
-local charCamActive = false
-local charCamAngle = 0.0
-
-RegisterNUICallback("startCharCam", function(data, cb)
- if charCamActive then
- cb("ok")
- return
- end
- charCamActive = true
- local ped = PlayerPedId()
- SetEntityVisible(ped, true, false)
-
- camHandle = CreateCamera("DEFAULT_SCRIPTED_CAMERA", false)
- SetCamActive(camHandle, true)
- RenderScriptCams(true, false, 0, true, true)
-
- Citizen.CreateThread(function()
- while charCamActive do
- local pedPos = GetEntityCoords(ped)
- local angle = charCamAngle
- local dist = 1.6
- local cx = pedPos.x + math.sin(math.rad(angle)) * dist
- local cy = pedPos.y - math.cos(math.rad(angle)) * dist
- local cz = pedPos.z + 0.7
-
- SetCamCoord(camHandle, cx, cy, cz)
- PointCamAtCoord(camHandle, pedPos.x, pedPos.y, pedPos.z + 0.7)
- charCamAngle = charCamAngle + 0.15 -- slow auto-orbit
-
- Wait(16)
- end
- end)
-
- cb("ok")
-end)
-
-RegisterNUICallback("stopCharCam", function(data, cb)
- charCamActive = false
- if camHandle then
- RenderScriptCams(false, true, 600, true, true)
- Wait(700)
- DestroyCam(camHandle, false)
- camHandle = nil
- end
- cb("ok")
-end)
-
-RegisterNUICallback("charCamDrag", function(data, cb)
- charCamAngle = charCamAngle + (data.delta or 0) * 0.5
- charCamActive = true -- stop auto-orbit briefly
- cb("ok")
-end)
-
--- Apply live appearance updates during char creation
-RegisterNUICallback("applyAppearance", function(data, cb)
- local ped = PlayerPedId()
-
- if data.gender ~= nil then
- local model = data.gender == 1
- and GetHashKey("mp_f_freemode_01")
- or GetHashKey("mp_m_freemode_01")
- if not HasModelLoaded(model) then
- RequestModel(model)
- while not HasModelLoaded(model) do Wait(10) end
- end
- SetPlayerModel(PlayerId(), model)
- SetModelAsNoLongerNeeded(model)
- ped = PlayerPedId()
- end
-
- if data.face_blend ~= nil then
- SetPedHeadBlendData(
- ped,
- math.floor(data.face_blend * 45),
- math.floor(data.face_blend * 45),
- 0,
- math.floor(data.face_blend * 45),
- math.floor(data.face_blend * 45),
- 0,
- data.face_blend, data.face_blend, 0.0, false
- )
- end
- if data.skin_tone ~= nil then
- SetPedMicroblendData(ped, data.skin_tone, data.skin_tone, data.skin_tone)
- end
- if data.eye_color ~= nil then
- SetPedEyeColor(ped, data.eye_color)
- end
- if data.hair_style ~= nil or data.hair_color ~= nil then
- SetPedComponentVariation(ped, 2, data.hair_style or 0, 0, 2)
- SetPedHairColor(ped, data.hair_color or 0, 0)
- end
- if data.body_weight ~= nil then
- SetPedFaceFeature(ped, 16, (data.body_weight - 0.5) * 2)
- end
- if data.outfit_top ~= nil then
- SetPedComponentVariation(ped, 11, data.outfit_top, 0, 2)
- end
- if data.outfit_pants ~= nil then
- SetPedComponentVariation(ped, 4, data.outfit_pants, 0, 2)
- end
- if data.outfit_shoes ~= nil then
- SetPedComponentVariation(ped, 6, data.outfit_shoes, 0, 2)
- end
-
- cb("ok")
-end)
-
--- ─── Position auto-save ───────────────────────────────────────────────────────
-
-Citizen.CreateThread(function()
- while true do
- Wait(60000)
- if charLoaded then
- local ped = PlayerPedId()
- local pos = GetEntityCoords(ped)
- local hdg = GetEntityHeading(ped)
- TriggerServerEvent("justrp:savePosition", pos.x, pos.y, pos.z, hdg)
- end
- end
-end)
-
-print("[JustRP] Client core loaded.")
diff --git a/resources/justrp/fxmanifest.lua b/resources/justrp/fxmanifest.lua
deleted file mode 100644
index ee46c44..0000000
--- a/resources/justrp/fxmanifest.lua
+++ /dev/null
@@ -1,25 +0,0 @@
-fx_version 'cerulean'
-game 'gta5'
-
-author 'JustRP'
-description 'JustRP — Core authentication, character management and session control'
-version '1.0.0'
-
-loadscreen 'html/loading.html'
-loadscreen_manual_shutdown 'yes'
-loadscreen_cursor_shown 'yes'
-
-ui_page 'html/app.html'
-
-client_scripts {
- 'client/main.lua',
-}
-
-server_scripts {
- 'server/main.lua',
-}
-
-files {
- 'html/loading.html',
- 'html/app.html',
-}
diff --git a/resources/justrp/html/app.html b/resources/justrp/html/app.html
deleted file mode 100644
index bd20337..0000000
--- a/resources/justrp/html/app.html
+++ /dev/null
@@ -1,1203 +0,0 @@
-
-
-