Files
splatter-tennis/src/profile.js
T

166 lines
8.0 KiB
JavaScript

// 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';
}