diff --git a/server.py b/server.py index defe963..480a589 100644 --- a/server.py +++ b/server.py @@ -126,6 +126,13 @@ def clean_profile(payload): 'at': at, 'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX], 'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')} + # THE STORY (2026-10-15): the act and the checkpoint, nothing else. The rack a checkpoint + # hands back stays on the device — a wiped phone restarts the act's chapter, not its middle. + st = p.get('story') + if isinstance(st, dict): + cp = str(st.get('cp', 'cup')) + out['story'] = {'act': max(1, min(int(st.get('act', 1)), 5)), + 'cp': cp if cp in ('pro', 'cup', 'chap', 'mid', 'done') else 'cup'} except (TypeError, ValueError): return None return who, out diff --git a/src/beats.js b/src/beats.js index d06309a..afb7269 100644 --- a/src/beats.js +++ b/src/beats.js @@ -7,13 +7,13 @@ // (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, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js'; +import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel, WEAPONS, STORY, ROMAN, storyAct, storyRounds, storyStock, storyBossArt, storyBossChapter } from './config.js'; import { endRun, MODE_MATCH } from './stats.js'; import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } 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'; -import { cheer, aside, endBurst, deliverTin, showModal, logSwing, logPoint, advancePoint, scoreKey } from './gameplay.js'; +import { cheer, aside, endBurst, deliverTin, showModal, logSwing, logPoint, advancePoint, scoreKey, grantWeapon } from './gameplay.js'; import { startDrillStream } from './drill.js'; import { recordEvent } from './recorder.js'; import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon @@ -28,6 +28,9 @@ export function proceedAfterClear() { // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.) // The chapter boss outranks everything here, including a set: a boss walking on into a standing // wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat. + // THE STORY's bosses ride the same slot, ahead of everything: the mid-boss at game 5, the act's + // boss at game 10 (splatPoint arms G.storyBoss; the arcade's G.bossDue is never set in a chapter) + if (G.storyBoss) { const k = G.storyBoss; G.storyBoss = null; G.afterDuel = mode || 'game'; startStoryBoss(k); return; } if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; } // The set/horde beat outranks the duel; the duel just waits for the next clear. if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; } @@ -68,6 +71,10 @@ export function startDuel() { // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while // another control mode owns the court. Each of these has ended a run when they overlapped. if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return; + // THE STORY HAS NO DUEL (Oliver, 2026-10-15): the rack is the cup's loot, the barrels and the + // restock after the mid-boss; healing is the mid-boss and the act start. The racket zombie still + // walks on in a chapter — as an ordinary body that returns balls. + if (G.story) return; if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel G.weapon = 0; // tennis rules for the duel: plain balls, no tins @@ -96,16 +103,16 @@ export function startDuel() { // decision (which is the point — tins arrive every 3 games, so there was never a reason to save one). // It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary // instant death. G.bossFight holds the chapter number while it runs. -export function startBossFight(chapter) { +export function startBossFight(chapter, design = null) { if (G.bossFight || G.drill || G.match || G.bench || G.net) return; G.bossFight = chapter; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage G.horde = null; G.mgT = 0; endBurst(); G.clearing = false; ball.active = false; setupPoint(); - spawnChapterBoss(chapter); + spawnChapterBoss(chapter, design); zombieDuck(true); // the music drops out for it aside('ump_boss_arrives'); voice('sudden_death', 2.6, 0.85); // the stake is an event; the quip is benched (see aside()) - const art = bossArt(chapter); + const art = design || bossArt(chapter); // THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in // the same slot and size as a routine set, and it read as the boss simply being there. This is on // the sanctioned modal list (a boss stage is guaranteed calm — the two lines above just cleared @@ -121,9 +128,21 @@ export function startBossFight(chapter) { export function endBossFight() { if (!G.bossFight) return; const c = G.bossFight; G.bossFight = 0; + const fight = G.story && G.story.fight; + if (G.story) G.story.fight = null; zombieDuck(false); shots.length = 0; cheer(3.4, 0.9); voice('unbelievable', 0.3); + // THE ACT'S BOSS ENDS THE ACT: no game beat is handed back, nothing more walks on (bossFight stays + // up, which is what holds every spawn), and once the body has come apart the act card takes over + if (fight === 'end') { + G.bossFight = c; G.afterDuel = null; + G.story.winT = TUNE.bossDeathT + TUNE.bossWinT; + G.card = { title: 'EPIC!', sub1: storyBossArt(G.story.act, 'end').name + ' IS DOWN', color: UI.pick, + t: TUNE.bossWinT, wait: TUNE.bossDeathT }; + ball.active = false; G.phase = 'over'; G.overT = G.story.winT + 1; + return; + } // hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It // used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game // that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it @@ -135,6 +154,7 @@ export function endBossFight() { // card that repeats what the HUD already says is the clutter this pass is meant to remove.) G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT, wait: TUNE.bossDeathT }; // ...after the body has finished coming apart + if (fight === 'mid') storyCheckpoint(); ball.active = false; // a duel the handback just started owns the phase; otherwise hold the boss beat, and never // SHORTEN a longer one the handback set (a changeover's 5 s outlasts this) @@ -362,9 +382,9 @@ export function watchdog(dt) { // opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a // felled body walks straight back on, and bleeding out costs a modal instead of the run. All of // it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint. -// `draw` is DEAD weight kept only for call-site arity — the cup runs easy to hard now -// (cupRoundsFor is deterministic, 2026-08-29) and nothing reads it. -export function startMatch(cup = false, draw = null, train = 0) { +// `rounds` (the old dead `draw` slot) lets THE STORY hand in its own cup (config.storyRounds: the +// final is the act boss's body); null is the league's ordinary cup. +export function startMatch(cup = false, rounds = null, train = 0) { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // 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. @@ -379,7 +399,8 @@ export function startMatch(cup = false, draw = null, train = 0) { // 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.cupRoundsFor. - if (cup) { G.match.cup = { round: 0, g: 0, rounds: cupRoundsFor(G.league) }; cupDraw(); } + if (G.story) G.league = storyAct(G.story.act).lg; // the act IS the league's world + if (cup) { G.match.cup = { round: 0, g: 0, rounds: 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 @@ -396,6 +417,13 @@ export function startMatch(cup = false, draw = null, train = 0) { body: ['ONE BODY, ONE GAME AT A TIME', 'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'], rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] }); + } else if (cup && G.story) { + // THE STORY's act opens on its own card (phase 3 gives it the animated title card) + const A = storyAct(G.story.act), R = G.match.cup.rounds; + showModal({ title: 'ACT ' + ROMAN[G.story.act], color: UI.warm, strap: leagueOf(A.lg).name, + body: ['WIN THE CUP — ' + R.length + ' ROUNDS', R[R.length - 1].name + ': ' + matchFoe(A.rounds[R.length - 1]).name], + rules: ['WIN THE CUP — ' + R.length + ' ROUNDS', + { lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }); } else if (cup && !G.seen.cup) { G.seen.cup = true; showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub, @@ -917,6 +945,7 @@ function cupGameWon(zb, banked = false) { // summary over a still court, then the gated restart). Banked through the same endRun as every // match run, not dead: the stats screen's deaths column keeps meaning what it means. function cupWon() { + if (G.story) return storyCupWon(); const d = G.match; d.ending = false; d.mopT = 0; G.interlude = false; @@ -1091,3 +1120,126 @@ export function startHorde() { G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave return true; } + +// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY") --------------------------------------------- +// Five acts, one per league and court: the league cup, the transformation, one ten-game gore chapter +// with a mid-boss at game 5 (the checkpoint) and the act's boss at game 10, then the act card. Same +// court-ownership contract as every mode in this file. The PLACE in the story lives in the profile +// (prof.story: act, checkpoint, the rack it hands back) because restartRun wipes G; G.story is only +// what this run is doing with it. Death is not permadeath here: it costs the attempt and restarts +// from the checkpoint (storyDeath, then main.maybeRestart re-enters through startStory). +export function startStory() { + if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net || G.story) return false; + const s = prof.story; + if (s.cp === 'done') { s.act = 1; s.cp = 'cup'; s.rack = []; s.tries = 0; } // the story told again + if (s.cp === 'pro') s.cp = 'cup'; // (the prologue is phase 2 — until then, act I) + prof.lg = Math.max(prof.lg, storyAct(s.act).lg); // the act's league is open to the arcade too + profSave(); + if (s.cp === 'cup') { + G.story = { act: s.act, part: 'cup', base: STORY.base(s.act), fight: null, winT: 0 }; + return startMatch(true, storyRounds(s.act)); + } + return startStoryChapter(s.cp === 'mid'); +} + +// THE CHAPTER: the gore, from game 1 (or from game 6, after the mid-boss), on the act's court. The +// rack is the act's stock (or what the mid-boss checkpoint saved), the blood is full, and the +// arcade's director plays its games base+1..base+10 so every curve keeps its tuned slope. +export function startStoryChapter(fromMid = false) { + if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; + const s = prof.story, act = s.act; + G.story = { act, part: 'chap', base: STORY.base(act), midDone: fromMid, fight: null, winT: 0 }; + G.league = storyAct(act).lg; + G.runGames = G.story.base + (fromMid ? STORY.midAt : 0); + score.games = [fromMid ? STORY.midAt : 0, 0]; score.pts = [0, 0]; G.sets = [0, 0]; G.killCount = 0; + score.server = 'P'; + G.hp = G.hpMax = TUNE.playerHp; + const stock = fromMid && s.rack.length ? s.rack : storyStock(act); + G.rack = []; G.temp = null; + for (const e of stock) if (WEAPONS[e.id]) grantWeapon(e.id, e.n); + G.weapon = 0; + G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; G.storyBoss = null; + G.changeover = false; G.clearing = false; G.afterClear = null; G.afterDuel = null; + G.mgT = 0; endBurst(); G.boy = null; + zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; + s.cp = fromMid ? 'mid' : 'chap'; + if (!fromMid) s.rack = stock.map(e => ({ ...e })); + profSave(); profPush(); + zombieDuck(false); + ball.active = false; setupPoint(); + G.card = { title: 'CHAPTER ' + ROMAN[act], color: UI.hot, t: 3.2, + sub1: fromMid ? 'FROM GAME ' + (STORY.midAt + 1) + ' · ' + storyBossArt(act, 'end').name + ' WAITS AT ' + STORY.length + : STORY.length + ' GAMES · ' + storyBossArt(act, 'mid').name + ' AT ' + STORY.midAt }; + return true; +} + +// THE CUP IS WON: the cup board still notes it, the act moves to its chapter, and the rack is filled +// with what the act has earned. (Phase 2 puts the transformation between the two; phase 3 the +// animated loot screen. This is the plain version of both.) +function storyCupWon() { + const d = G.match; + award('cup'); + if (!d.train) { recordCupWin(G.league, levelAt(prof.xp)); recordCup(G.league, d.cup.rounds.length); } + cheer(4, 1); voice('unbelievable', 0.4); + const stock = storyStock(G.story.act); + endMatch(); + G.story = null; // startStoryChapter stamps the chapter's own + prof.story.tries = 0; + startStoryChapter(false); + showModal({ title: 'THE CUP IS YOURS', color: UI.pick, + body: ['…AND THESE FOR THE HORDES TO COME', stock.map(e => WEAPONS[e.id].label).join(' · ')] }); +} + +function startStoryBoss(kind) { + const act = G.story.act; + G.story.fight = kind; + startBossFight(storyBossChapter(act, kind), storyBossArt(act, kind)); +} + +// THE MID-BOSS IS DOWN: the checkpoint. The rack is topped back up to the act's stock (never down), +// the blood is full — the two things the duel used to pay — and both are saved, so a death in games +// 6 to 10 restarts here. +function storyCheckpoint() { + const s = prof.story; + G.story.midDone = true; + for (const e of storyStock(G.story.act)) { + const have = G.rack.find(r => r.id === e.id); + const n = e.n - (have ? have.n : 0); + if (n > 0) grantWeapon(e.id, n); + } + G.weapon = 0; + G.hp = G.hpMax; + s.cp = 'mid'; s.tries = 0; + s.rack = G.rack.map(e => ({ id: e.id, n: e.n })); + profSave(); profPush(); + G.card = { title: 'CHECKPOINT', sub1: 'RESTOCKED · FULL HEALTH', color: UI.pick, t: TUNE.bossWinT, + wait: TUNE.bossDeathT }; +} + +// THE ACT'S BOSS HAS COME APART: the act is cleared. The next act's cup is the new checkpoint and +// its league opens for every mode; act V tells the story to its end (the champion's flag). +function storyActCleared() { + const s = prof.story, act = G.story.act; + award('round'); + if (act < 5) { s.act = act + 1; s.cp = 'cup'; prof.lg = Math.max(prof.lg, storyAct(act + 1).lg); } + else { s.cp = 'done'; prof.ch = 1; } + s.rack = []; s.tries = 0; + profSave(); profPush(); + G.story.cleared = act; + ball.active = false; G.flash = null; G.modal = null; G.card = null; + G.done = { won: true, t: 0, story: act }; +} + +// Ticked from update beside tickMatchFall: the act boss's death beat before the act card. +export function tickStory(dt) { + if (G.story && G.story.winT > 0 && (G.story.winT -= dt) <= 0) { G.story.winT = 0; storyActCleared(); } +} + +// zombies.killPlayer hands a story death here instead of banking a run: the attempt is counted +// (the help offer reads it) and the checkpoint stands. +export function storyDeath() { + G.card = null; // the world freezes in 'eaten': a card would hang there + prof.story.tries++; + profSave(); profPush(); + G.story.dead = true; +} diff --git a/src/config.js b/src/config.js index acacf6a..bfe7a44 100644 --- a/src/config.js +++ b/src/config.js @@ -1018,6 +1018,54 @@ export const cupFixture = (lg, champion = 0) => { sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' }; }; +// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY"): five acts, one per league and court. An act is +// the league cup (zombie tennis), the transformation, and one ten-game gore chapter with a MID-BOSS +// at game 5 (the checkpoint) and the act's BOSS at game 10. Pure data; beats.js runs it. +// rounds the cup's rungs, in order. The FINAL is always the boss's own body (it grows into the +// boss), so acts II and IV play their band out of ladder order — cupRoundsFor is untouched. +// mid BOSS_ART index of the game-5 boss (the five designs one chapter per act leaves over) +// boss BOSS_ART index of the game-10 boss (the cup finalist, grown) +// pool weapons this act ADDS to the rack; the stock is every pool up to and including the act +// double the act's stock and restock pay two tins of each (league 5's double tins) +// The chapter plays the arcade's games base+1..base+10 (base = (act-1)*20), so every director curve +// that reads G.runGames — spawn ramps, barrel gaps, machines, racket hp — keeps its tuned slope. +export const STORY = { + acts: [ + { lg: 1, rounds: [1, 2, 3, 4], mid: 8, boss: 0, pool: ['bomb', 'triple', 'fire'] }, + { lg: 2, rounds: [5, 6, 8, 7], mid: 6, boss: 5, pool: ['gun', 'beacon'] }, + { lg: 3, rounds: [9, 10, 11, 12], mid: 1, boss: 2, pool: ['laser'] }, + { lg: 4, rounds: [13, 15, 14], mid: 4, boss: 3, pool: ['flamer', 'rocket'] }, + { lg: 5, rounds: [16, 17, 18], mid: 7, boss: 9, pool: [], double: true }, + ], + midAt: 5, // the mid-boss walks on after this many chapter games (and is the checkpoint) + length: 10, // games in a chapter; the act's boss walks on after the last + base: act => (act - 1) * 20, +}; +export const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V']; +export const storyAct = act => STORY.acts[Math.max(1, Math.min(5, act)) - 1]; +// the story cup: the league's own round names and needs, on the act's rung order +export const storyRounds = act => { + const A = storyAct(act); + return cupRoundsFor(A.lg).map((r, i) => ({ ...r, band: [A.rounds[i]] })); +}; +// every weapon the act has earned so far, and how many tins of each the rack starts the chapter with +export const storyPool = act => STORY.acts.slice(0, Math.max(1, Math.min(5, act))).flatMap(a => a.pool); +export const storyStock = act => storyPool(act).map(id => ({ id, n: WEAPONS[id].tin * (storyAct(act).double ? 2 : 1) })); +export const storyBossArt = (act, kind) => BOSS_ART[storyAct(act)[kind === 'mid' ? 'mid' : 'boss']]; +// strength: the act's two bosses stand where they would in the arcade's ten chapters (mid 2a-1, boss 2a) +export const storyBossChapter = (act, kind) => 2 * act - (kind === 'mid' ? 1 : 0); +// the front door's words for where the story stands (refreshSplash, the summaries) +export const storyFixture = s => { + const act = Math.max(1, Math.min(5, s.act)), lg = leagueOf(storyAct(act).lg); + const court = (COURTS.find(c => c.id === lg.court) || COURTS[0]).name; + if (s.cp === 'done') return { title: 'THE STORY IS TOLD', line: 'THE DEAD CLUB IS QUIET · PLAY IT AGAIN', court }; + const where = s.cp === 'pro' ? 'PROLOGUE · A MATCH LIKE ANY OTHER' + : s.cp === 'chap' ? 'CHAPTER ' + ROMAN[act] + ' · FROM GAME 1' + : s.cp === 'mid' ? 'CHAPTER ' + ROMAN[act] + ' · FROM GAME ' + (STORY.midAt + 1) + : 'THE CUP · ' + storyRounds(act)[0].name + ': ' + matchFoe(storyAct(act).rounds[0]).name; + return { title: s.cp === 'pro' ? 'THE STORY' : 'ACT ' + ROMAN[act] + ' · ' + court, line: where, court }; +}; + // How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL. export const cupRoundName = (lg, reached) => { const rounds = cupRoundsFor(lg); diff --git a/src/gameplay.js b/src/gameplay.js index b84e35d..a24cf3e 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1,6 +1,6 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; +import { TUNE, UI, bossArt, STORY, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { haptic } from './haptics.js'; @@ -11,7 +11,7 @@ import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; import { award, beginRunXp, prof, bonus, levelAt } from './profile.js'; import { recordEvent, recordFrame } from './recorder.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; -import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing +import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver, tickStory } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal // ------------------------------------------------------------- ballistics ---- @@ -892,13 +892,20 @@ export function splatPoint(side, quiet = false) { G.runGames++; // the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance // at a weapon, but you still have to win the point to be handed one. Fires at the next clear. - if (G.runGames === TUNE.duelFirst || + // THE STORY's chapter has no duel, and its two bosses are its own: the mid-boss after game 5 + // (the checkpoint) and the act's boss after game 10. The arcade schedule below never runs there. + const chap = G.story && G.story.part === 'chap'; + if (chap) { + const rel = G.runGames - G.story.base; + if (rel >= STORY.length) G.storyBoss = 'end'; + else if (rel === STORY.midAt && !G.story.midDone) G.storyBoss = 'mid'; + } else if (G.runGames === TUNE.duelFirst || (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0)) G.duelDue = true; // ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from // proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks // everything else there — a boss must not walk on into a standing wave. - if (G.runGames % TUNE.bossEvery === 0) + if (!chap && G.runGames % TUNE.bossEvery === 0) G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a // long stretch with no clear skips a chapter rather than queueing one, which is intended @@ -2532,7 +2539,8 @@ export function update(dt) { // The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is // pure upside for weapon supply. if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight - && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) + && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled + && !G.story) // the story has no duel at all startDuel(); if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; // the duel is played under tennis lights: lerp the palette home while it runs, back after @@ -2753,6 +2761,7 @@ export function update(dt) { // ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over' // block so it can hand the point back before that block puts the player in a serve stance. tickMatchFall(dt); + tickStory(dt); if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { diff --git a/src/main.js b/src/main.js index 608076a..04a2445 100644 --- a/src/main.js +++ b/src/main.js @@ -3,10 +3,10 @@ // Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) -import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER } from './config.js'; +import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER , storyFixture, storyAct } from './config.js'; import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; -import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; +import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs , startStory } from './beats.js'; import { netConnect, netHooks, netAgain } from './net.js'; import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; @@ -371,6 +371,7 @@ function leavePlayer(saved) { if (back === 'card') { buildCard(); go('card'); } else if (back === 'setup') go('setup'); else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); } + else if (back === 'story' && saved) start('story'); else go('splash'); } document.getElementById('pBack').addEventListener('click', () => leavePlayer(false)); @@ -633,7 +634,8 @@ function refreshSplash() { document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%'; // THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use // (config.cupFixture) — minus the round count, which lives in the tournament's own modal now. - const fx = cupFixture(profile.prof.lg, profile.prof.ch); + // THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands + const fx = storyFixture(profile.prof.story); document.getElementById('msTitle').textContent = fx.title; document.getElementById('msLine').textContent = fx.line; document.getElementById('mCourt').textContent = 'COURT · ' + @@ -651,7 +653,8 @@ function start(modeId = pickedMode) { selectKit(pickedKit); selectSkin(pickedSkin); // THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other // mode — the climb included, it is an exhibition — plays your pick (among the unlocked). - selectCourt(modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked); + selectCourt(modeId === 'story' ? leagueOf(storyAct(profile.prof.story.act).lg).court + : modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked); menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture // ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes @@ -669,6 +672,8 @@ function start(modeId = pickedMode) { if (modeId === 'cup') { intoSplatter(); startMatch(true); } // cupDraw shuffles per round // training: the picked rung rides the match machinery with the ladder parked (beats.startMatch) if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); } + // THE STORY: the act's court, the splatter world under it, and the checkpoint the profile holds + if (modeId === 'story') { intoSplatter(); startStory(); } } // Career totals and detailed records live one level beneath the profile. @@ -725,8 +730,8 @@ document.addEventListener('visibilitychange', () => { addEventListener('pagehide', () => recorder.saveRecording()); const goMode = () => { buildTops(); go('mode'); }; document.getElementById('mStart').addEventListener('click', () => { - if (!profile.prof.setup) { playerReturn = 'cup'; buildPlayer(); go('player'); return; } - pickMode('cup'); start('cup'); + if (!profile.prof.setup) { playerReturn = 'story'; buildPlayer(); go('player'); return; } + start('story'); }); document.getElementById('mOther').addEventListener('click', goMode); document.getElementById('mVs').addEventListener('click', () => vsOpen()); @@ -1023,6 +1028,7 @@ const maybeRestart = e => { // resets both phones itself and the menu never comes back. It refuses (and falls through to the // clubhouse) if the friend has already hung up. if (door === 'rematch' && netAgain()) return; + const story = !!G.story; // restartRun wipes G — the story's place is in the profile restartRun(); profile.push(); // the run's xp is banked — mirror it while the moment is quiet refreshSplash(); // the run just ended: the splash count has moved @@ -1035,6 +1041,8 @@ const maybeRestart = e => { // prompt it may also have earned (prGo hands the run on), then the title. const promo = profile.takePromo(); if (promo) { postCup(promo); buildPromo(promo, run); go('promo'); return; } + // THE STORY: TRY AGAIN / CONTINUE re-enter at the checkpoint; anywhere else is the club + if (story) { if (door === 'story') start('story'); else go('splash'); return; } // ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here // silently barred two of the three modes from ever reaching the prompt // ...on a board that still EXISTS: classic tennis left the world board with the stats streamline @@ -1159,5 +1167,6 @@ requestAnimationFrame(frame); window.T = { TUNE, ball, P, O, score, G, zombies, keyPressed, SPR, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall, start, intoSplatter, startHorde, sendBoy, // the two run doors + the wave: the promo rig (tools/) stages every clip through them + profile, startStory, // THE STORY: read and set its place from the console (prof.story) get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/src/profile.js b/src/profile.js index 40b7645..c59cea2 100644 --- a/src/profile.js +++ b/src/profile.js @@ -34,17 +34,37 @@ const cleanCups = (c, cap = 9) => { 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 }); +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)) }; +}; + function load() { try { const p = JSON.parse(localStorage.getItem(KEY) || 'null'); if (p && typeof p === 'object') { - return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)), - ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0, - cups: cleanCups(p.cups), at: cleanCups(p.at, 50), - out: Math.max(0, Math.min(99, p.out | 0)) }; + // 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: 1, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, at: {}, out: 0 }; + return { v: 2, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, at: {}, out: 0, story: freshStory() }; } export const prof = load(); @@ -185,8 +205,21 @@ export async function pull() { 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); diff --git a/src/render.js b/src/render.js index bf478a1..ef6a4d8 100644 --- a/src/render.js +++ b/src/render.js @@ -1,7 +1,7 @@ // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; -import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName } from './config.js'; +import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName , STORY, ROMAN, storyFixture, storyBossArt } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; import { RAISERS, FRAMES } from './crowd.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; @@ -3026,6 +3026,9 @@ export function render() { // the arcade names the RUNG and counts d.felled (THE LEAGUES: opp is a band position now) : G.match ? `${matchFoe(G.match.opp).short} · ${G.match.felled || 0} FELLED` : G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}` + // THE STORY's chapter counts its own ten (its games run on the arcade's scale underneath) + : G.story && G.story.part === 'chap' + ? `ACT ${ROMAN[G.story.act]} · GAME ${Math.min(STORY.length, G.runGames - G.story.base + 1)} / ${STORY.length}` : `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8); } // ...and NOT during a match: nothing on that court is killed for points, so the pips were both a @@ -3072,12 +3075,21 @@ export function render() { // "OUT IN SEMI-FINAL" reads like a telegram: the round names carry their own article when // they have one (THE FINAL, ROUND ONE) and want one when they do not. const theRound = n => /^(THE |ROUND )/.test(n) ? n : 'THE ' + n; - const title = dead + // THE STORY's endings: a death names the checkpoint it goes back to, a cleared act names the + // next one. Nothing here is a run summary — the story keeps its place in the profile. + const st = G.story; + const sf = st ? storyFixture(prof.story) : null; + const title = st ? (dead ? (cup ? 'OUT IN ' + theRound(cup.rounds[Math.min(cup.round, cup.rounds.length - 1)].name) + '.' : 'EATEN.') + : G.done.story >= 5 ? 'THE DEAD CLUB IS QUIET.' : 'ACT ' + ROMAN[G.done.story] + ' CLEARED.') + : dead ? (cup ? 'OUT IN ' + theRound(cupRoundName(G.league, cup.round + 1)) + '.' : m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') : G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.') : won ? 'GAME, SET, MATCH.' : 'BEATEN.'; - const line = dead + const line = st ? (dead ? (cup ? 'ACT ' + ROMAN[st.act] + ' · BACK TO ROUND ONE' + : 'GAME ' + Math.min(STORY.length, G.runGames - st.base + 1) + ' OF ' + STORY.length + ' · ATTEMPT ' + prof.story.tries) + : storyBossArt(G.done.story, 'end').name + ' FELL') + : dead ? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT' : 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league)))) : m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' @@ -3102,7 +3114,7 @@ export function render() { const lineLines = wrapText(line, F(9), inner); // the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's // felled count beside it was two bests answering different questions in the same box - const mark = G.newBest || cup ? null + const mark = G.newBest || cup || st ? null : G.best && G.best.games > 0 ? (m ? 'BEST ' + G.best.games + ' FELLED' : G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' @@ -3112,7 +3124,7 @@ export function render() { // 2026-09-11). Netplay used to end on THE CLUBHOUSE alone, which meant a fresh room code and // a second trip through the lobby to play the same person again. const online = !promo && !!(G.net && G.net.ready && !G.net.dead); - const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD' + const fxLines = ready ? wrapText(st ? sf.line : promo ? 'THE CLUB WANTS A WORD' : online ? 'YOUR FRIEND IS STILL ON THE LINE' : G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : []; let h = pad + headLines.length * 24 + lineLines.length * 13 + 6; @@ -3177,7 +3189,7 @@ export function render() { ctx.font = F(7, 0); shadowText('NEXT', inX, y + 6, UI.dim); ctx.font = F(9, 700); - shadowText(promo ? 'THE PROMOTION' : online ? 'ANOTHER DUEL' + shadowText(st ? sf.title : promo ? 'THE PROMOTION' : online ? 'ANOTHER DUEL' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title, inX + 34, y + 6, UI.warm); y += 16; @@ -3196,16 +3208,17 @@ export function render() { const againLabel = G.classic ? 'CLASSIC AGAIN' : G.match && G.match.train ? 'TRAIN AGAIN' : G.match ? 'CLIMB AGAIN' : 'GORE AGAIN'; - const second = solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE'] + const second = st ? ['club', 'THE CLUBHOUSE'] + : solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE'] : ['again', againLabel]) : online ? ['club', 'THE CLUBHOUSE'] : null; const bw = second && !stack ? Math.round((inner - 8) / 2) : inner; // "THE DRAW" was tennis for the field of entrants — and nothing is drawn any more since the // cup went deterministic (2026-08-29). The button names what it opens instead. - const label = promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE CUP' + const label = st ? (dead ? 'TRY AGAIN' : 'CONTINUE') : promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE CUP' : online ? 'REMATCH' : 'THE CLUBHOUSE'; drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true); - endBtns.push({ id: solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN }); + endBtns.push({ id: st ? 'story' : solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN }); if (second) { const sx = stack ? inX : inX + bw + 8, sy = stack ? y + MODAL_BTN + 8 : y; drawButton(sx, sy, bw, MODAL_BTN, second[1], UI.warm, false); diff --git a/src/state.js b/src/state.js index ddb3ca3..a03deeb 100644 --- a/src/state.js +++ b/src/state.js @@ -185,6 +185,10 @@ export const G = { swung: false, // player swung at least once this rally — whiff log supersedes PASSED lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly moveTapT: 0, // the double tap's open window (gameplay.keyPressed, moveCall) + // THE STORY's run state (beats.startStory). Wiped by restartRun like everything on G — the place in + // the story lives in the profile (prof.story); this is only what THIS run is doing with it. + story: null, // {act, part: 'cup'|'chap', base, midDone, fight: 'mid'|'end'|null, winT} + storyBoss: null, // 'mid' | 'end' — a story boss is due at the next clear (splatPoint arms it) ring: null, // the drawn gold ring's steady cue (gameplay update smooths ringCue) logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump }; diff --git a/src/zombies.js b/src/zombies.js index 1f3e2af..564521d 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -3,13 +3,13 @@ // your baseline — one crossing it is a point for THEM. // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) -import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; +import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R, storyPool } from './config.js'; import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js'; import { haptic } from './haptics.js'; import { drillKill, drillCaught } from './drill.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; -import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one +import { resolveInterlude, endBossFight, storyDeath } from './beats.js'; // runtime-only cycle, like the gameplay one import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; import { award, bonus, prof, recordCup } from './profile.js'; @@ -83,6 +83,14 @@ let lastBarrel = null; // run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in // a row, which is the part a player actually notices. function pickBarrelWeapon() { + // THE STORY: a barrel holds what the act has earned (config.storyPool) — the act decides the rack, + // so a barrel in act I is a bomb, a triple or fire, never the laser the story hands out in act III + if (G.story) { + const own = storyPool(G.story.act).filter(id => id !== lastBarrel); + const id = own.length ? own[Math.floor(Math.random() * own.length)] : lastBarrel || 'bomb'; + lastBarrel = id; + return id; + } const pool = []; for (const [id, b] of Object.entries(BARREL_WEAPONS)) { if (G.runGames < b.from) continue; @@ -132,8 +140,10 @@ export function spawnMachine() { // slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it // reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a // body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing. -export function spawnChapterBoss(chapter) { - const art = bossArt(chapter); // which of the boss designs this chapter is (every tenth = THE CHAMPION) +export function spawnChapterBoss(chapter, design = null) { + // which of the boss designs this chapter is (every tenth = THE CHAMPION) — or, in THE STORY, the + // act's own design at the act's strength (config.storyBossArt / storyBossChapter) + const art = design || bossArt(chapter); // Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a // design row that carries nothing but art behaves exactly the way a boss did before there were any. const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1)); @@ -317,6 +327,11 @@ export function killPlayer() { // It goes through the SAME endRun with the mode tagged and its own two headline numbers in the // record's two headline slots (see the shape note there), so the best is derived from the record // rather than stored a second time in its own key and left to drift. + // THE STORY HAS NO PERMADEATH: the death costs the attempt, not the story (prof.story keeps the + // checkpoint), and it banks nothing on the boards — the story is not a high-score run, and its + // chapter games are numbered on the arcade's scale (STORY.base), which would post act V's game + // 85 as a gore best. The summary reads G.story and offers the retry. + if (G.story) { storyDeath(); return; } const m = G.match; // the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The // arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and diff --git a/test.js b/test.js index e53f9d2..6c9c55f 100644 --- a/test.js +++ b/test.js @@ -90,7 +90,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; const { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); -const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); +const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs, startStory, startStoryChapter } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // player would, so the tests step a live court @@ -118,6 +118,7 @@ function freshSplatter() { // THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly // the way the promotion did — the training court reads `out`, and a stale one reorders its list prof.prof.cups = {}; prof.prof.at = {}; prof.prof.out = 0; + prof.prof.story = prof.freshStory(); // THE STORY's place leaks between tests the same way G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); @@ -3640,5 +3641,131 @@ test('PLAYTEST: reloading restores saved events without silently starting record passed++; console.log(' ok - PLAYTEST: file sharing, cancellation and download fallback preserve the export'); } +// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY") ---------------------------------------------- +// One act, played headless through the real modules: the cup (its final is the boss's body), the +// chapter (no duel, the act's stock, the mid-boss checkpoint, the act boss), the act card, and a death +// that restarts from the checkpoint instead of ending anything. +const storyGame = () => { zombies.length = 0; for (let k = 0; k < TUNE.killsPerPoint * 4; k++) splatPoint('P', true); }; +function intoStory(act, cp) { + prof.prof.story = { act, cp, rack: [], tries: 0 }; + G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); + assert.ok(startStory(), 'the story takes the court'); + G.modal = null; G.modalQ.length = 0; +} +function winStoryCup() { + for (let i = 0; i < 120 * 60 && G.story && G.story.part === 'cup'; i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; + if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) + resolveInterlude('P', 'test'); + update(DT); + } + G.modal = null; G.modalQ.length = 0; +} + +test('THE STORY: every act\'s cup final is the body that becomes its boss, and the stock grows act by act', () => { + const famOf = n => cfg.matchFoe(n).fam; + const bossFam = { 0: 'gk', 5: 'ma', 2: 'cu', 3: 'hc', 9: 'ch' }; // design -> the ladder body it grew from + for (let a = 1; a <= 5; a++) { + const A = cfg.storyAct(a), R = cfg.storyRounds(a); + assert.equal(R.length, cfg.cupRoundsFor(A.lg).length, `act ${a}: the league cup's own round count`); + assert.equal(R[R.length - 1].name, 'THE FINAL'); + assert.equal(famOf(R[R.length - 1].band[0]), bossFam[A.boss], `act ${a}: the finalist is the boss's body`); + const band = cfg.leagueOf(A.lg).band, rungs = R.map(r => r.band[0]).sort((x, y) => x - y); + assert.deepEqual(rungs, Array.from({ length: band[1] - band[0] + 1 }, (_, i) => band[0] + i), `act ${a}: the whole band, once`); + } + const all = new Set([0, 1, 2, 3, 4].flatMap(i => [cfg.STORY.acts[i].mid, cfg.STORY.acts[i].boss])); + assert.equal(all.size, 10, 'the ten boss designs, each exactly once'); + assert.deepEqual(cfg.storyPool(1), ['bomb', 'triple', 'fire']); + assert.ok(cfg.storyPool(4).includes('laser') && cfg.storyPool(4).includes('rocket')); + assert.equal(cfg.storyStock(5)[0].n, cfg.WEAPONS.bomb.tin * 2, 'act V pays double tins'); + assert.deepEqual(cfg.cupRoundsFor(2).map(r => r.band[0]), [5, 6, 7, 8], 'the league cup itself is untouched'); +}); + +test('THE STORY: the act\'s cup is won into its chapter — no promotion, the stock in the rack, full blood', () => { + intoStory(1, 'cup'); + assert.equal(G.story.part, 'cup'); + assert.ok(G.match && G.match.cup, 'the act opens on its cup'); + winStoryCup(); + assert.ok(G.story && G.story.part === 'chap', 'the won cup hands the court to the chapter'); + assert.ok(!G.match && !G.done, 'no trophy screen, no match left behind'); + assert.equal(prof.takePromo(), null, 'the story moves the league itself — no promotion ceremony'); + assert.equal(prof.prof.story.cp, 'chap', 'the chapter is the checkpoint now'); + assert.deepEqual(G.rack.map(e => e.id), ['bomb', 'triple', 'fire'], 'act I\'s stock is in the rack'); + assert.equal(G.hp, TUNE.playerHp); + assert.equal(G.runGames, cfg.STORY.base(1), 'the chapter starts at its own game 1'); +}); + +test('THE STORY: a chapter has no duel, a mid-boss checkpoint at five and the act boss at ten', () => { + prof.prof.lg = 1; + intoStory(2, 'chap'); + assert.equal(G.runGames, cfg.STORY.base(2)); + for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } + assert.ok(!G.duelDue && !G.interlude, 'no duel was ever called'); + assert.ok(G.bossFight, 'the mid-boss walks on after game five'); + const mid = zombies.find(z => z.variant === 'B'); + assert.equal(mid.art.name, cfg.storyBossArt(2, 'mid').name, 'act II\'s mid-boss is the stringer'); + G.rack = [{ id: 'bomb', n: 1 }]; G.hp = 1; // a hard first half + zombies.length = 0; endBossFight(); + assert.equal(prof.prof.story.cp, 'mid', 'the mid-boss is the checkpoint'); + assert.equal(G.hp, G.hpMax, 'full blood'); + for (const e of cfg.storyStock(2)) assert.ok(G.rack.find(r => r.id === e.id).n >= e.n, `${e.id} restocked`); + // the lucky-clear duel trigger: one fresh racket zombie alone in pretoss — never a duel here + G.bossFight = 0; zombies.length = 0; spawnZombie(0, 2, 'r'); G.phase = 'pretoss'; + update(DT); + assert.ok(!G.interlude, 'the racket zombie is an ordinary body in the story'); + zombies.length = 0; + for (let g = 0; g < cfg.STORY.length - cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } + const boss = zombies.find(z => z.variant === 'B'); + assert.ok(G.bossFight && boss, 'the act boss walks on after game ten'); + assert.equal(boss.art.name, cfg.storyBossArt(2, 'end').name, 'act II\'s boss is the sponsor mascot'); + zombies.length = 0; endBossFight(); + for (let i = 0; i < 20 * 60 && !G.done; i++) { G.modal = null; update(DT); } + assert.equal(G.done && G.done.story, 2, 'the act is cleared'); + assert.equal(prof.prof.story.act, 3); assert.equal(prof.prof.story.cp, 'cup'); + assert.ok(prof.prof.lg >= 3, 'the next act\'s league opens'); +}); + +test('THE STORY: death is not permadeath — it banks nothing and restarts from the checkpoint', () => { + intoStory(3, 'chap'); + for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } + zombies.length = 0; endBossFight(); // past the mid-boss + const saved = prof.prof.story.rack.map(e => e.id + e.n).join(); + G.best = null; + storyGame(); + killPlayer(); + assert.equal(G.mode, 'eaten'); + assert.equal(prof.prof.story.tries, 1, 'the attempt is counted'); + assert.equal(prof.prof.story.cp, 'mid', 'the checkpoint stands'); + assert.equal(G.best, null, 'no board, no run record (endRun never ran)'); + restartRun(); + intoStory(3, 'mid'); + prof.prof.story.tries = 1; + assert.equal(G.runGames, cfg.STORY.base(3) + cfg.STORY.midAt, 'back at game six'); + assert.ok(G.story.midDone, 'the mid-boss stays beaten'); + assert.equal(G.hp, G.hpMax); +}); + +test('THE STORY: a barrel holds what the act has earned', () => { + intoStory(1, 'chap'); + const seen = new Set(); + for (let i = 0; i < 60; i++) { + zombies.length = 0; G.runGames = cfg.STORY.base(1) + 3; + const zb = (() => { const z = spawnZombie(0, 0, 'o'); return zombies.find(q => q.variant === 'o') || z; })(); + if (zb && zb.wid) seen.add(zb.wid); + } + assert.ok(seen.size >= 2, 'barrels were opened: ' + [...seen]); + for (const id of seen) assert.ok(cfg.storyPool(1).includes(id), `act I barrel: ${id}`); +}); + +test('THE STORY: a profile from before the story starts it from the top, keeping its level', () => { + const s = prof.cleanStory(undefined); + assert.deepEqual([s.act, s.cp], [1, 'pro']); + const t = prof.cleanStory({ act: 9, cp: 'nonsense', rack: [{ id: 'bomb', n: 500 }, { n: 2 }], tries: -3 }); + assert.deepEqual([t.act, t.cp, t.tries], [5, 'cup', 0]); + assert.deepEqual(t.rack, [{ id: 'bomb', n: 99 }]); + assert.equal(cfg.storyFixture({ act: 1, cp: 'mid' }).line, 'CHAPTER I · FROM GAME 6'); + assert.equal(cfg.storyFixture({ act: 5, cp: 'done' }).title, 'THE STORY IS TOLD'); +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report