Nothing arrives unannounced: the horde gets its breather, the boss gets a name

THEY KEEP COMING is a 2.4s 'over' beat again (warnHorde in beats.js) instead of a
flash raised on the same frame the wave lands, and the chapter boss walks on behind
an overlay — his name in red over ENTERED THE COURT — held for 3s by the new
self-dismissing modal clock (m.hold) rather than announced on a routine-set card.

startHorde returns a bool: if the court has been taken when the warning expires the
pendingHorde latch is re-armed rather than the set losing the horde it paid for. The
warning ticks on its own clock, not on the 'over' beat, so a beat that moves the
phase underneath it can't strand it (the mgT lock, again).

Three tests, 15 total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 16:38:29 +02:00
1 parent 1f636530b9
commit 5ea84f8511
8 files changed
+132 -16

No files matched your search

+34 -1
View File
@@ -10,7 +10,8 @@ detail of any individual change, read the git log.
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
Last updated 2026-08-11 (beats.js extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER — Last updated 2026-08-11 (the horde's breather restored, the boss walk-on overlay; beats.js
extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
@@ -101,6 +102,11 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour
scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no
`in` field draws fully landed, so older/test call sites still work. `in` field draws fully landed, so older/test call sites still work.
**`m.hold` is an optional self-dismiss clock** (seconds, ticked only once `m.in` has landed): the
modal announces, holds the frozen court, and lets go by itself. The chapter-boss walk-on is the
case it exists for — an announcement whose job is to PREPARE the player, where demanding a tap to
start a fight he did not ask for would be the control steal the popup policy exists to prevent.
A tap still skips it. Every other modal has no `hold` and waits for the player, as before.
While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but
won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there
would drop that tap through into a serve instead. would drop that tap through into a serve instead.
@@ -380,6 +386,33 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: nothing arrives unannounced (2026-08-11, same day)
Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were
true, and both were the same mistake — the announcement fired on the same frame as the thing it
announced, so it was over before it registered.
- **The horde gets its breather back.** `warnHorde()` (beats.js) raises THEY KEEP COMING and holds
an ordinary 'over' beat for `TUNE.hordeWarnT` (2.4 s); `startHorde()` runs when it expires. The
cheat (`forceZombies`) still starts one instantly — it is a cheat.
The breather runs on **its own clock** (`G.hordeWarn`, ticked in update above the 'over' block),
deliberately NOT on the 'over' beat it also sets: any beat that moves the phase underneath it
would strand the latch forever, which is the `mgT` lock this file already has a section about.
`startHorde()` **returns a bool** now, and if the court has been taken when the warning expires
the caller re-arms `pendingHorde` — the set gets its horde later rather than losing it silently.
- **The chapter boss walks on behind an OVERLAY**, not a band card: the boss's name in `UI.bad` over
`ENTERED THE COURT`. It is a modal (a boss stage is guaranteed calm — `startBossFight` clears the
court two lines earlier, so this is on the sanctioned list) with the new `m.hold` self-dismiss
clock, `TUNE.bossIntroT` = 3 s. A card in the routine-set slot and the routine-set size is why the
biggest fight in the game read as the boss simply being there.
- **The flamer does not trigger a horde** (his "vague feeling"). Arming any `kind: 'burst'` tin puts
you in the same CONTROL MODE a horde does — racket auto-fires, the finger steers you absolutely,
no serving — so a flamer or gun genuinely feels like a horde starting. Nothing in the weapon paths
touches `startHorde`; with the horde announced again the two are told apart by the announcement.
- Three tests added (15 total): the wave may not land on the frame of its own announcement, a mode
that steals the court during the warning re-arms the latch, and the boss overlay names the boss
and hands the court back without a tap.
## Session log: beats.js, the rename, and the build gate (2026-08-11, same day) ## Session log: beats.js, the rename, and the build gate (2026-08-11, same day)
Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both — Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both —
+1 -1
View File
@@ -398,6 +398,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=149"></script> <script type="module" src="src/main.js?v=150"></script>
</body> </body>
</html> </html>
+27 -6
View File
@@ -94,8 +94,14 @@ export function startBossFight(chapter) {
zombieDuck(true); // the music drops out for it zombieDuck(true); // the music drops out for it
voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85); voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
const art = bossArt(chapter); const art = bossArt(chapter);
G.card = { title: art.name, color: UI.bad, t: TUNE.setCardT }; // THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in
G.flash = null; // 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
// it), and it HOLDS itself for bossIntroT rather than waiting for a tap: the point is to prepare
// the player, and demanding a tap to start a fight he didn't ask for is the control steal the
// popup policy exists to prevent. A tap still skips it.
G.card = null; G.flash = null;
showModal({ title: art.name, color: UI.bad, hold: TUNE.bossIntroT, body: ['ENTERED THE COURT'] });
// (the four-line machine instruction modal is gone — people figure it out) // (the four-line machine instruction modal is gone — people figure it out)
} }
@@ -163,7 +169,7 @@ export function watchdog(dt) {
if ((stuckT += dt) < TUNE.stuckT) return; if ((stuckT += dt) < TUNE.stuckT) return;
logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`, logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`,
phase: G.phase, sig, aim: !!G.aim }); phase: G.phase, sig, aim: !!G.aim });
G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false; G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false; G.hordeWarn = 0;
G.aim = null; endBurst(); setupPoint(); G.aim = null; endBurst(); setupPoint();
stuckT = 0; stuckSig = ''; stuckT = 0; stuckSig = '';
} }
@@ -200,9 +206,23 @@ export function resolveInterlude(side) {
} }
} }
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you // THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement,
// so the flash was already gone by the time the crowd was on you — "the horde is not announced
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
// counts it down under the flash), and startHorde still owns the transition itself.
export function warnHorde() {
if (G.horde || G.interlude || G.bossFight) return false; // same court-ownership contract as the rest
G.hordeWarn = TUNE.hordeWarnT;
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
return true;
}
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you.
// Returns false when another mode owns the court — the caller re-arms the latch rather than
// silently eating the wave the set paid for.
export function startHorde() { export function startHorde() {
if (G.horde || G.interlude || G.bossFight) return; // same contract as startDuel: one mode owns the court if (G.horde || G.interlude || G.bossFight) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
@@ -213,5 +233,6 @@ export function startHorde() {
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated' aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
// that used to play right before it belongs to the transition, not here.) // that used to play right before it belongs to the transition, not here.)
G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave
return true;
} }
+6
View File
@@ -278,6 +278,9 @@ export const TUNE = {
// 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which // 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which
// is why switching tins felt fiddly. rackHit also pads the touch band (render.js). // is why switching tins felt fiddly. rackHit also pads the touch band (render.js).
bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater
hordeWarnT: 2.4, // s of THEY KEEP COMING breather between the set card and the wave landing.
// The horde used to start on the same frame as its own announcement, which reads
// as no announcement at all — Oliver stopped noticing hordes were being called.
hordeLen: 12, // s a horde wave lasts hordeLen: 12, // s a horde wave lasts
hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the
// wave is meant to leave a standing crowd on your half when it ends, not to be a // wave is meant to leave a standing crowd on your half when it ends, not to be a
@@ -377,6 +380,9 @@ export const TUNE = {
bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is
// the longest fight in the game and the win was over before it registered, but // the longest fight in the game and the win was over before it registered, but
// stretching setCardT would have made every routine set announcement drag too. // stretching setCardT would have made every routine set announcement drag too.
bossIntroT: 3.0, // s the boss walk-on overlay holds the frozen court before it lets go BY ITSELF.
// A boss used to arrive on a band card nobody read ("bosses are just there now") —
// this is the one announcement the player has to be prepared by, not informed of.
modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the
// left, each on easeOutBack. The gesture that dismisses is swallowed until it // left, each on easeOutBack. The gesture that dismisses is swallowed until it
// lands, so a panic tap can't skip a popup before it has been read. // lands, so a panic tap can't skip a popup before it has been read.
+20 -4
View File
@@ -6,7 +6,7 @@ import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zom
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
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, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
// ------------------------------------------------------------- ballistics ---- // ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -1187,7 +1187,14 @@ export function update(dt) {
if (G.modal) { if (G.modal) {
G.idleT += dt; G.idleT += dt;
const m = G.modal; // the entrance is the ONE thing that animates behind the freeze const m = G.modal; // the entrance is the ONE thing that animates behind the freeze
if (m.in < 1 && (m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud if (m.in < 1) {
if ((m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud
}
// ...and a modal may carry its OWN clock (`hold`): it announces, holds the frozen court for a
// couple of seconds and hands it back without being tapped. The boss walk-on is the case — an
// announcement the player must be PREPARED by, where waiting for a tap would be the control
// steal the popup policy forbids. Only ticks once it has landed; a tap still dismisses early.
else if (m.hold > 0 && (m.hold -= dt) <= 0) dismissModal();
return; return;
} }
// The flash is FROZEN behind a popup too, and it has to be: it used to tick above the return, so // The flash is FROZEN behind a popup too, and it has to be: it used to tick above the return, so
@@ -1466,6 +1473,15 @@ export function update(dt) {
const boy = G.boy; const boy = G.boy;
if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt); if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt);
// The announced breather runs on its OWN clock, not on the 'over' beat it also sets: any beat that
// moves the phase underneath it (a duel taking the calm court, say) would otherwise strand the
// latch forever — the mgT lock, exactly. If the court is taken when it expires, the latch is
// re-armed and the set gets its horde later rather than losing it. Above the 'over' block so the
// wave lands before that block can put the player back in the serve stance for one frame.
if (G.hordeWarn > 0 && (G.hordeWarn -= dt) <= 0) {
G.hordeWarn = 0;
if (!startHorde()) G.pendingHorde = true;
}
if (G.phase === 'over') { if (G.phase === 'over') {
G.overT -= dt; G.overT -= dt;
if (G.overT <= 0) { if (G.overT <= 0) {
@@ -1479,8 +1495,8 @@ export function update(dt) {
G.seen.horde = true; G.seen.horde = true;
showModal({ title: 'THEY KEEP COMING', color: UI.hot, showModal({ title: 'THEY KEEP COMING', color: UI.hot,
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'], body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
onDismiss: startHorde }); onDismiss: warnHorde });
} else startHorde(); // ...and every later set starts on the flash alone. Re-showing the } else warnHorde(); // ...and every later set starts on the flash alone. Re-showing the
// same popup at the hottest moment of every set was one of the // same popup at the hottest moment of every set was one of the
// "popups get me killed" reports — you know the rules by set two. // "popups get me killed" reports — you know the rules by set two.
return; return;
+2 -2
View File
@@ -211,7 +211,7 @@ document.getElementById('hot').addEventListener('pointerdown', () => {
function intoSplatter() { function intoSplatter() {
if (G.mode === 'tennis') forceZombies(); if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60); for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60);
G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false; G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false;
G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp; G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; shots.length = 0; pellets.length = 0; zombies.length = 0; shots.length = 0; pellets.length = 0;
score.server = 'P'; setupPoint(); score.server = 'P'; setupPoint();
@@ -352,7 +352,7 @@ function laserScene() {
paused = false; paused = false;
if (G.mode === 'tennis') forceZombies(); if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter
G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false; G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false;
G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp; G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0; zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0;
score.server = 'P'; setupPoint(); score.server = 'P'; setupPoint();
+2 -1
View File
@@ -111,7 +111,8 @@ export const G = {
clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score. clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score.
afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set' afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set'
clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card) clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card)
pendingHorde: false,// a set was won; the horde starts after the set card's pause pendingHorde: false,// a set was won; the horde is WARNED after the set card's pause
hordeWarn: 0, // s left of the THEY KEEP COMING breather; the wave lands when it expires (beats.warnHorde)
card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block' card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block'
// the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns) // the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns)
// and only a tap or key clears it, so a popup can be read without being eaten while you read it. // and only a tap or key clears it, so a popup can be read without being eaten while you read it.
+40 -1
View File
@@ -64,7 +64,7 @@ const { G, P, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js'); const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./src/beats.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./src/beats.js');
const { zombies, spawnZombie } = await import('./src/zombies.js'); const { zombies, spawnZombie } = await import('./src/zombies.js');
const { TUNE } = await import('./src/config.js'); const { TUNE, bossArt } = await import('./src/config.js');
const DT = 1 / 60; const DT = 1 / 60;
function freshSplatter() { function freshSplatter() {
@@ -217,5 +217,44 @@ test('a lost duel banks their game — and their set — without a horde', () =>
assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde'); assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde');
}); });
// ---- the announcements: nothing that changes the control mode may arrive unannounced -----------
test('a set warns THEY KEEP COMING before the wave lands', () => {
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement');
assert.ok(G.hordeWarn > 0, 'the breather is running');
assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so');
for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second
assert.equal(G.horde, null, 'the breather actually holds');
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.ok(G.horde, 'and the wave lands when it runs out');
});
test('a mode that steals the court during the warning re-arms the horde', () => {
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.ok(G.hordeWarn > 0, 'setup: warning running');
startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here
G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping)
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.equal(G.horde, null, 'the wave must not land inside the duel');
assert.ok(G.pendingHorde, 'the set is still owed its horde');
});
test('a chapter boss announces itself and hands the court back on its own', () => {
startBossFight(2);
assert.ok(G.modal, 'the walk-on is an overlay, not a band card');
assert.equal(G.modal.title, bossArt(2).name, 'it names the boss');
assert.deepEqual(G.modal.body, ['ENTERED THE COURT']);
for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT);
assert.ok(G.modal, 'and holds the frozen court while the player reads it');
for (let i = 0; i < 20 && G.modal; i++) update(DT);
assert.equal(G.modal, null, 'then lets go without waiting for a tap');
assert.ok(G.bossFight, 'the fight is on');
});
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