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
@@ -126,6 +126,13 @@ def clean_profile(payload):
|
|||||||
'at': at,
|
'at': at,
|
||||||
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
|
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
|
||||||
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
|
'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):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return who, out
|
return who, out
|
||||||
|
|||||||
+161
-9
@@ -7,13 +7,13 @@
|
|||||||
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
|
// (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.)
|
// 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 { endRun, MODE_MATCH } from './stats.js';
|
||||||
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.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 { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
|
||||||
import { voice, zombieDuck, aww } from './audio.js';
|
import { voice, zombieDuck, aww } from './audio.js';
|
||||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.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 { startDrillStream } from './drill.js';
|
||||||
import { recordEvent } from './recorder.js';
|
import { recordEvent } from './recorder.js';
|
||||||
import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon
|
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.)
|
// 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
|
// 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.
|
// 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; }
|
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.
|
// 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; }
|
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
|
// 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.
|
// 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;
|
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
|
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
|
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
|
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).
|
// 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
|
// 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.
|
// 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;
|
if (G.bossFight || G.drill || G.match || G.bench || G.net) return;
|
||||||
G.bossFight = chapter;
|
G.bossFight = chapter;
|
||||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
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;
|
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
|
||||||
ball.active = false; setupPoint();
|
ball.active = false; setupPoint();
|
||||||
spawnChapterBoss(chapter);
|
spawnChapterBoss(chapter, design);
|
||||||
zombieDuck(true); // the music drops out for it
|
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())
|
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 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 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
|
// 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() {
|
export function endBossFight() {
|
||||||
if (!G.bossFight) return;
|
if (!G.bossFight) return;
|
||||||
const c = G.bossFight; G.bossFight = 0;
|
const c = G.bossFight; G.bossFight = 0;
|
||||||
|
const fight = G.story && G.story.fight;
|
||||||
|
if (G.story) G.story.fight = null;
|
||||||
zombieDuck(false);
|
zombieDuck(false);
|
||||||
shots.length = 0;
|
shots.length = 0;
|
||||||
cheer(3.4, 0.9); voice('unbelievable', 0.3);
|
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
|
// 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
|
// 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
|
// 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.)
|
// 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,
|
G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT,
|
||||||
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
|
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
|
||||||
|
if (fight === 'mid') storyCheckpoint();
|
||||||
ball.active = false;
|
ball.active = false;
|
||||||
// a duel the handback just started owns the phase; otherwise hold the boss beat, and never
|
// 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)
|
// 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
|
// 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
|
// 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.
|
// 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
|
// `rounds` (the old dead `draw` slot) lets THE STORY hand in its own cup (config.storyRounds: the
|
||||||
// (cupRoundsFor is deterministic, 2026-08-29) and nothing reads it.
|
// final is the act boss's body); null is the league's ordinary cup.
|
||||||
export function startMatch(cup = false, draw = null, train = 0) {
|
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;
|
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).
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// 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.matchFall = null;
|
||||||
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
|
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
|
// 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',
|
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'],
|
'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'] });
|
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) {
|
} else if (cup && !G.seen.cup) {
|
||||||
G.seen.cup = true;
|
G.seen.cup = true;
|
||||||
showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
|
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
|
// 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.
|
// match run, not dead: the stats screen's deaths column keeps meaning what it means.
|
||||||
function cupWon() {
|
function cupWon() {
|
||||||
|
if (G.story) return storyCupWon();
|
||||||
const d = G.match;
|
const d = G.match;
|
||||||
d.ending = false; d.mopT = 0;
|
d.ending = false; d.mopT = 0;
|
||||||
G.interlude = false;
|
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
|
G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
@@ -1018,6 +1018,54 @@ export const cupFixture = (lg, champion = 0) => {
|
|||||||
sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' };
|
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.
|
// 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) => {
|
export const cupRoundName = (lg, reached) => {
|
||||||
const rounds = cupRoundsFor(lg);
|
const rounds = cupRoundsFor(lg);
|
||||||
|
|||||||
+14
-5
@@ -1,6 +1,6 @@
|
|||||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
// 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 { 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 { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||||
import { haptic } from './haptics.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 { award, beginRunXp, prof, bonus, levelAt } from './profile.js';
|
||||||
import { recordEvent, recordFrame } from './recorder.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 { 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
|
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
|
||||||
|
|
||||||
// ------------------------------------------------------------- ballistics ----
|
// ------------------------------------------------------------- ballistics ----
|
||||||
@@ -892,13 +892,20 @@ export function splatPoint(side, quiet = false) {
|
|||||||
G.runGames++;
|
G.runGames++;
|
||||||
// the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance
|
// 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.
|
// 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.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
|
||||||
G.duelDue = true;
|
G.duelDue = true;
|
||||||
// ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from
|
// ...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
|
// 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.
|
// 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
|
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
|
// 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
|
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
|
||||||
// pure upside for weapon supply.
|
// pure upside for weapon supply.
|
||||||
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight
|
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();
|
startDuel();
|
||||||
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
|
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
|
// 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'
|
// ...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.
|
// block so it can hand the point back before that block puts the player in a serve stance.
|
||||||
tickMatchFall(dt);
|
tickMatchFall(dt);
|
||||||
|
tickStory(dt);
|
||||||
if (G.phase === 'over') {
|
if (G.phase === 'over') {
|
||||||
G.overT -= dt;
|
G.overT -= dt;
|
||||||
if (G.overT <= 0) {
|
if (G.overT <= 0) {
|
||||||
|
|||||||
+15
-6
@@ -3,10 +3,10 @@
|
|||||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
// 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)
|
// 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 { 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 { 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 { netConnect, netHooks, netAgain } from './net.js';
|
||||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js';
|
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js';
|
||||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||||
@@ -371,6 +371,7 @@ function leavePlayer(saved) {
|
|||||||
if (back === 'card') { buildCard(); go('card'); }
|
if (back === 'card') { buildCard(); go('card'); }
|
||||||
else if (back === 'setup') go('setup');
|
else if (back === 'setup') go('setup');
|
||||||
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
|
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
|
||||||
|
else if (back === 'story' && saved) start('story');
|
||||||
else go('splash');
|
else go('splash');
|
||||||
}
|
}
|
||||||
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
|
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
|
||||||
@@ -633,7 +634,8 @@ function refreshSplash() {
|
|||||||
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
|
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
|
// 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.
|
// (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('msTitle').textContent = fx.title;
|
||||||
document.getElementById('msLine').textContent = fx.line;
|
document.getElementById('msLine').textContent = fx.line;
|
||||||
document.getElementById('mCourt').textContent = 'COURT · ' +
|
document.getElementById('mCourt').textContent = 'COURT · ' +
|
||||||
@@ -651,7 +653,8 @@ function start(modeId = pickedMode) {
|
|||||||
selectKit(pickedKit); selectSkin(pickedSkin);
|
selectKit(pickedKit); selectSkin(pickedSkin);
|
||||||
// THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other
|
// 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).
|
// 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
|
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
|
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
|
// ...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
|
if (modeId === 'cup') { intoSplatter(); startMatch(true); } // cupDraw shuffles per round
|
||||||
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
|
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
|
||||||
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
|
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.
|
// Career totals and detailed records live one level beneath the profile.
|
||||||
@@ -725,8 +730,8 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
addEventListener('pagehide', () => recorder.saveRecording());
|
addEventListener('pagehide', () => recorder.saveRecording());
|
||||||
const goMode = () => { buildTops(); go('mode'); };
|
const goMode = () => { buildTops(); go('mode'); };
|
||||||
document.getElementById('mStart').addEventListener('click', () => {
|
document.getElementById('mStart').addEventListener('click', () => {
|
||||||
if (!profile.prof.setup) { playerReturn = 'cup'; buildPlayer(); go('player'); return; }
|
if (!profile.prof.setup) { playerReturn = 'story'; buildPlayer(); go('player'); return; }
|
||||||
pickMode('cup'); start('cup');
|
start('story');
|
||||||
});
|
});
|
||||||
document.getElementById('mOther').addEventListener('click', goMode);
|
document.getElementById('mOther').addEventListener('click', goMode);
|
||||||
document.getElementById('mVs').addEventListener('click', () => vsOpen());
|
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
|
// 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.
|
// clubhouse) if the friend has already hung up.
|
||||||
if (door === 'rematch' && netAgain()) return;
|
if (door === 'rematch' && netAgain()) return;
|
||||||
|
const story = !!G.story; // restartRun wipes G — the story's place is in the profile
|
||||||
restartRun();
|
restartRun();
|
||||||
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
|
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
|
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.
|
// prompt it may also have earned (prGo hands the run on), then the title.
|
||||||
const promo = profile.takePromo();
|
const promo = profile.takePromo();
|
||||||
if (promo) { postCup(promo); buildPromo(promo, run); go('promo'); return; }
|
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
|
// ...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
|
// 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
|
// ...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,
|
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,
|
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
|
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; } };
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
||||||
+38
-5
@@ -34,17 +34,37 @@ const cleanCups = (c, cap = 9) => {
|
|||||||
return out;
|
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() {
|
function load() {
|
||||||
try {
|
try {
|
||||||
const p = JSON.parse(localStorage.getItem(KEY) || 'null');
|
const p = JSON.parse(localStorage.getItem(KEY) || 'null');
|
||||||
if (p && typeof p === 'object') {
|
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)),
|
// A PROFILE FROM BEFORE THE STORY starts the story from the top (Oliver: the testers play the
|
||||||
ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0,
|
// whole thing). Its level stays — xp is the server mirror's one-way ratchet and cannot go
|
||||||
cups: cleanCups(p.cups), at: cleanCups(p.at, 50),
|
// down — but the league, the cups and the champion flag were earned on the old main path,
|
||||||
out: Math.max(0, Math.min(99, p.out | 0)) };
|
// 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 {}
|
} 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();
|
export const prof = load();
|
||||||
@@ -185,8 +205,21 @@ export async function pull() {
|
|||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const p = (await r.json()).p;
|
const p = (await r.json()).p;
|
||||||
if (!p) return;
|
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) {
|
if ((p.xp | 0) > prof.xp) {
|
||||||
prof.xp = p.xp | 0;
|
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.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1));
|
||||||
prof.ch = prof.ch || (p.ch ? 1 : 0);
|
prof.ch = prof.ch || (p.ch ? 1 : 0);
|
||||||
prof.setup = prof.setup || (p.setup ? 1 : 0);
|
prof.setup = prof.setup || (p.setup ? 1 : 0);
|
||||||
|
|||||||
+22
-9
@@ -1,7 +1,7 @@
|
|||||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||||
|
|
||||||
import { cv, ctx } from './dom.js';
|
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 { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
|
||||||
import { RAISERS, FRAMES } from './crowd.js';
|
import { RAISERS, FRAMES } from './crowd.js';
|
||||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.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)
|
// 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.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}`
|
: 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);
|
: `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
|
// ...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
|
// "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.
|
// 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 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)) + '.'
|
? (cup ? 'OUT IN ' + theRound(cupRoundName(G.league, cup.round + 1)) + '.'
|
||||||
: m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
|
: m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
|
||||||
: G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.')
|
: G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.')
|
||||||
: won ? 'GAME, SET, MATCH.' : 'BEATEN.';
|
: 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'
|
? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT'
|
||||||
: 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league))))
|
: 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league))))
|
||||||
: m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
|
: 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);
|
const lineLines = wrapText(line, F(9), inner);
|
||||||
// the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's
|
// 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
|
// 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
|
: G.best && G.best.games > 0
|
||||||
? (m ? 'BEST ' + G.best.games + ' FELLED'
|
? (m ? 'BEST ' + G.best.games + ' FELLED'
|
||||||
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
|
: 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
|
// 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.
|
// a second trip through the lobby to play the same person again.
|
||||||
const online = !promo && !!(G.net && G.net.ready && !G.net.dead);
|
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'
|
: online ? 'YOUR FRIEND IS STILL ON THE LINE'
|
||||||
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
|
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
|
||||||
let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
|
let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
|
||||||
@@ -3177,7 +3189,7 @@ export function render() {
|
|||||||
ctx.font = F(7, 0);
|
ctx.font = F(7, 0);
|
||||||
shadowText('NEXT', inX, y + 6, UI.dim);
|
shadowText('NEXT', inX, y + 6, UI.dim);
|
||||||
ctx.font = F(9, 700);
|
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,
|
: G.net ? 'BACK TO THE CLUBHOUSE' : fx.title,
|
||||||
inX + 34, y + 6, UI.warm);
|
inX + 34, y + 6, UI.warm);
|
||||||
y += 16;
|
y += 16;
|
||||||
@@ -3196,16 +3208,17 @@ export function render() {
|
|||||||
const againLabel = G.classic ? 'CLASSIC AGAIN'
|
const againLabel = G.classic ? 'CLASSIC AGAIN'
|
||||||
: G.match && G.match.train ? 'TRAIN AGAIN'
|
: G.match && G.match.train ? 'TRAIN AGAIN'
|
||||||
: G.match ? 'CLIMB AGAIN' : 'GORE 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])
|
: ['again', againLabel])
|
||||||
: online ? ['club', 'THE CLUBHOUSE'] : null;
|
: online ? ['club', 'THE CLUBHOUSE'] : null;
|
||||||
const bw = second && !stack ? Math.round((inner - 8) / 2) : inner;
|
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
|
// "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.
|
// 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';
|
: online ? 'REMATCH' : 'THE CLUBHOUSE';
|
||||||
drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true);
|
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) {
|
if (second) {
|
||||||
const sx = stack ? inX : inX + bw + 8, sy = stack ? y + MODAL_BTN + 8 : y;
|
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);
|
drawButton(sx, sy, bw, MODAL_BTN, second[1], UI.warm, false);
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ export const G = {
|
|||||||
swung: false, // player swung at least once this rally — whiff log supersedes PASSED
|
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
|
lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly
|
||||||
moveTapT: 0, // the double tap's open window (gameplay.keyPressed, moveCall)
|
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)
|
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
|
logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-4
@@ -3,13 +3,13 @@
|
|||||||
// your baseline — one crossing it is a point for THEM.
|
// your baseline — one crossing it is a point for THEM.
|
||||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
// (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 { 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 { 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 { haptic } from './haptics.js';
|
||||||
import { drillKill, drillCaught } from './drill.js';
|
import { drillKill, drillCaught } from './drill.js';
|
||||||
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.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 { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
|
||||||
import { award, bonus, prof, recordCup } from './profile.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
|
// 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.
|
// a row, which is the part a player actually notices.
|
||||||
function pickBarrelWeapon() {
|
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 = [];
|
const pool = [];
|
||||||
for (const [id, b] of Object.entries(BARREL_WEAPONS)) {
|
for (const [id, b] of Object.entries(BARREL_WEAPONS)) {
|
||||||
if (G.runGames < b.from) continue;
|
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
|
// 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
|
// 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.
|
// body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing.
|
||||||
export function spawnChapterBoss(chapter) {
|
export function spawnChapterBoss(chapter, design = null) {
|
||||||
const art = bossArt(chapter); // which of the boss designs this chapter is (every tenth = THE CHAMPION)
|
// 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
|
// 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.
|
// 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));
|
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
|
// 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
|
// 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.
|
// 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;
|
const m = G.match;
|
||||||
// the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The
|
// 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
|
// arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
|||||||
const state = await import('./src/state.js');
|
const state = await import('./src/state.js');
|
||||||
const { G, P, O, score, ball, setupPoint } = state;
|
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 { 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');
|
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
|
// 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
|
// 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 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
|
// 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.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.mode = 'splatter';
|
||||||
G.everTransitioned = true;
|
G.everTransitioned = true;
|
||||||
setupPoint();
|
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');
|
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`);
|
console.log(`\n${passed} tests passed`);
|
||||||
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
||||||
Reference in new issue
Block a user