Three animated screens in the storyboard's look, built in the page over
a paused world:
- The act title card: the act's court pans in behind ACT n, its league,
its court and its goal; it closes itself after three seconds or on a
tap.
- The cup's ceremony: YOU CLEARED THE COURT, the rounds and the run's
xp, then the act's weapons drop into a rack one by one ("…and gained
these for the hordes to come"), and the chapter preview with the
mid-boss named and game 10 still "???" until the transformation.
- The act card: what the act cost (bodies, bosses, deaths), what it
opened in the arcade, and the next act's court; CONTINUE goes straight
on, THE CLUBHOUSE goes home.
The checkpoint keeps its in-game banner. Headless, every screen falls
back to the plain card it replaced. v381, staging only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
14 KiB
JavaScript
262 lines
14 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; gameplay reads its bounded bonuses back through bonus()/levelAt(). 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
|
|
// one quarter. award() carries the no-stakes guard itself — drill, bench and test mode — 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';
|
|
|
|
// THE CLUB REMEMBERS THE ATTEMPTS (2026-08-31, THE SEASON). Two small fields carry the whole
|
|
// record: `cups` is league -> how many rounds you have ever reached in its cup (1 = round one,
|
|
// rounds.length = you took the final), and `out` is the rung of the body that put you out last.
|
|
// That is what turns a lost cup from a null event into a line on the board — and what lets the
|
|
// training court offer the one that beat you. Bounded on load like everything else that comes
|
|
// back off a device.
|
|
const cleanCups = (c, cap = 9) => {
|
|
const out = {};
|
|
if (c && typeof c === 'object')
|
|
for (let l = 1; l <= 5; l++) { const v = c[l] | 0; if (v > 0) out[l] = Math.min(cap, v); }
|
|
return out;
|
|
};
|
|
|
|
// THE STORY (2026-10-15, PROJECT.md "THE STORY"): where the player stands in it. `act` 1..5, `cp` the
|
|
// checkpoint to restart from ('pro' the prologue, 'cup' the act's cup, 'chap' the chapter's first
|
|
// game, 'mid' after the mid-boss, 'done' the story is told), `rack` the weapons that checkpoint
|
|
// hands back, `tries` failed attempts since the checkpoint (the help offer reads it). An ACT is the
|
|
// unit that is saved; the checkpoints live inside it.
|
|
const CPS = ['pro', 'cup', 'chap', 'mid', 'done'];
|
|
export const freshStory = () => ({ act: 1, cp: 'pro', rack: [], tries: 0, deaths: 0 });
|
|
export const cleanStory = s => {
|
|
if (!s || typeof s !== 'object') return freshStory();
|
|
const rack = Array.isArray(s.rack) ? s.rack.filter(e => e && typeof e.id === 'string' && e.n > 0).slice(0, 9)
|
|
.map(e => ({ id: e.id.slice(0, 12), n: Math.min(99, e.n | 0) })) : [];
|
|
return { act: Math.min(5, Math.max(1, s.act | 0 || 1)), cp: CPS.includes(s.cp) ? s.cp : 'cup',
|
|
rack, tries: Math.max(0, Math.min(99, s.tries | 0)), deaths: Math.max(0, Math.min(999, s.deaths | 0)) };
|
|
};
|
|
|
|
function load() {
|
|
try {
|
|
const p = JSON.parse(localStorage.getItem(KEY) || 'null');
|
|
if (p && typeof p === 'object') {
|
|
// A PROFILE FROM BEFORE THE STORY starts the story from the top (Oliver: the testers play the
|
|
// whole thing). Its level stays — xp is the server mirror's one-way ratchet and cannot go
|
|
// down — but the league, the cups and the champion flag were earned on the old main path,
|
|
// and the story is the main path now: it hands them out again, act by act.
|
|
const pre = !p.story;
|
|
return { v: 2, xp: Math.max(0, p.xp | 0), lg: pre ? 1 : Math.min(5, Math.max(1, p.lg | 0 || 1)),
|
|
ch: !pre && p.ch ? 1 : 0, setup: p.setup ? 1 : 0,
|
|
cups: pre ? {} : cleanCups(p.cups), at: pre ? {} : cleanCups(p.at, 50),
|
|
out: pre ? 0 : Math.max(0, Math.min(99, p.out | 0)), story: cleanStory(p.story) };
|
|
}
|
|
} catch {}
|
|
return { v: 2, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, at: {}, out: 0, story: freshStory() };
|
|
}
|
|
|
|
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.
|
|
// The training court left this list 2026-08-29 (Oliver: zero pay made it feel pointless in a
|
|
// meta game): it pays A QUARTER now — reps count, the real court stays strictly 4x better, and
|
|
// the no-permadeath grind can never be the optimal one.
|
|
const muted = () => G.drill || G.bench || G.testMode;
|
|
|
|
export function award(kind, n = 1) {
|
|
if (muted()) return 0;
|
|
let amt = (XP[kind] || 0) * n;
|
|
if (G.match && G.match.train) amt = Math.round(amt / 4);
|
|
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 ----
|
|
// The level's buffs, through existing dials only: level 1 is the shipped game (factor 1, the
|
|
// untouchable baseline) and the caps stay honest. FRONT-LOADED (sqrt of the level fraction,
|
|
// 2026-08-29): Oliver plays at level ~20 and the buffs must be felt on the way up, not only at
|
|
// the cap — the xp curve itself is fast-early for the same reason. At level 20 the fraction is
|
|
// ~0.62 of the cap value; linear gave 0.39.
|
|
// Read at the dial sites: meleeCooldown's floor, the parry's reach/arm, hurtPlayer's grace (the
|
|
// gore trio), and — THE LEAGUE PAIR (his league-4 report: "autorun schlicht zu langsam, timing
|
|
// zu tight") — gameplay.assistK's legs and trySwing's contact window, league modes only.
|
|
export function bonus() {
|
|
const f = Math.sqrt((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
|
|
run: 1 + 0.15 * f, // the league autorun's legs, up to +15%
|
|
window: 1 + 0.20 * f }; // the league contact window, up to +20% — cleaner timing too:
|
|
// quality is centre-normalised, so the same ms error scores higher
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the cup record ----
|
|
// How far you have ever got in a league's cup, and who ended the last attempt. Written by the two
|
|
// places a cup run can end (zombies.killPlayer, beats.cupWon) and read by the summary, the league
|
|
// panel and the training court. A record only ever moves FORWARD — an early exit after a deep run
|
|
// does not erase the deep run, it just is not news.
|
|
export const cupBest = (lg = prof.lg) => prof.cups[lg] | 0;
|
|
// THE INSCRIPTION (2026-08-31, the cup board): the level you were when you took a league's cup.
|
|
// It is what the cup board breaks its ties on and what a badge on the card says under it — the
|
|
// same cup taken with fewer buffs is the better win. Written once, at the promotion, and never
|
|
// overwritten: a league is taken once.
|
|
export const cupAt = lg => prof.at[lg] | 0;
|
|
export function recordCupWin(lg, level) {
|
|
if (!(lg >= 1 && lg <= 5) || !(level > 0) || prof.at[lg]) return;
|
|
prof.at[lg] = Math.min(LEVEL_CAP, level);
|
|
save();
|
|
}
|
|
export function recordCup(lg, reached, rung = 0) {
|
|
if (!(lg >= 1 && lg <= 5) || reached < 1) return;
|
|
if (reached > cupBest(lg)) prof.cups[lg] = Math.min(9, reached);
|
|
if (rung) prof.out = rung; // the LAST one to put you out, deep run or not
|
|
save();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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; }
|
|
// ...and a PEEK, for the summary: a won cup parks a ceremony, and the handover has to point at
|
|
// that instead of at the next draw. Never consumes — main.maybeRestart is the only consumer.
|
|
export const hasPromo = () => !!promo;
|
|
|
|
// ---------------------------------------------------------------- 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;
|
|
// the story's place comes back FORWARD only, and only from a mirror that has one: a mirror
|
|
// written before the story carries the old main path's league and cups, which the story hands
|
|
// out again — adopting them would skip acts the tester is meant to play
|
|
const story = p.story && typeof p.story === 'object';
|
|
if (story) {
|
|
const t = cleanStory(p.story), here = prof.story;
|
|
const rank = s => s.cp === 'done' ? 99 : s.act * 10 + ['pro', 'cup', 'chap', 'mid'].indexOf(s.cp);
|
|
if (rank(t) > rank(here)) { prof.story = { ...t, rack: [], tries: 0, cp: t.cp === 'mid' ? 'chap' : t.cp }; save(); }
|
|
}
|
|
if ((p.xp | 0) > prof.xp) {
|
|
prof.xp = p.xp | 0;
|
|
prof.setup = prof.setup || (p.setup ? 1 : 0); // the identity is done whichever path it was done on
|
|
if (!story) { runFrom = levelAt(prof.xp); save(); }
|
|
}
|
|
if (story && (p.xp | 0) >= prof.xp) {
|
|
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);
|
|
// the cup record comes back with the xp it was earned beside — merged forward, never down
|
|
for (let l = 1; l <= 5; l++) {
|
|
const v = p.cups && p.cups[l] | 0;
|
|
if (v > cupBest(l)) prof.cups[l] = Math.min(9, v);
|
|
}
|
|
prof.out = prof.out || Math.max(0, Math.min(99, p.out | 0));
|
|
for (let l = 1; l <= 5; l++) { // the inscription, never overwritten
|
|
const v = p.at && p.at[l] | 0;
|
|
if (v > 0 && !prof.at[l]) prof.at[l] = Math.min(LEVEL_CAP, v);
|
|
}
|
|
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';
|
|
}
|