The leagues, phase 1: the profile clock ticks — xp, levels, the identity screen, the server mirror
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc10216d0b
commit
9133c0d351
12 files changed
+507
-19
No files matched your search
+146
@@ -0,0 +1,146 @@
|
||||
// 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 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';
|
||||
}
|
||||
Reference in new issue
Block a user