The leagues, phase 2: five bands, pace tiers, locked courts, the members' ground, the promotion ceremony

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:43:23 +02:00
1 parent 9133c0d351
commit 16bf5b9fed
12 files changed
+406 -71

No files matched your search

+47 -17
View File
@@ -7,9 +7,9 @@
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js';
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { award } from './profile.js';
import { award, prof, save as profSave, push as profPush, setPromo } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -354,7 +354,7 @@ export function watchdog(dt) {
// the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
// `draw` is the bracket screen's settled rungs, one per cup round (main.buildBracket). It is
// optional and null means "roll each round as it comes", which is what the mode did before the
// screen existed and is still what the #cup back door gets.
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
@@ -363,12 +363,20 @@ export function watchdog(dt) {
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
export function startMatch(cup = false, draw = null, train = 0) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: train || 1, ending: false, train: !!train };
// THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md).
// Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module.
// Netplay and training stay league-1 worlds: a human opponent and a practice rep both play the
// shipped game (train still picks any met rung, but at the shipped pace).
G.league = G.net || train ? 1 : prof.lg;
const band = leagueOf(G.league).band;
// the arcade ladder opens at the league's own bottom rung and never leaves the band: past its
// top the league simply has nobody stronger, so the top body walks on again (see matchGameWon)
G.match = { opp: train || (G.net ? 1 : band[0]), ending: false, train: !!train, felled: 0 };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
// by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS.
if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null }; cupDraw(); }
// by the round's `need`), and where a won game goes (cupGameWon). See config.cupRoundsFor.
if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null, rounds: cupRoundsFor(G.league) }; cupDraw(); }
G.matchFall = null;
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated
@@ -412,9 +420,17 @@ export function startMatch(cup = false, draw = null, train = 0) {
// Guarded on the band all the same: a saved or hand-passed draw that names a rung this round does
// not contain falls back to an honest roll rather than dealing the champion in round one.
function cupDraw() {
const cup = G.match.cup, R = CUP_ROUNDS[cup.round];
const cup = G.match.cup, R = cup.rounds[cup.round];
const drawn = cup.draw && cup.draw[cup.round];
cup.rung = R.band.includes(drawn) ? drawn : R.band[Math.floor(Math.random() * R.band.length)];
// WITHOUT REPLACEMENT: a league band is 3-4 rungs and every non-final round carries the whole
// sub-band, so the run remembers who has already fallen (cup.played) and never deals a body
// twice — a repeat would read as the bracket screen lying. The final's band is one rung and
// sails through untouched.
cup.played = cup.played || [];
const pool = R.band.filter(r => !cup.played.includes(r));
cup.rung = R.band.includes(drawn) && !cup.played.includes(drawn) ? drawn
: (pool.length ? pool : R.band)[Math.floor(Math.random() * (pool.length ? pool : R.band).length)];
cup.played.push(cup.rung);
G.match.opp = cup.rung;
}
@@ -649,8 +665,8 @@ function matchOpponent() {
// In the cup the round names itself on the second line.
const cup = G.match.cup;
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
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '')] : []) });
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' : '')] : []) });
// ...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;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
@@ -658,7 +674,7 @@ function matchOpponent() {
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
// matchPoint divides the same way, so the wound stages spread across the whole round instead of
// being spent in game one — the body enters game two already missing what game one cost it.
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? CUP_ROUNDS[cup.round].need : 1);
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? cup.rounds[cup.round].need : 1);
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
}
@@ -683,7 +699,7 @@ function matchPoint(side) {
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
const was = racketStage(zb);
const div = 4 * ((d.cup && CUP_ROUNDS[d.cup.round].need) || 1); // the cup spreads the pool over the round
const div = 4 * ((d.cup && d.cup.rounds[d.cup.round].need) || 1); // the cup spreads the pool over the round
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / div));
// ONLY when the stage actually moved. A point that chips the pool without crossing a threshold
// has no limb to explain, and a second of collapse for nothing is a beat the player waits out.
@@ -774,7 +790,12 @@ function matchGameWon(zb, banked = false) {
// and handing them the roster's next name and face would break the fiction (the ladder dials the
// opp number drives are AI-only and dormant here anyway). Training parks the ladder the same
// way: the rung you picked is the rung that walks back on, as many times as you want it.
if (!G.net && !d.train) d.opp++;
if (!G.net && !d.train) {
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
// 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]);
}
// 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
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
@@ -830,7 +851,7 @@ function matchGameWon(zb, banked = false) {
// THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round
// won: the kill, the mop beat, and the next round's draw — or, past the final, the trophy.
function cupGameWon(zb, banked = false) {
const d = G.match, cup = d.cup, R = CUP_ROUNDS[cup.round];
const d = G.match, cup = d.cup, R = cup.rounds[cup.round];
d.ending = true;
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++;
@@ -853,7 +874,7 @@ function cupGameWon(zb, banked = false) {
d.afterFall = () => {
matchHealBeat();
cup.round++; cup.g = 0;
if (cup.round >= CUP_ROUNDS.length) return cupWon();
if (cup.round >= cup.rounds.length) return cupWon();
G.flash = { text: R.name + ' — YOURS.', t: 1.8 };
// the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw
sendBoy(0, 4.5, true);
@@ -877,10 +898,19 @@ function cupWon() {
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
award('cup');
// THE PROMOTION (the leagues): the cup of your CURRENT league is the gate up. Winning it moves
// the profile, unlocks the next league's court for every mode, and parks a ceremony that
// main.maybeRestart plays once the summary is tapped away. Winning league 5 crowns the profile
// instead (prof.ch) — the summary below already says CHAMPION OF THE DEAD CLUB.
if (!d.train && G.league === prof.lg) {
if (prof.lg < 5) { prof.lg++; setPromo({ to: prof.lg }); }
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true }); }
profSave(); profPush();
}
const games = score.games[0] + score.games[1];
const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH);
const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
d.felled = CUP_ROUNDS.length; d.games = games; d.best = b.games;
d.felled = d.cup.rounds.length; d.games = games; d.best = b.games;
G.done = { won: true, t: 0, cup: true };
}