From a38c8040695413f07f7d2533e537a63db5314bdc Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 30 Aug 2026 15:58:34 +0200 Subject: [PATCH] Training pays a quarter, and the level bar wears its endpoints Co-Authored-By: Claude Fable 5 --- PROJECT.md | 12 ++++++++++++ index.html | 15 +++++++++++---- src/beats.js | 2 +- src/main.js | 6 +++++- src/profile.js | 8 ++++++-- test.js | 4 ++-- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 878305c..0c31397 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,18 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: training pays a quarter, the bar wears its endpoints (2026-08-29, the batch's two discussions, both go) + +- **THE TRAINING COURT PAYS A QUARTER.** Zero pay made it feel pointless in a meta game (his + words) — but full pay would make the no-permadeath, pick-your-body grind the optimal one. + award() now quarters (rounds) training amounts instead of muting them; drill, bench and test + mode stay muted. The footer says "NO RUN, NO DEATH — REPS PAY ¼ XP", the mode's one-time modal + agrees, and training gained real practice value anyway since bodies train at their band's true + tempo. The "NOTHING IS BANKED" half is still true: no run, no board, no ladder. +- **THE BAR WEARS ITS ENDPOINTS**: "20 ▓▓░░ 21" on the plaque and the card (MAX at the cap) — + he read the bare bar as total progress; the two numbers say "fill toward the next level" + without a word of explanation. The card keeps its words and the CAREER bar as the long form. + ## Session log: the licence, the peer line, the steward's vest (2026-08-29, Oliver's small-finds batch) Three of five items from his post-merge test built (training xp and the level/xp display are diff --git a/index.html b/index.html index 04e3451..d2b6574 100644 --- a/index.html +++ b/index.html @@ -364,7 +364,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat white-space: nowrap; overflow: hidden; } #plLv { grid-area: lv; align-self: start; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; text-decoration: none; text-shadow: 2px 2px 0 rgba(0,0,0,0.5); } - #plRow { grid-area: bar; display: block; width: auto; margin-top: 15px; } + /* the bar wears its ENDPOINTS — "20 ▓▓░░ 21" says "fill toward the next level" without a + word of explanation (Oliver read the bare bar as total progress) */ + #plRow { grid-area: bar; display: flex; align-items: center; gap: 7px; width: auto; + margin-top: 15px; } + #plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream); + text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); } /* NO nowrap here: the longest goal line is ~380px and a nowrap child sets the CARD's min-content past the viewport — that was the phantom horizontal scrollbar Oliver spotted */ /* LITERAL gold — there is no --warm in the page palette (UI.warm is a CANVAS colour), and an @@ -377,6 +382,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat #plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); border: 1px solid var(--line); text-decoration: none; display: block; } #cdBar s { height: 8px; } + #cdBar { display: flex; align-items: center; gap: 8px; } #cdCareer { display: flex; align-items: center; gap: 8px; } #cdCareer u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none; letter-spacing: 1px; } @@ -615,7 +621,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat LEVEL 1 - + 12 > @@ -740,7 +746,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat LEVEL 1 -
+
12
+
CAREER
@@ -864,6 +871,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index f766986..cc075ba 100644 --- a/src/beats.js +++ b/src/beats.js @@ -390,7 +390,7 @@ export function startMatch(cup = false, draw = null, train = 0) { G.seen.train = true; showModal({ title: 'THE TRAINING COURT', color: UI.hot, body: ['ONE BODY, ONE GAME AT A TIME', - 'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NOTHING IS BANKED HERE'] }); + 'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'] }); } else if (cup && !G.seen.cup) { G.seen.cup = true; showModal({ title: 'THE TOURNAMENT', color: UI.hot, diff --git a/src/main.js b/src/main.js index e0528c8..f97cc3d 100644 --- a/src/main.js +++ b/src/main.js @@ -526,6 +526,8 @@ function buildCard() { el.style.color = lg.color; document.getElementById('cdLevel').textContent = 'LEVEL ' + pr.level + ' OF ' + profile.LEVEL_CAP; + document.getElementById('cdFrom').textContent = pr.level; + document.getElementById('cdTo').textContent = pr.need ? pr.level + 1 : 'MAX'; // both bars sweep from empty to their mark (the 0.7s eased transition in the CSS) for (const [id, w] of [['cdFill', Math.round(pr.frac * 100)], ['cdCareerFill', Math.min(100, Math.round(profile.prof.xp / profile.xpFor(profile.LEVEL_CAP) * 100))]]) { @@ -635,7 +637,7 @@ function summary() { `${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${cupMode ? ' · LEAGUE ' + lg.id : ''}` // training banks nothing, so there is no mark to beat and the footer must not invent one - + `${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS' + + `${m.id === 'train' ? 'NO RUN, NO DEATH — REPS PAY ¼ XP' : best ? best + unit + ' TO BEAT' : 'FIRST RUN'}`; } @@ -652,6 +654,8 @@ function refreshSplash() { document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`; document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER'; document.getElementById('plLv').textContent = 'LEVEL ' + pr.level + ' · LEAGUE ' + lg.id; + document.getElementById('plFrom').textContent = pr.level; + document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX'; document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%'; document.getElementById('plGoal').textContent = profile.prof.ch ? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME' diff --git a/src/profile.js b/src/profile.js index bf511b8..6f99785 100644 --- a/src/profile.js +++ b/src/profile.js @@ -72,11 +72,15 @@ 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); +// 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; - const amt = (XP[kind] || 0) * n; + 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; diff --git a/test.js b/test.js index b7ef8a2..3d58b0b 100644 --- a/test.js +++ b/test.js @@ -2181,9 +2181,9 @@ test('PROFILE: award pays through the one point funnel, and mutes every no-stake G[flag] = false; } G.match = { train: true }; - assert.equal(prof.award('kill'), 0, 'the training court pays nothing'); + assert.equal(prof.award('point'), prof.XP.point / 4, 'the training court pays a QUARTER — reps count'); G.match = null; - assert.ok(prof.award('kill') > 0, '...and a real kill pays again'); + assert.equal(prof.award('point'), prof.XP.point, '...and the real court pays full'); }); test('PROFILE: the run delta survives the death screen and resets with the next run', () => {