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

+128 -1
View File
@@ -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