The leagues, the plaque round: the meta speaks — the player plaque, the goal line, the way-up tag, the centred ceremony

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 15:30:24 +02:00
1 parent 787877bce6
commit 87ac3294bc
7 files changed
+124 -25

No files matched your search

+27
View File
@@ -491,6 +491,33 @@ 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 `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. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE LEAGUES — the plaque round: the meta speaks (2026-08-29, same day, STAGING ONLY)
Oliver's first screenshot review, four items, all built. The theme: the meta layer existed but
did not TALK to the player.
- **No second bar** (his question, settled in discussion): a career bar moves ~2% a session late
and demotivates more than it informs. Total progress reads as the LEVEL NUMBER + LEAGUE
instead; the card carries the long form — "LEVEL 12 OF 50" and a thin CAREER bar (whole-curve
xp, honest about how far the cap is).
- **THE PLAYER PLAQUE** replaces the bare level strip on the splash: ONE button-styled panel —
league wappen, name, "LEVEL X · LEAGUE Y" over the bar, a chevron that says it opens (the bare
strip's tap was undiscoverable), and **the standing GOAL LINE**: "WIN THE GRAVEYARD SHIFT CUP
TO ADVANCE" / "…BECOME CHAMPION" / "CHAMPION OF THE DEAD CLUB — DEFEND THE NAME". The badge
COLLECTION deliberately stays on the card (splash real estate: the boxed-bar lesson) — now one
DISCOVERABLE tap away.
- **"How do I advance?" is answered in three standing places** (the promotion ceremony taught it
exactly once, and only AFTER the first promotion — too late): (1) the plaque's goal line;
(2) the arcade's own "what now?" moment — when the band's top body walks BACK on (`d.again`,
set by the clamp), its walk-on modal carries "NOBODY STRONGER IS LEFT / THE CUP IS THE WAY UP"
(pinned by the band-clamp test); (3) the tournament poster wears a corner tag **THE WAY UP**
(`tag` on the MODES row, rendered by banner()) and its blurb says "YOUR LEAGUE'S ROUNDS · WIN
IT TO RISE".
- **The promotion ceremony's court tile is centred** (`margin auto` + `align-self` — the .mid
column stretches block children, so the 74%-wide img sat left), and the death screen's XP line
moved to +24/+36/+50 — the pulsing ★NEW BEST★ is B(20) drawn top-baseline at +2 and overlapped
the +18 line whenever both showed.
## Session log: THE LEAGUES, PHASE 4 — the duel learns, tin tiers, the level is you (2026-08-29, STAGING ONLY) ## Session log: THE LEAGUES, PHASE 4 — the duel learns, tin tiers, the level is you (2026-08-29, STAGING ONLY)
Phase 4, the last of the built plan. STAGING ONLY — the whole feature waits on Oliver's own Phase 4, the last of the built plan. STAGING ONLY — the whole feature waits on Oliver's own
+42 -9
View File
@@ -271,7 +271,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
animation: drop 0.5s ease-out 0.15s backwards; } animation: drop 0.5s ease-out 0.15s backwards; }
#prLeague { margin: 0; font: 700 12px Silkscreen, monospace; color: var(--cream); #prLeague { margin: 0; font: 700 12px Silkscreen, monospace; color: var(--cream);
letter-spacing: 2px; animation: drop 0.5s ease-out 0.3s backwards; } letter-spacing: 2px; animation: drop 0.5s ease-out 0.3s backwards; }
#prCourt { width: 74%; max-width: 300px; margin-top: 10px; border: 2px solid var(--line); #prCourt { width: 74%; max-width: 300px; margin: 10px auto 0; align-self: center;
border: 2px solid var(--line);
image-rendering: pixelated; animation: drop 0.5s ease-out 0.5s backwards; } image-rendering: pixelated; animation: drop 0.5s ease-out 0.5s backwards; }
#prCourtName { margin: 0; font: 400 10px Silkscreen, monospace; color: var(--acid); #prCourtName { margin: 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
letter-spacing: 1.5px; animation: drop 0.5s ease-out 0.62s backwards; } letter-spacing: 1.5px; animation: drop 0.5s ease-out 0.62s backwards; }
@@ -296,11 +297,34 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#xpRow { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; } #xpRow { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
#xpRow u { font: 700 9px Silkscreen, monospace; color: var(--cream); text-decoration: none; #xpRow u { font: 700 9px Silkscreen, monospace; color: var(--cream); text-decoration: none;
letter-spacing: 1px; white-space: nowrap; } letter-spacing: 1px; white-space: nowrap; }
#xpRow { cursor: pointer; } /* the strip IS the door to the character card */ /* THE PLAYER PLAQUE (replaces the bare level strip): ONE button-styled panel — wappen, name,
#xpRow s, #cdBar s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); border: 1px solid var(--line); level+league over the bar, and the standing GOAL LINE ("win the X cup to advance"), with a
text-decoration: none; display: block; } chevron that says it opens. The splash stays two buttons + one plaque; the badge COLLECTION
stays on the card, one (now discoverable) tap away. */
#plaque { display: flex; align-items: center; gap: 10px; width: 100%; margin: 0 0 10px;
padding: 8px 10px; background: var(--solid); border: 2px solid var(--line);
cursor: pointer; text-align: left; -webkit-tap-highlight-color: transparent; }
#plBadge { height: 34px; flex: none; image-rendering: pixelated; }
#plBody { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
#plName { font: 700 10px Silkscreen, monospace; color: var(--cream); letter-spacing: 1.5px;
white-space: nowrap; overflow: hidden; }
#plRow { display: flex; align-items: center; gap: 8px; }
#plRow u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none;
letter-spacing: 1px; white-space: nowrap; }
#plGoal { font: 400 8px Silkscreen, monospace; font-style: normal; color: var(--acid);
letter-spacing: 1px; }
#plaque em { flex: none; font: 400 22px Bangers, cursive; font-style: normal; color: var(--acid); }
#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 s { height: 8px; }
#xpRow s i, #cdBar s i { display: block; height: 100%; width: 0%; #cdCareer { display: flex; align-items: center; gap: 8px; }
#cdCareer u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none;
letter-spacing: 1px; }
/* the mode picker's WAY-UP tag: the standing answer to "how do I advance", worn by the cup */
.way { position: absolute; top: 8px; left: 8px; padding: 3px 6px; font: 700 8px Silkscreen, monospace;
font-style: normal; color: var(--ink); background: var(--acid); letter-spacing: 1px;
text-decoration: none; }
#plRow s i, #cdBar s i, #cdCareer s i { display: block; height: 100%; width: 0%;
background: linear-gradient(90deg, #3f9560, var(--acid) 55%, #e8b23a); background: linear-gradient(90deg, #3f9560, var(--acid) 55%, #e8b23a);
transition: width 0.4s ease; } transition: width 0.4s ease; }
/* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is /* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is
@@ -554,9 +578,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
you can press before the sheets have landed is how you reach the stats screen with half you can press before the sheets have landed is how you reach the stats screen with half
the zombies missing. Swapped by #splash.busy, see main.js. --> the zombies missing. Swapped by #splash.busy, see main.js. -->
<div id="boot"><s><i></i></s><u>LOADING</u></div> <div id="boot"><s><i></i></s><u>LOADING</u></div>
<!-- THE LEVEL STRIP (the leagues): the clock the whole meta-game runs on, visible on every <!-- THE PLAYER PLAQUE (the leagues): wappen, name, level+league, the bar, and the
visit to the title. Filled by refreshSplash. --> standing goal line. One tap opens the character card. Filled by refreshSplash. -->
<div id="xpRow"><u id="xpLv">LEVEL 1</u><s><i id="xpFill"></i></s></div> <button id="plaque">
<img id="plBadge" alt="">
<span id="plBody">
<b id="plName"></b>
<span id="plRow"><u id="plLv">LEVEL 1</u><s><i id="plFill"></i></s></span>
<i id="plGoal"></i>
</span>
<em>&rsaquo;</em>
</button>
<button class="btn big" id="mStart">PLAY MATCH</button> <button class="btn big" id="mStart">PLAY MATCH</button>
<div class="two"> <div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button> <button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
@@ -660,6 +692,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<u id="cdLeague"></u> <u id="cdLeague"></u>
<b id="cdLevel">LEVEL 1</b> <b id="cdLevel">LEVEL 1</b>
<div id="cdBar"><s><i id="cdFill"></i></s><u id="cdXp"></u></div> <div id="cdBar"><s><i id="cdFill"></i></s><u id="cdXp"></u></div>
<div id="cdCareer"><u>CAREER</u><s><i id="cdCareerFill"></i></s></div>
</div> </div>
</div> </div>
<h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3> <h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3>
@@ -795,6 +828,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=315"></script> <script type="module" src="src/main.js?v=316"></script>
</body> </body>
</html> </html>
+10 -3
View File
@@ -666,7 +666,10 @@ function matchOpponent() {
const cup = G.match.cup; const cup = G.match.cup;
showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64, showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
body: [zb.foe.line].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 body: [zb.foe.line].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1
? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] : []) }); ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')]
// the top of the band walking BACK on is the arcade's natural "what now?" moment — the
// intro answers it in place (THE LEAGUES: the cup is the only promotion)
: G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : []) });
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
@@ -793,8 +796,12 @@ function matchGameWon(zb, banked = false) {
if (!G.net && !d.train) { if (!G.net && !d.train) {
d.felled = (d.felled || 0) + 1; // bodies actually felled (the board's number) d.felled = (d.felled || 0) + 1; // bodies actually felled (the board's number)
// ...and the ladder climbs only inside the league's band: past its top rung the league has // ...and the ladder climbs only inside the league's band: past its top rung the league has
// nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up) // nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up).
d.opp = Math.min(d.opp + 1, leagueOf(G.league).band[1]); // `d.again` marks exactly that moment — the felled body WAS the top — so its walk-on modal
// can answer the "what now?" the repeat would otherwise pose (see matchOpponent).
const top = leagueOf(G.league).band[1];
d.again = d.opp >= top;
d.opp = Math.min(d.opp + 1, top);
} }
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks // ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the // the game but never touches the server — that flip lives in tennis's own pointTo, which the
+4 -1
View File
@@ -826,7 +826,10 @@ export const COURTS = [
export const MODES = [ export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' }, { id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' }, { id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' }, // `tag` is the poster's corner chip (main.banner): the cup is THE WAY UP — winning your
// league's tournament is the one route to promotion, and the picker should say so standing.
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'YOUR LEAGUE\'S ROUNDS · WIN IT TO RISE', art: 'mode_cup',
tag: 'THE WAY UP' },
// TRAINING (2026-08-28): pick any rung, play it one game at a time, nothing is banked. The same // TRAINING (2026-08-28): pick any rung, play it one game at a time, nothing is banked. The same
// match machinery with the ladder parked — see startMatch's train parameter. // match machinery with the ladder parked — see startMatch's train parameter.
{ id: 'train', name: 'TRAINING', sub: 'PICK A BODY · ONE GAME · GO AGAIN', art: 'mode_train' }, { id: 'train', name: 'TRAINING', sub: 'PICK A BODY · ONE GAME · GO AGAIN', art: 'mode_train' },
+24 -9
View File
@@ -107,7 +107,9 @@ const banner = (card, cls, last) => {
b.style.backgroundImage = b.style.backgroundImage =
`linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${card.art}.png?v=${SPRV})`; `linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>` b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>`
+ (last ? '<u class="last">LAST</u>' : ''); + (last ? '<u class="last">LAST</u>' : '')
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
return b; return b;
}; };
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} }; const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
@@ -512,10 +514,15 @@ function buildCard() {
el.textContent = `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`; el.textContent = `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
el.style.color = lg.color; el.style.color = lg.color;
document.getElementById('cdLevel').textContent = document.getElementById('cdLevel').textContent =
'LEVEL ' + pr.level + (pr.need ? '' : ' · THE CAP'); 'LEVEL ' + pr.level + ' OF ' + profile.LEVEL_CAP;
document.getElementById('cdFill').style.width = Math.round(pr.frac * 100) + '%'; document.getElementById('cdFill').style.width = Math.round(pr.frac * 100) + '%';
document.getElementById('cdXp').textContent = profile.prof.xp.toLocaleString('en-GB') + ' XP' document.getElementById('cdXp').textContent = profile.prof.xp.toLocaleString('en-GB') + ' XP'
+ (pr.need ? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1) : ''); + (pr.need ? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1)
: ' · THE CAP');
// the CAREER bar is the long form the splash deliberately does not carry: whole-curve progress,
// honest about how far level 50 is
document.getElementById('cdCareerFill').style.width =
Math.min(100, Math.round(profile.prof.xp / profile.xpFor(profile.LEVEL_CAP) * 100)) + '%';
const row = document.getElementById('cdBadges'); const row = document.getElementById('cdBadges');
row.innerHTML = ''; row.innerHTML = '';
for (const l of LEAGUES) { for (const l of LEAGUES) {
@@ -545,7 +552,7 @@ function buildCard() {
+ fig('RUNS', (g.matches + z.matches + c.matches).toLocaleString('en-GB')); + fig('RUNS', (g.matches + z.matches + c.matches).toLocaleString('en-GB'));
refreshLook(); refreshLook();
} }
document.getElementById('xpRow').addEventListener('click', () => { buildCard(); go('card'); }); document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });
document.getElementById('cdBack').addEventListener('click', () => go('splash')); document.getElementById('cdBack').addEventListener('click', () => go('splash'));
let playerReturn = null; // EDIT PLAYER borrows the identity screen and comes back to the card let playerReturn = null; // EDIT PLAYER borrows the identity screen and comes back to the card
document.getElementById('cdEdit').addEventListener('click', () => { document.getElementById('cdEdit').addEventListener('click', () => {
@@ -613,11 +620,19 @@ function summary() {
// has just ended and the count has moved. // has just ended and the count has moved.
function refreshSplash() { function refreshSplash() {
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS'; document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
// the level strip — the meta clock, re-read whenever the splash comes back because a run has // THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
// just moved it (same rule as the kill count above) // moved it (same rule as the kill count above). The goal line is the standing answer to "what
const pr = profile.progress(); // do I do to advance": win the CURRENT league's cup, become champion, or rest on the crown.
document.getElementById('xpLv').textContent = 'LEVEL ' + pr.level; const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
document.getElementById('xpFill').style.width = Math.round(pr.frac * 100) + '%'; 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('plFill').style.width = Math.round(pr.frac * 100) + '%';
document.getElementById('plGoal').textContent = profile.prof.ch
? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME'
: profile.prof.lg >= LEAGUES.length
? 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP — BECOME CHAMPION'
: 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP TO ADVANCE';
summary(); summary();
} }
+4 -3
View File
@@ -2919,10 +2919,11 @@ export function render() {
if (xd.xp > 0) { if (xd.xp > 0) {
const pr = progress(); const pr = progress();
ctx.font = F(8); ctx.font = F(8);
// +24/+34: clear of the NEW BEST line above, which is B(20) drawn top-baseline at +2
shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to
: ' · LEVEL ' + pr.level), : ' · LEVEL ' + pr.level),
W / 2, H / 2 + 18, xd.to > xd.from ? UI.pick : UI.cream); W / 2, H / 2 + 24, xd.to > xd.from ? UI.pick : UI.cream);
const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 30); const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 36);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4); ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4);
// the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip // the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip
const gr = ctx.createLinearGradient(bx, 0, bx + bw, 0); const gr = ctx.createLinearGradient(bx, 0, bx + bw, 0);
@@ -2932,7 +2933,7 @@ export function render() {
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same) // gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) { if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) {
ctx.font = F(8); ctx.font = F(8);
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 44, UI.warm); shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 50, UI.warm);
} }
ctx.restore(); ctx.restore();
} }
+13
View File
@@ -2230,6 +2230,19 @@ test('THE LEAGUES: the arcade opens at its band, climbs inside it, and counts fe
} }
assert.equal(d.opp, 8, 'the ladder clamps at the top of the band — the cup is the only way up'); assert.equal(d.opp, 8, 'the ladder clamps at the top of the band — the cup is the only way up');
assert.equal(d.felled, 6, 'every felled body counts, clamp or no clamp'); assert.equal(d.felled, 6, 'every felled body counts, clamp or no clamp');
// ...and the repeat walk-on ANSWERS the "what now?" it poses: fell the top body once more and
// read its intro modal instead of wiping it
G.interlude.introT = 0;
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
let saw = null;
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !saw; i++) {
update(DT);
if (G.modal && G.modal.body) saw = G.modal.body.join(' | ');
}
assert.ok(saw && saw.includes('THE CUP IS THE WAY UP'),
'the repeat walk-on carries the promotion lesson: ' + saw);
G.modal = null; G.modalQ.length = 0;
endMatch(); endMatch();
}); });