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:
wuwiandClaude Fable 5 committed 2026-08-29 12:22:33 +02:00
1 parent bc10216d0b
commit 9133c0d351
12 files changed
+507 -19

No files matched your search

+71
View File
@@ -103,6 +103,8 @@ const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js');
const { hapticTest } = await import('./src/haptics.js');
const prof = await import('./src/profile.js');
const { advancePoint } = await import('./src/gameplay.js');
const { read: readStats, check: statsCheck } = statsMod;
const DT = 1 / 60;
@@ -2122,5 +2124,74 @@ test('THE READ: a habit is learned by repetition and a break wrong-foots the rac
assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game');
});
// ---- THE LEAGUES, phase 1: the profile clock -------------------------------
// The plan of record is PROJECT.md "THE LEAGUES". These pin the three promises phase 1 makes:
// the curve is fast-early/slow-late and capped, the award funnel pays every mode and mutes every
// no-stakes mode, and the run delta survives nothing it should not.
test('PROFILE: the level curve is monotonic, fast early, capped', () => {
let prev = -1;
for (let l = 1; l <= prof.LEVEL_CAP; l++) {
const x = prof.xpFor(l);
assert.ok(x > prev, 'xp thresholds strictly climb');
prev = x;
}
assert.ok(prof.xpFor(2) <= 120, 'level 2 lands inside the first minutes — the hook');
assert.ok(prof.xpFor(prof.LEVEL_CAP) > 50000, 'the cap is a long way off — levelling is not easy');
assert.equal(prof.levelAt(0), 1, 'everyone starts at level 1');
assert.equal(prof.levelAt(prof.xpFor(7)), 7, 'levelAt inverts xpFor on the threshold');
assert.equal(prof.progress(prof.xpFor(prof.LEVEL_CAP) + 999).frac, 1, 'the cap holds at a full bar');
// steps GROW: each level costs more than the one before (fast early IS slow late)
const step = l => prof.xpFor(l + 1) - prof.xpFor(l);
assert.ok(step(40) > step(10) && step(10) > step(2), 'the ladder steepens as it climbs');
});
test('PROFILE: award pays through the one point funnel, and mutes every no-stakes mode', () => {
const xp0 = prof.prof.xp;
advancePoint('P');
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'a rally point pays through advancePoint');
advancePoint('O');
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'the opponent\'s point pays you nothing');
// a won game pays the game on top of its final point (3 more points win it from 1-0)
score.pts = [3, 0];
advancePoint('P');
assert.equal(prof.prof.xp, xp0 + 2 * prof.XP.point + prof.XP.game, 'the game lands with its last point');
// every no-stakes mode is muted INSIDE award — a call site cannot forget the guard
for (const flag of ['drill', 'bench', 'testMode']) {
const before = prof.prof.xp;
G[flag] = true;
assert.equal(prof.award('kill'), 0, flag + ' pays nothing');
assert.equal(prof.prof.xp, before, flag + ' moved no xp');
G[flag] = false;
}
G.match = { train: true };
assert.equal(prof.award('kill'), 0, 'the training court pays nothing');
G.match = null;
assert.ok(prof.award('kill') > 0, '...and a real kill pays again');
});
test('PROFILE: the run delta survives the death screen and resets with the next run', () => {
prof.beginRunXp();
const from = prof.runDelta().from;
prof.award('kill'); prof.award('point');
const d = prof.runDelta();
assert.equal(d.xp, prof.XP.kill + prof.XP.point, 'the delta is the run\'s own earnings');
assert.equal(d.from, from, 'the starting level was recorded at the run start');
restartRun(); // the tap off the death screen
assert.equal(prof.runDelta().xp, 0, 'the next run opens with a clean delta');
assert.ok(prof.prof.xp > 0, '...while the banked xp survives, which is the whole point');
});
test('PROFILE: zombie names fit the board and its charset, and the profile round-trips', () => {
for (let k = 0; k < 60; k++) {
const n = prof.zombieName();
assert.ok(n.length >= 3 && n.length <= 12, 'a name fits the 12-char board limit: ' + n);
assert.equal(n, statsMod.tidyName(n), 'a rolled name is already board-legal: ' + n);
}
prof.prof.lg = 3; prof.save();
assert.ok(prof.prof.xp >= 0 && prof.prof.lg === 3, 'the profile holds what was written');
prof.prof.lg = 1; prof.save();
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report