// profile.js — THE META-PROGRESSION PROFILE: xp, level, league, and its server mirror. // // The plan of record is PROJECT.md "THE LEAGUES". Phase 1 (this file): the clock ticks and is // visible; nothing reads the level back into gameplay yet. The profile is the ONE persistent // object the leagues hang off: xp/level (you), league + champion flag (the world), and the setup // flag that gates the first-boot identity screen. Name, tour, kit and skin deliberately KEEP // their existing homes (stats' NAME_KEY, the picker localStorage keys) — this file only mirrors // them to the server so a wiped phone gets its whole identity back, not just its number. // // XP comes from ACTIVE PLAY EVENTS, never wall-clock (settled): losing pays too, training pays // nothing. award() carries the no-stakes guard itself — drill, bench, test mode, the training // court — so a new call site cannot forget it. // // The server mirror is a BACKUP, not an account: localStorage is the authority while it exists, // and pull() adopts the mirror only when it is AHEAD on xp (a wiped phone, a second device). // Same trust stance as the world board: the client is public JS, the id is the only claim. import { API, UI } from './config.js'; import { G } from './state.js'; import { myId, savedName, saveName } from './stats.js'; const KEY = 'splatterProfile'; function load() { try { const p = JSON.parse(localStorage.getItem(KEY) || 'null'); if (p && typeof p === 'object') { return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)), ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0 }; } } catch {} return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0 }; } export const prof = load(); export function save() { try { localStorage.setItem(KEY, JSON.stringify(prof)); } catch {} } // ---------------------------------------------------------------- the curve ---- // Fast early, slow late (settled): the first levels land inside the first run — the hook — and // the late ones each take sessions. Cumulative xp to REACH level l; one constant and one power, // because a table would be twenty numbers saying the same thing. export const LEVEL_CAP = 50; export const xpFor = l => Math.round(80 * Math.pow(Math.max(0, l - 1), 1.8)); export function levelAt(xp) { let l = 1; while (l < LEVEL_CAP && xp >= xpFor(l + 1)) l++; return l; } // { level, into, need, frac } — everything a bar needs, one call. frac is 1 at the cap. export function progress(xp = prof.xp) { const level = levelAt(xp); if (level >= LEVEL_CAP) return { level, into: 0, need: 0, frac: 1 }; const a = xpFor(level), b = xpFor(level + 1); return { level, into: xp - a, need: b - a, frac: (xp - a) / (b - a) }; } // ---------------------------------------------------------------- awards ---- // What one event pays. Sized so a decent run lands ~1-2k xp: kills are plentiful and cheap, // points are rarer, a game is the headline. Rebalancing is this table and nothing else. export const XP = { kill: 2, point: 8, game: 40, duelWin: 60, round: 150, cup: 400 }; // The run's own delta, for the death screen ("+340 XP · LEVEL 7 → 8"). Module-local like the // stats bag, and for the same reason: G is restored from PRISTINE by restartRun and the summary // has to survive exactly that. beginRunXp() is called beside stats.beginRun(). let runXp = 0, runFrom = levelAt(prof.xp); export function beginRunXp() { runXp = 0; runFrom = levelAt(prof.xp); } export const runDelta = () => ({ xp: runXp, from: runFrom, to: levelAt(prof.xp) }); // ONE guard for every no-stakes mode, in the shared function — a call site cannot forget it. const muted = () => G.drill || G.bench || G.testMode || (G.match && G.match.train); export function award(kind, n = 1) { if (muted()) return 0; const amt = (XP[kind] || 0) * n; if (!amt) return 0; const before = levelAt(prof.xp); prof.xp += amt; runXp += amt; save(); const after = levelAt(prof.xp); // the level-up announces as a CARD, never a modal — it can land on a live court (popup policy) if (after > before && !G.modal) G.card = { title: 'LEVEL ' + after, sub1: null, color: UI.pick, t: 2.4 }; return amt; } // ---------------------------------------------------------------- the level is you ---- // Phase 4's defensive buffs, through existing dials only: level 1 is the shipped game (factor 1) // and the cap stays honest — a maxed profile survives volleys better, it does not stop dying. // Read at the dial sites (meleeCooldown's floor, the parry's reach/arm, hurtPlayer's grace). export function bonus() { const f = (levelAt(prof.xp) - 1) / (LEVEL_CAP - 1); return { melee: 1 - 0.25 * f, // the swish floor drops to 75% — panic scales with mastery parry: 1 + 0.25 * f, // the parry reaches a quarter further grace: 1 + 0.35 * f }; // a volley needs a third longer to land twice } // ---------------------------------------------------------------- the promotion ---- // A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart // consumes once the summary is tapped away). Module-local and consumed-on-read, the same shape as // stats.lastRun and for the same reason: it must survive restartRun's PRISTINE restore. let promo = null; export const setPromo = p => { promo = p; }; export function takePromo() { const p = promo; promo = null; return p; } // ---------------------------------------------------------------- the mirror ---- // POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server // refuses to go backward on xp (see server.py), so a stale device cannot clobber the investment. export async function push() { try { const id = myId(); if (!id) return; // private mode: no claim, no mirror const grab = k => { try { return localStorage.getItem(k) || ''; } catch { return ''; } }; await fetch(API + 'profile', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, p: { ...prof, n: savedName(), tour: grab('tour'), kit: grab('kit'), skin: grab('skin') } }) }); } catch {} } // Adopt the mirror when it is ahead — and bring the identity back with it, but never overwrite // an identity this device already has (localStorage is the authority while it exists). export async function pull() { try { const id = myId(); if (!id) return; const r = await fetch(API + 'profile?id=' + id, { cache: 'no-store' }); if (!r.ok) return; const p = (await r.json()).p; if (!p) return; if ((p.xp | 0) > prof.xp) { prof.xp = p.xp | 0; prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1)); prof.ch = prof.ch || (p.ch ? 1 : 0); prof.setup = prof.setup || (p.setup ? 1 : 0); runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta save(); } if (!savedName() && p.n) saveName(p.n); for (const k of ['tour', 'kit', 'skin']) try { if (!localStorage.getItem(k) && p[k]) localStorage.setItem(k, p[k]); } catch {} } catch {} } // ---------------------------------------------------------------- zombie names ---- // The first-boot prefill and the dice button. Two lists beat one, and the 12-char board limit // (server NAME_MAX) is enforced by re-rolling, not by truncating a name into gibberish. const NAME_A = ['ACE', 'DEUCE', 'SMASH', 'VOLLEY', 'GRIM', 'ROT', 'BONE', 'CRYPT', 'GRAVE', 'GHOUL', 'FESTER', 'MOLDY', 'PUTRID', 'RIGOR', 'TOMB', 'SLICE', 'LOB', 'MAGGOT', 'MORBID', 'RANCID', 'SOGGY', 'LIMP']; const NAME_B = ['FANG', 'DECAY', 'MARROW', 'CADAVER', 'SLUDGE', 'GRISTLE', 'KNUCKLE', 'SOCKET', 'GIBLET', 'WORM', 'SHANK', 'SPLEEN', 'MOLAR', 'STUMP', 'CRAWLER', 'RATTLE', 'DRIBBLE', 'WHIFF']; export function zombieName() { for (let i = 0; i < 40; i++) { const n = NAME_A[(Math.random() * NAME_A.length) | 0] + ' ' + NAME_B[(Math.random() * NAME_B.length) | 0]; if (n.length <= 12) return n; } return 'BONE FANG'; }