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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 5
2026-08-12 23:42:20 +00:00
co-authored by Claude Opus 5
parent 71856b15e9
commit c857979a55
88 changed files with 3904 additions and 7463 deletions
@@ -0,0 +1,133 @@
/* FiveRP — loading screen behaviour.
The game drives this page by posting messages at `window`. The only field
that is a genuine 0..1 measure of progress is `loadProgress.loadFraction`;
everything else is narration we turn into the phase and detail lines. */
(function () {
'use strict';
const elPct = document.getElementById('pct');
const elFill = document.getElementById('fill');
const elPhase = document.getElementById('phase');
const elDetail = document.getElementById('detail');
const elTip = document.getElementById('tip');
/* ------------------------------------------------------------- progress */
// Shown and target values are kept apart so the number eases toward the
// truth instead of jumping. The game reports progress in coarse steps.
let shown = 0;
let target = 0;
function setTarget(fraction) {
const pct = Math.max(0, Math.min(100, fraction * 100));
// Never walk backwards: a later phase reporting a lower fraction would
// otherwise make the bar visibly retreat.
if (pct > target) target = pct;
}
function tick() {
const gap = target - shown;
if (gap > 0.05) {
shown += Math.max(gap * 0.08, 0.05);
if (shown > target) shown = target;
elFill.style.width = shown.toFixed(2) + '%';
elPct.textContent = Math.floor(shown);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
/* ---------------------------------------------------------------- phases */
function phase(text) {
if (elPhase.textContent !== text) elPhase.textContent = text;
}
function detail(text) {
const clean = String(text || '').trim();
if (!clean) return;
elDetail.textContent = clean.length > 88 ? clean.slice(0, 87) + '…' : clean;
}
// Init function names arrive as engine internals ("CPathServer::InitSession").
// Strip the noise so the line reads as a status, not a stack trace.
function humanise(name) {
return String(name || '')
.replace(/^dlc_?/i, 'DLC ')
.replace(/::.*$/, '')
.replace(/^C(?=[A-Z])/, '')
.replace(/([a-z])([A-Z])/g, '$1 $2');
}
window.addEventListener('message', function (event) {
const data = event.data || {};
switch (data.eventName) {
case 'loadProgress':
setTarget(Number(data.loadFraction) || 0);
break;
case 'startInitFunctionOrder':
phase('Loading game');
break;
case 'startInitFunction':
phase('Loading game');
break;
case 'initFunctionInvoking':
phase('Loading game');
detail(humanise(data.name));
// The engine gives idx/count per order; use it when loadProgress is
// quiet so the bar keeps creeping during long orders.
if (data.count) setTarget(0.1 + 0.5 * (Number(data.idx) / Number(data.count)));
break;
case 'startDataFileEntries':
phase('Streaming content');
detail('Mounting ' + (data.count || 0) + ' data files');
break;
case 'performMappedDataFileEntry':
phase('Streaming content');
detail(String(data.name || '').split('/').pop());
break;
case 'onLogLine':
detail(data.message);
break;
case 'onDataFileEntry':
phase('Streaming content');
break;
}
});
/* ------------------------------------------------------------------ tips */
const TIPS = [
'Your account and your character are separate — one login can hold your identity for good.',
'Names are issued once. Pick a first and last name you want to be known by.',
'Press F8 at any time to open the console if something looks wrong.',
'Your passport is proof of residency — officers may ask to see it.',
'Use a real email address. It is the only way to recover an account.',
'Speak in character. The city is more fun when everyone stays in the story.',
'Lost? The Legion Square spawn is the heart of downtown Los Santos.'
];
let tipIndex = 0;
setInterval(function () {
tipIndex = (tipIndex + 1) % TIPS.length;
elTip.classList.add('is-swapping');
setTimeout(function () {
elTip.textContent = TIPS[tipIndex];
elTip.classList.remove('is-swapping');
}, 450);
}, 7000);
/* Nothing has reported in yet — creep to a believable floor so the screen
never sits at a dead 0% while the engine warms up. */
setTimeout(function () { setTarget(0.04); }, 400);
})();
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>FiveRP</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Slow drifting colour behind frosted white. Nothing here is interactive. -->
<div class="sky" aria-hidden="true">
<span class="blob blob-a"></span>
<span class="blob blob-b"></span>
<span class="blob blob-c"></span>
<span class="blob blob-d"></span>
</div>
<div class="veil" aria-hidden="true"></div>
<main class="stage">
<div class="mark fade-up">
<span class="mark-glyph">FR</span>
</div>
<p class="eyebrow fade-up delay-1">San Andreas &middot; Los Santos</p>
<h1 class="wordmark fade-up delay-1">FiveRP</h1>
<p class="tagline fade-up delay-2">Preparing the city for your arrival</p>
<section class="meter fade-up delay-3" aria-label="Loading progress">
<div class="meter-top">
<span class="phase" id="phase">Starting up</span>
<span class="pct"><span id="pct">0</span><i>%</i></span>
</div>
<div class="track">
<div class="fill" id="fill"></div>
</div>
<p class="detail" id="detail">Waiting for the game to hand over&hellip;</p>
</section>
</main>
<footer class="dock">
<div class="tip fade-up delay-4">
<span class="tip-label">Tip</span>
<span class="tip-body" id="tip">Your account and your character are separate — one login can hold your identity for good.</span>
</div>
<div class="badge fade-up delay-4">
<span class="dot"></span>
<span>192.227.168.73<i>:30120</i></span>
</div>
</footer>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,346 @@
/* FiveRP — loading screen.
Design system: /opt/fivem/server-data/CLAUDE.md
This page owns the whole screen (no game behind it), so unlike the in-game
panels it is allowed an opaque background and real shadows. */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 300 800;
font-display: block;
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: #f5f5f7;
--bg-deep: #e8e8ed;
--surface: rgba(255, 255, 255, 0.72);
--surface-solid: #ffffff;
--fg: #1d1d1f;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong: #0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
'Liberation Mono', 'DejaVu Sans Mono', monospace;
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
color-scheme: light;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
cursor: none;
}
/* ---------------------------------------------------------------------- sky */
/* Four wide, heavily blurred washes drifting on long loops. Kept pale so the
page still reads as white — colour is a hint, not a gradient poster. */
.sky { position: fixed; inset: -20%; z-index: 0; filter: blur(90px); }
.blob {
position: absolute;
display: block;
border-radius: 50%;
will-change: transform;
}
.blob-a {
width: 46vw; height: 46vw; top: 4%; left: 8%;
background: radial-gradient(circle, rgba(0,113,227,0.30), rgba(0,113,227,0) 70%);
animation: driftA 34s var(--ease) infinite alternate;
}
.blob-b {
width: 40vw; height: 40vw; top: 32%; left: 52%;
background: radial-gradient(circle, rgba(90,200,250,0.32), rgba(90,200,250,0) 70%);
animation: driftB 41s var(--ease) infinite alternate;
}
.blob-c {
width: 34vw; height: 34vw; top: 56%; left: 18%;
background: radial-gradient(circle, rgba(201,182,255,0.28), rgba(201,182,255,0) 70%);
animation: driftC 47s var(--ease) infinite alternate;
}
.blob-d {
width: 30vw; height: 30vw; top: 6%; left: 68%;
background: radial-gradient(circle, rgba(255,214,165,0.30), rgba(255,214,165,0) 70%);
animation: driftD 38s var(--ease) infinite alternate;
}
@keyframes driftA { to { transform: translate3d(9vw, 7vh, 0) scale(1.14); } }
@keyframes driftB { to { transform: translate3d(-11vw, -6vh, 0) scale(1.08); } }
@keyframes driftC { to { transform: translate3d(7vw, -9vh, 0) scale(1.18); } }
@keyframes driftD { to { transform: translate3d(-8vw, 8vh, 0) scale(1.10); } }
/* A milky sheet over the colour: this is what turns four blobs into frosted
white rather than a lava lamp. */
.veil {
position: fixed;
inset: 0;
z-index: 1;
background:
radial-gradient(110% 85% at 50% 42%, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0.72) 46%, rgba(255,255,255,0.88) 100%),
linear-gradient(180deg, rgba(245,245,247,0.20), rgba(232,232,237,0.42));
}
/* -------------------------------------------------------------------- stage */
.stage {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 32px 96px;
text-align: center;
}
/* App-icon style badge: the one saturated object on an otherwise white page. */
.mark {
width: 84px;
height: 84px;
display: grid;
place-items: center;
border-radius: 24px;
background-image: linear-gradient(160deg, #35a0ff 0%, var(--accent) 52%, #0059b8 100%);
margin-bottom: 26px;
}
.mark-glyph {
font-size: 27px;
font-weight: 800;
letter-spacing: -0.045em;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.22);
}
.eyebrow {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.20em;
text-transform: uppercase;
color: var(--fg-tertiary);
margin-bottom: 14px;
}
.wordmark {
font-size: clamp(52px, 6.4vw, 84px);
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
color: var(--fg);
margin-bottom: 16px;
}
.tagline {
font-size: 14px;
font-weight: 400;
color: var(--fg-secondary);
margin-bottom: 46px;
}
/* -------------------------------------------------------------------- meter */
/* Left-aligned: the phase and detail lines change constantly, and centred text
that reflows on every update reads as jitter. */
.meter { width: min(560px, 84vw); text-align: left; }
.meter-top {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12px;
}
.phase {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--fg-secondary);
}
.pct {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.045em;
color: var(--fg);
font-variant-numeric: tabular-nums;
}
.pct i {
font-style: normal;
font-size: 15px;
font-weight: 600;
color: var(--fg-tertiary);
margin-left: 3px;
}
.track {
position: relative;
height: 8px;
border-radius: 999px;
/* Inset rather than flat grey: on a near-white page a 7%-black bar reads as
a smudge, a hairline-bordered groove reads as a control. */
background: rgba(0, 0, 0, 0.055);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
border-radius: 999px;
background-image: linear-gradient(90deg, #5ac8fa, var(--accent));
transition: width 520ms var(--ease);
}
/* Travelling highlight so a stalled bar still looks alive. */
.fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg,
rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%);
transform: translateX(-100%);
animation: sweep 1.9s var(--ease) infinite;
}
@keyframes sweep { to { transform: translateX(100%); } }
.detail {
margin-top: 14px;
height: 14px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0;
color: var(--fg-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity .3s ease;
}
/* --------------------------------------------------------------------- dock */
.dock {
position: fixed;
z-index: 2;
left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 32px;
}
.tip {
display: flex;
align-items: center;
gap: 12px;
max-width: 62vw;
padding: 12px 18px 12px 12px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line-strong);
}
.tip-label {
flex: none;
padding: 5px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.tip-body {
font-size: 12.5px;
font-weight: 400;
line-height: 1.5;
color: var(--fg-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity .45s ease, transform .45s var(--ease);
}
.tip-body.is-swapping { opacity: 0; transform: translateY(-6px); }
.badge {
flex: none;
display: flex;
align-items: center;
gap: 9px;
padding: 11px 18px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line-strong);
font-family: var(--mono);
font-size: 11.5px;
color: var(--fg-secondary);
letter-spacing: 0;
}
.badge i { font-style: normal; color: var(--fg-tertiary); }
.badge .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--success, #34c759);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .45; } }
/* ------------------------------------------------------------------- motion */
.fade-up { animation: fadeUp .7s cubic-bezier(.22,1,.36,1) both; }
.delay-1 { animation-delay: .07s; }
.delay-2 { animation-delay: .14s; }
.delay-3 { animation-delay: .21s; }
.delay-4 { animation-delay: .30s; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}