166 lines
8.0 KiB
JavaScript
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';
|
|
}
|