Story phase 1: the spine — acts, cups, chapters, checkpoints

The front door now continues the story. An act is its league cup, whose
final is the boss's own body (acts II and IV play their band out of
ladder order for that), then a ten-game gore chapter on the act's court.
The chapter has no duel at all, takes the act's weapon stock into the
rack, meets a mid-boss after game 5 (the checkpoint: restock and full
blood) and the act boss after game 10, then the act card. Death is not
permadeath: it counts the attempt, banks nothing on the boards, and
TRY AGAIN restarts from the checkpoint the profile holds. Barrels hold
what the act has earned. The chapter plays the arcade's games
base+1..base+10 so every director curve keeps its slope.

Profiles from before the story start it from the top (league, cups and
champion reset; level kept). The server mirror stores the act and
checkpoint and only ever moves them forward.

Plain cards stand in for the screens (phase 3) and the transformation
(phase 2). Six new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-15 14:10:09 +02:00
1 parent 5e1c6abba4
commit 75b450f105
10 files changed
+456 -39

No files matched your search

+22 -9
View File
@@ -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);