(() => { 'use strict'; const RESOURCE = 'fiverp-auth'; const stage = document.getElementById('stage'); const slab = document.getElementById('slab'); const railFill = document.getElementById('railFill'); const permit = document.getElementById('permit'); const sheen = document.getElementById('sheen'); const seal = document.getElementById('seal'); const cardNo = document.getElementById('cardNo'); const cardFirst = document.getElementById('cardFirst'); const cardLast = document.getElementById('cardLast'); const cardDob = document.getElementById('cardDob'); const cardIssue = document.getElementById('cardIssue'); const cardExpiry = document.getElementById('cardExpiry'); const cardSign = document.getElementById('cardSign'); const mrz1 = document.getElementById('mrz1'); const mrz2 = document.getElementById('mrz2'); const stampDate = document.getElementById('stampDate'); const views = { login: document.getElementById('formLogin'), account: document.getElementById('formAccount'), identity: document.getElementById('formIdentity') }; let current = 'login'; let busy = false; // Set when sign-in finds an account whose registration never reached step 2. let resuming = false; // ------------------------------------------------------------------ post function post(endpoint, data) { return fetch(`https://${RESOURCE}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=UTF-8' }, body: JSON.stringify(data || {}) }).catch(() => {}); } // ------------------------------------------------------------------ views function show(view) { current = view; slab.dataset.view = view; Object.entries(views).forEach(([name, form]) => { form.classList.toggle('is-current', name === view); }); document.querySelectorAll('.rail-step').forEach((step) => { const which = step.dataset.step; step.classList.toggle('is-active', which === view); step.classList.toggle('is-done', which === 'account' && view === 'identity'); }); railFill.classList.toggle('is-full', view === 'identity'); const first = views[view].querySelector('input'); if (first) setTimeout(() => first.focus(), 240); } function notice(view, message, good) { const el = views[view].querySelector('[data-notice]'); if (!message) { el.classList.remove('is-shown', 'is-good'); el.textContent = ''; return; } el.textContent = message; el.classList.add('is-shown'); el.classList.toggle('is-good', !!good); } function setBusy(view, state) { busy = state; const btn = views[view].querySelector('.btn'); btn.classList.toggle('is-busy', state); btn.disabled = state; } function values(view) { const out = {}; views[view].querySelectorAll('input').forEach((input) => { out[input.name] = input.value.trim(); }); return out; } function markBad(view, names) { views[view].querySelectorAll('input').forEach((input) => { input.classList.toggle('is-bad', names.includes(input.name)); }); } // =========================================================== the passport // Everything on the data page is derived from the name, so the same resident // always gets the same document — it reads as a record, not a random mock-up. function seedOf(text) { let hash = 0; for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; return hash; } function passportNumber(seed) { return String(seed % 1000000000).padStart(9, '0'); } const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']; function human(date) { return String(date.getDate()).padStart(2, '0') + ' ' + MONTHS[date.getMonth()] + ' ' + date.getFullYear(); } // MRZ dates are YYMMDD. function mrzDate(date) { return String(date.getFullYear() % 100).padStart(2, '0') + String(date.getMonth() + 1).padStart(2, '0') + String(date.getDate()).padStart(2, '0'); } // ICAO 9303 check digit: weights cycle 7-3-1 over the field. function checkDigit(field) { const WEIGHTS = [7, 3, 1]; let sum = 0; for (let i = 0; i < field.length; i++) { const c = field[i]; let v; if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48; else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55; else v = 0; // '<' filler sum += v * WEIGHTS[i % 3]; } return String(sum % 10); } function pad(text, length) { return (text + '<'.repeat(length)).slice(0, length); } function buildDocument(firstName, lastName) { const seed = seedOf((firstName + lastName).toLowerCase()); const number = passportNumber(seed); const now = new Date(); const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate()); // A plausible adult birth date, stable for this name. const dob = new Date( now.getFullYear() - (21 + (seed % 34)), (seed >>> 7) % 12, 1 + ((seed >>> 13) % 28) ); const surname = lastName.toUpperCase(); const given = firstName.toUpperCase(); const line1 = pad('P { const box = permit.getBoundingClientRect(); sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`); sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`); }); // --------------------------------------------------------- password meter const pwInput = views.account.querySelector('input[name="password"]'); const meter = views.account.querySelector('[data-meter]'); pwInput.addEventListener('input', () => { const v = pwInput.value; meter.classList.toggle('is-live', v.length > 0); let level = 1; if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2; if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3; meter.dataset.level = String(level); meter.querySelector('i').style.width = `${level * 33.4}%`; }); views.identity.querySelectorAll('input').forEach((input) => { input.addEventListener('input', () => { // Keep the document honest: only English letters ever reach it. input.value = input.value.replace(/[^A-Za-z]/g, ''); syncPassport(); }); }); // ------------------------------------------------------------ validation const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i; const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/; const RE_NAME = /^[A-Za-z]{2,24}$/; function checkAccount(v) { if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']]; if (!RE_USERNAME.test(v.username)) return ['Username must be 3–32 characters, letters, numbers or underscore.', ['username']]; if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']]; return null; } function checkIdentity(v) { if (!RE_NAME.test(v.firstName)) return ['First name must be 2–24 English letters.', ['firstName']]; if (!RE_NAME.test(v.lastName)) return ['Last name must be 2–24 English letters.', ['lastName']]; return null; } // ---------------------------------------------------------------- submits views.login.addEventListener('submit', (e) => { e.preventDefault(); if (busy) return; const v = values('login'); if (!v.username || !v.password) { markBad('login', [!v.username ? 'username' : 'password']); return notice('login', 'Enter your username and password.'); } markBad('login', []); notice('login', ''); setBusy('login', true); post('login', v); }); views.account.addEventListener('submit', (e) => { e.preventDefault(); if (busy) return; const v = values('account'); const bad = checkAccount(v); if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); } markBad('account', []); notice('account', ''); setBusy('account', true); post('registerAccount', v); }); views.identity.addEventListener('submit', (e) => { e.preventDefault(); if (busy) return; const v = values('identity'); const bad = checkIdentity(v); if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); } markBad('identity', []); notice('identity', ''); setBusy('identity', true); post(resuming ? 'resumeIdentity' : 'registerIdentity', v); }); document.querySelectorAll('[data-goto]').forEach((btn) => { btn.addEventListener('click', () => { if (busy) return; notice(current, ''); show(btn.dataset.goto); }); }); // ------------------------------------------------------ messages from Lua window.addEventListener('message', (event) => { const msg = event.data || {}; const p = msg.payload || {}; switch (msg.action) { case 'open': stage.classList.add('is-open'); stage.setAttribute('aria-hidden', 'false'); show('login'); break; case 'close': stage.classList.remove('is-open'); stage.setAttribute('aria-hidden', 'true'); break; case 'accountResult': setBusy('account', false); if (p.ok) { notice('account', ''); show('identity'); syncPassport(); } else { notice('account', p.error || 'Something went wrong.'); } break; case 'identityResult': setBusy('identity', false); if (p.ok) { fillPassport(String(p.firstName), String(p.lastName)); issuePassport(); notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true); views.identity.querySelector('.btn span').textContent = 'Entering the city…'; views.identity.querySelector('.btn').disabled = true; // Nothing left to go back to once the document exists. views.identity.querySelector('.switch').style.display = 'none'; } else { if (p.reset) { resuming = false; show('account'); } notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.'); } break; case 'loginResult': setBusy('login', false); if (p.ok && p.needsIdentity) { // Account is real but has no resident yet — finish step 2. resuming = true; show('identity'); syncPassport(); notice('identity', 'Your file is open. Name your resident to finish.', true); } else if (p.ok) { notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true); views.login.querySelector('.btn span').textContent = 'Entering the city…'; views.login.querySelector('.btn').disabled = true; } else { markBad('login', ['username', 'password']); notice('login', p.error || 'Something went wrong.'); } break; } }); // Clear the error ring as soon as the player starts fixing the field. document.querySelectorAll('input').forEach((input) => { input.addEventListener('input', () => input.classList.remove('is-bad')); }); })();