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:
1 parent
5e1c6abba4
commit
75b450f105
10 files changed
+456
-39
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user