Gore opens in the gore, the theme comes back, and every mode gets a board

Splatter gore no longer makes you win a game of tennis first — the mode you
pick is the mode you get, with the man you came to play already turned. The
transition itself is untouched and still reachable; it just isn't a toll booth.

The menu theme could only ever be started, never brought back, so the first
run of a session silenced it for good while the court's ambience followed you
into the menu. Starting a bed now un-ducks it too.

Three world boards instead of one: kills, opponents felled, and games taken
off the CPU, each with its own name prompt and its own row per player.

...and the gore economy stops paying a barrel for every game won past game 16,
while the shamblers, the mix and the ball machines keep escalating past the
point they all used to stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 13:53:42 +02:00
1 parent 9cc3ac54b3
commit 3cf3e029a4
11 files changed
+440 -88

No files matched your search

+98 -1
View File
@@ -20,7 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run
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.
Last updated 2026-08-15 (the batch below: six new opponent families and an 18-rung ladder, the
Last updated 2026-08-15 (two sessions: the finetuning pass — gore opens in the gore, the menu theme
comes back, one world board per mode, and the barrel economy cut — over the batch below: six new
opponent families and an 18-rung ladder, the
tournament DRAW screen, three netplay fixes off Oliver's cellular report, the night court, and
camera flashes in the stand). **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
@@ -412,6 +414,101 @@ 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
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day)
Oliver's finetuning pass on the batch below.
### THE GORE MODE STARTS IN THE GORE
*"the splatter gore should not have the normal tennis intro anymore, we should just start right in
splatter mode."* The prologue was a game of real tennis you had to WIN before the mode you picked
would begin — a reveal the first time, a toll booth every time after.
`gameplay.startGore()` is the direct door: it lands on the same state the transition's last beat
lands on (extracted as `enterSplatter`, so the two doors can never drift), sets `everTransitioned`
so the cinematic cannot fire behind it, and spawns the turned opponent as your first body — he is
the man you came to play and the mode's premise is that he is already one of them. **The transition
is NOT deleted**: it is still what `forceZombies` / `T.forceZombies()` plays, and it is still the
best thing in the game to look at. It just no longer stands between the menu and the mode. The mode
poster stopped promising a match that turns, because there is no longer one.
`main.intoSplatter` — the back room's "get me into splatter" hack — now calls `startGore()` too,
which deletes its 900 hidden `update()` calls. That loop fast-forwarded the whole eight-second
cinematic inside a single frame, firing every sound it makes at once.
### THE MENU THEME COMES BACK, AND THE COURT STAYS ON THE COURT
*"after some games — or returning to the main menu via the close button — it does not play the
background music anymore but you sometimes still hear the court background noise."*
Two halves of one bug, and both are `loopBed`'s early return. **It no-ops once a file is playing**,
so `menuMusic()` could only ever START the theme and never bring it back from the duck `start()`
put it under — the menu was silent for the rest of the session from the first run onward, and
quitting was the reliable way in because the death summary is the only path that ever called
`menuDuck(false)`. Meanwhile nothing ducked the court's own two beds at all, so the ambience
followed you onto the silent menu.
`menuMusic()` is now the one call that means "we are in the menus": it starts the theme, un-ducks
it, and ducks both court beds. `ambient()` and `zombieMusic()` un-duck themselves for the same
reason — the SECOND run would otherwise have been played in the silence the menu left them in.
**The rule, for anything added here later: a bed's start function must also un-duck it, because
starting it is a no-op the second time and ducking it is not.** `audio.bedTest` exposes the duck
set to the suite (same idea as `netTest`), which is what makes this testable at all — the beds
never load headless, but the INTENT is the thing that was wrong.
### THREE WORLD BOARDS
*"the world rank is still about the splatter gore which is not the only thing — we need something
similar for the other modes."*
`stats.BOARDS` is the only place that knows what each board ranks, and the server does not know at
all: it sorts `k` then `g` within a mode and nothing crosses.
| board | ranks | second column |
|---|---|---|
| SPLATTER | kills in a run | games survived |
| TENNIS (arcade + cup) | opponents felled | games |
| CLASSIC | games taken off the CPU | games played |
Classic needed a number worth ranking — every win is exactly two sets, so a board of 2s is a wall
of ties. `G.classicWon` counts the games YOU took and rides in the record's `s` slot, which held
sets until now and which **nothing has ever displayed** (the sets line on the death screen is
gore's; `read()` has never looked at `s`). The name prompt is per-mode too, gated on the board's
own number rather than on kills — the old `run.kills > 0` test silently barred classic (no kills)
from ever reaching it.
Server: rows carry `m`, `GET /api/scores?m=g|z|c` filters, POST keeps one row per name **per
board** and trims `KEEP` **per board** so a busy mode cannot evict a quiet one. Name ownership
stays GLOBAL — one person owns their name on all three, or a stranger could take it on classic
while you hold it on gore. Rows written before the split carry no `m` and are gore rows, which is
the same convention the client has used since the stats screen split, so there is no migration.
### THE GORE MODE GETS HARD AGAIN
*"in splatter gore it now gets too easy to just play through. Maybe we should reduce the barrels
again — pretty early in the game every won game gives a barrel. The splatter gore should be
unbelievably hard towards the end."*
He is right and the arithmetic said so. `barrelEvery 3 / barrelRamp 8` drove the gap to **one** at
game 16 and left it there, so the entire back half of a run paid out a weapon for every game won —
while every difficulty ramp in the file reached its ceiling around game 25 and stopped. A run got
richer after game 25 and never more dangerous.
- **Barrels: 5 / 14, and a new `barrelFloor` of 3.** The ramp survives (the late game does need
more inventory than the early one), it is just slower and it STOPS. `gameplay.barrelGap()` is the
curve, exported and pinned by a test, because this is exactly the kind of one-liner that quietly
becomes an income again.
- **`zSpeedMax` 2.9 → 3.6** — shamblers keep gaining speed to ~game 28 instead of 22.
- **`nastyMax` 0.75 → 1.0** — by game 34 there are no ordinary shamblers left in the mix at all.
- **Ball machines scale**: `machCap()` is 2 + one per `machMaxPer` games, capped at `machMaxCap`.
The one kind of late pressure that does not crowd the ground — they hold position and shoot, so
the court stays readable while the number of things that can reach you goes up.
The body COUNT ceiling (`zMaxCap`) is untouched and stays untouched: it is a readability limit on a
270 px screen, not a difficulty dial. What escalates now is what the bodies ARE and how fast they
arrive. **Feel-pending: these are dials, and the end of a long run is the one part of this game
that cannot be judged from the numbers.**
## Session log: the roster doubles, the draw is made on screen, and netplay stops locking (2026-08-15)
Oliver's seven-item batch. All of it is on STAGING and pending his phone verdict; items 3-5 in
+10 -2
View File
@@ -498,9 +498,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
<div id="stW"></div>
<div class="foot" id="stFoot"></div>
</div>
<!-- THREE BOARDS, one per game mode — the three do not share a currency, so they cannot share
a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column
heading with the tab, because the number means something different on each one. -->
<div id="ranks">
<div class="hdr"><button class="chev" id="rkBack">&lsaquo;</button><h2>WORLD BOARD</h2></div>
<h3>BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
<div class="tabs" id="rkModes">
<button class="on" data-m="g">SPLATTER</button>
<button data-m="z">TENNIS</button>
<button data-m="c">CLASSIC</button>
</div>
<h3 id="rkHdr">BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
<div id="rkList"></div>
</div>
<!-- THE BACK ROOM. Five taps on the logo (main.js) and nothing else — no button anywhere hints
@@ -539,6 +547,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=260"></script>
<script type="module" src="src/main.js?v=261"></script>
</body>
</html>
+53 -21
View File
@@ -5,8 +5,11 @@ It replaced the nginx image because the leaderboard needs ONE writable process a
one. Everything nginx was doing here is four lines of headers, so a second container (and a second
Coolify resource, and a proxy rule) would have bought separation we have no use for at this size.
GET /api/scores -> {"scores": [{"n","k","g","t"}, ...]} best first
POST /api/scores <- {"n","k","g"} -> {"ok", "rank", "scores"}
GET /api/scores?m=g|z|c -> {"m", "scores": [{"n","k","g","t","m"}, ...]} best first
POST /api/scores <- {"n","k","g","m"} -> {"ok", "rank", "m", "scores"}
One board per game mode; `m` defaults to 'g' (splatter gore) at both ends, which is what every row
written before the split is.
Scores live in ONE json file under SPLAT_DATA (default /data, which must be a persistent volume —
anything written inside the container image is gone on the next deploy). There is no auth: the
@@ -22,12 +25,23 @@ DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else H
FILE = os.path.join(DATA, 'scores.json')
PORT = int(os.environ.get('PORT', 80))
KEEP = 200 # rows kept on disk; the board only ever shows the top slice of this
KEEP = 200 # rows kept on disk PER BOARD; the board only ever shows the top slice of this
MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one.
NAME_MAX = 12
# ONE BOARD PER GAME MODE (2026-08-15). The three do not share a currency — gore ranks bodies
# killed, zombie tennis ranks opponents felled, classic ranks games taken off the CPU — so a single
# board was really the gore mode's with the other two locked out of it. The server does not know or
# care what the numbers MEAN (see stats.BOARDS on the client); it sorts them within a mode and
# nothing crosses. Rows written before the split carry no mode and ARE gore rows, which is why 'g'
# is the default everywhere and no migration is needed.
MODES = ('g', 'z', 'c')
lock = threading.Lock()
def mode_of(r):
return r.get('m') or 'g'
def load():
try:
with open(FILE) as f:
@@ -60,7 +74,10 @@ def clean(payload):
if not (0 <= kills <= 1_000_000 and 0 <= games <= 10_000):
return None # bounds keep the sort honest and the column narrow
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who}
mode = str(payload.get('m', 'g'))
if mode not in MODES:
mode = 'g' # an unknown board is the default one, never a new one
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode}
def public(rows):
@@ -212,8 +229,11 @@ class Handler(SimpleHTTPRequestHandler):
if path == '/ws':
return self.handle_ws()
if path == '/api/scores':
want = re.sub(r'[^gzc]', '', (self.path.split('m=')[1].split('&')[0]
if 'm=' in self.path else ''))[:1] or 'g'
with lock:
return self.reply(200, {'scores': public(load())})
return self.reply(200, {'m': want,
'scores': public([r for r in load() if mode_of(r) == want])})
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
@@ -238,26 +258,38 @@ class Handler(SimpleHTTPRequestHandler):
return self.reply(400, {'error': 'bad score'})
with lock:
rows = load()
mine = [r for r in rows if r.get('n') == row['n']]
# A NAME BELONGS TO WHOEVER TOOK IT FIRST. The client mints a random id once and keeps
# it; the row remembers it, and a submission under someone else's name with a different
# id is refused. That is the whole of the "security" here — enough that a stranger
# cannot overwrite your row, and not one line more. Clear your browser storage and you
# lose the name, which is the honest cost of having no accounts.
if mine and mine[0].get('id') and mine[0]['id'] != row['id']:
# A NAME BELONGS TO WHOEVER TOOK IT FIRST, ACROSS ALL THREE BOARDS. The claim is on the
# NAME, not on a row: one person owns "OLIVER" everywhere, or a stranger could take it
# on the classic board while you hold it on gore. The client mints a random id once and
# keeps it; the row remembers it, and a submission under someone else's name with a
# different id is refused. That is the whole of the "security" here — enough that a
# stranger cannot overwrite your row, and not one line more. Clear your browser storage
# and you lose the name, which is the honest cost of having no accounts.
owner = next((r for r in rows if r.get('n') == row['n'] and r.get('id')), None)
if owner and owner['id'] != row['id']:
return self.reply(409, {'error': 'name taken'})
# EXACTLY ONE ROW PER NAME, their best. Without this the board is one player's session:
# every personal best posts again and a good run buries everyone else under itself.
# The client only posts on a personal best, but its idea of "best" is per-device — the
# same player on a second phone starts from zero and posts worse runs, so a weaker
# submission has to be dropped HERE rather than merely not sorted to the top.
# EXACTLY ONE ROW PER NAME PER BOARD, their best on it. Without this the board is one
# player's session: every personal best posts again and a good run buries everyone else
# under itself. The client only posts on a personal best, but its idea of "best" is
# per-device — the same player on a second phone starts from zero and posts worse runs,
# so a weaker submission has to be dropped HERE rather than merely not sorted to the top.
mine = [r for r in rows if r.get('n') == row['n'] and mode_of(r) == row['m']]
if row['k'] > max((r.get('k', 0) for r in mine), default=-1):
rows = [r for r in rows if r.get('n') != row['n']]
rows = [r for r in rows
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
rows.append(row)
rows.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
rows = rows[:KEEP]
# sorted and trimmed PER BOARD, so a busy mode can never evict a quiet one
kept = []
for m in MODES:
rs = [r for r in rows if mode_of(r) == m]
rs.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
kept += rs[:KEEP]
rows = kept
save(rows)
return self.reply(200, {'ok': True, 'rank': rank_of(rows, row['n']), 'scores': public(rows)})
board = [r for r in rows if mode_of(r) == row['m']]
board.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
return self.reply(200, {'ok': True, 'm': row['m'],
'rank': rank_of(board, row['n']), 'scores': public(board)})
# ---- static, with the headers the nginx conf used to set ----
def end_headers(self):
+25 -3
View File
@@ -161,11 +161,14 @@ function loopBed(file, lowcut, vol, loopEnd = 0) {
})
.catch(() => beds.delete(file)); // failed start (decode/network) can retry next call
}
export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); // whole-scene atmosphere, from first tap
// whole-scene atmosphere, from the first tap. Un-ducks as well as starts, for the same reason
// menuMusic does: loopBed no-ops once the bed exists, so a SECOND run would have opened over the
// silence the menu left it in.
export const ambient = () => { loopBed('tennis-ambient-loop.wav', 500, 0.035); courtDuck(false); };
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
export const zombieMusic = () => { loopBed('zombie-mode.m4a', 800, 0.2, 20.47077); zombieDuck(false); };
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
@@ -175,7 +178,19 @@ export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
// padding note in loopBed). Re-encode the file and you must re-measure that number:
// fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)
export const menuMusic = () => loopBed('background-music.m4a', 30, 0.65, 48);
// THE MENU OWNS THE THEME, AND THE COURT'S BEDS OWN NOTHING WHILE IT IS UP. This is called from
// every stage change, so it is the one place that can say "we are in the menus" — and it has to
// SAY it rather than merely start the bed, because loopBed no-ops once the file is playing. That
// was the bug (Oliver, 2026-08-15: "after some games, or returning to the main menu via the close
// button, the background music does not play any more but you sometimes still hear the court
// background noise"): start() ducked the theme to silence, nothing ever un-ducked it, and the
// court's ambience — which nothing ducked either — carried on underneath the silent menu. Quitting
// a run was the reliable way in, because the death summary is the only path that un-ducked.
export const menuMusic = () => {
loopBed('background-music.m4a', 30, 0.65, 48);
menuDuck(false); // ...and BACK UP, whatever a run left it at
courtDuck(true); // the court is not in the room any more
};
// duck a bed to silence (out=true) or back to its base level over `secs` — the sudden-death duel mutes the music
function fadeBed(file, out, secs = 0.7) {
@@ -191,6 +206,13 @@ export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must ma
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
export const menuDuck = out => fadeBed('background-music.m4a', out);
// BOTH court beds as one gesture, because "leaving the court" is one event and the two of them
// have to move together — the menu was silent AND noisy at the same time when only one did.
const courtDuck = out => { fadeBed('tennis-ambient-loop.wav', out); zombieDuck(out); };
// Test surface (test.js), same idea as net.js's netTest. `ducked` is the INTENT — the level a bed
// is at, or will start at if it is still loading — and it is the whole of what the menu-silence bug
// was: a flag nothing ever cleared. Nothing in the game reads this.
export const bedTest = { ducked };
let sysVoice = null;
function say(text, delay = 0) { // fallback when a recorded umpire line is missing
+28 -10
View File
@@ -121,10 +121,17 @@ export const TUNE = {
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
// was never a progression lever at all. g12 matches the old top speed (1.75
// m/s), and it climbs to 2.7 m/s by game 22.
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
// THE LATE GAME HAD STOPPED GETTING HARDER (Oliver, 2026-08-15: "it now gets too easy to just
// play through — the splatter gore should be unbelievably hard towards the end"). Every ramp in
// this block used to reach its ceiling by about game 25 and sit there, while the barrel economy
// above kept climbing — so a run got RICHER after game 25 and never more dangerous. The body
// COUNT still caps (zMaxCap is a readability ceiling on a 270 px screen and stays), but what
// those bodies are and how fast they arrive now keeps climbing well past it.
zSpeedMax: 3.6, // cap on that ramp (top speed = zSpeed * 4.6), reached ~game 28 instead of 22
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
// frozen at exactly 0.42 from game 12 to the end of the run.
nastyMax: 1.0, // ...and by game 34 there are NO ordinary shamblers left in the mix at all:
// every body that walks out is off the advanced roster. That is the wall the
// end of a run is supposed to be.
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
// purpose (that is the point of it), but hordeGrow means a late wave now tries
@@ -140,7 +147,11 @@ export const TUNE = {
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
// deal with, which is the target-priority decision the game was missing.
machEvery: 4, // games before the first machine can appear, and games between them after
machMax: 2, // machines allowed on court at once
machMax: 2, // machines allowed on court at once...
machMaxPer: 18, // ...plus one more for every this many games survived, up to machMaxCap. A third
machMaxCap: 4, // and fourth gun is the one kind of late pressure that does NOT crowd the
// ground: they hold position and shoot, so the court stays readable while the
// number of things that can reach you goes up.
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
@@ -427,11 +438,16 @@ export const TUNE = {
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 8, // ...and the gap SHRINKS by one for every this many games finished, floored at
// every game (g8 every 2, g16+ every game). Late runs live on the barrels: past
// the point where every tin is capped, fast tapping was the only way left to
// keep up, and "more inventory for strategic use" is the answer to that.
// HALVED, AND FLOORED (Oliver, 2026-08-15: "in splatter gore it now gets too easy to just play
// through, maybe we should reduce the barrels again — pretty early in the game every won game
// gives a barrel"). He is right and the arithmetic says so: at 3/8 the gap hit ONE at game 16 and
// stayed there, so the whole back half of a run handed you a weapon for every game you won, which
// is the opposite of a difficulty curve. The ramp is still there — the late game does need more
// inventory than the early one — it is just slower and it stops.
barrelEvery: 5, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 14, // ...and the gap SHRINKS by one for every this many games finished
barrelFloor: 3, // ...but never below this. g14 every 4, g28 every 3, and that is as good as it
// ever gets — a barrel is a reward for surviving, never an income.
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8)
@@ -671,7 +687,9 @@ export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', art: 'mode_gore' },
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
// promising one: the match already turned, you just arrived late
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
];
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
+52 -10
View File
@@ -535,6 +535,16 @@ export function cheer(seconds, vol) {
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
}
// Games between weapon barrels. Exported for the suite, because the shape of this curve IS the
// gore mode's economy and it is one line of arithmetic that once quietly became "one per game".
// The floor is the point: however long a run gets, a barrel stays something you waited for.
export const barrelGap = () => Math.max(TUNE.barrelFloor,
TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp));
// ...and how many ball machines the late court is allowed to hold at once. Same idea from the other
// end: the barrels stop growing, this one starts.
export const machCap = () => Math.min(TUNE.machMaxCap,
TUNE.machMax + Math.floor(G.runGames / TUNE.machMaxPer));
export function advancePoint(side) { // shared scoring math; returns whether a game was won
const i = side === 'P' ? 0 : 1, j = 1 - i;
score.pts[i]++;
@@ -574,7 +584,11 @@ export function pointTo(side, reason) { // exported for net.js: a remote verdict
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
cheer(2.8, 0.6);
score.server = other(score.server);
if (G.classic) { G.runGames++; if (classicGame(side)) return; } // ...then a set, then the match
if (G.classic) {
G.runGames++;
if (side === 'P') G.classicWon++; // the number its board ranks
if (classicGame(side)) return; // ...then a set, then the match
}
} else {
voice(scoreKey(), 0.7);
if (side === 'P') cheer(1.2, 0.3);
@@ -612,11 +626,15 @@ export function endClassic(won) {
ball.active = false; G.flash = null; G.modal = null; G.card = null;
if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); }
else { aww(1); voice('game_opponent', 0.3); }
// g = GAMES PLAYED, s = SETS YOU WON — this mode's two headline numbers in the record's two slots.
// g = GAMES PLAYED (what the stats screen calls LONGEST MATCH), s = GAMES YOU WON — this mode's
// two headline numbers in the record's two slots. `s` was SETS until the classic board arrived,
// and nothing ever displayed it: the sets line on the death screen is gore's, and read() has
// never looked at `s` at all. Games won is the number worth ranking, because every classic win is
// two sets and a board of nothing but 2s is a wall of ties.
// `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not,
// so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won,
// with no extra field to keep in step.
const b = endRun(G.runGames, G.sets[0], G.splatT, !won, MODE_CLASSIC);
const b = endRun(G.runGames, G.classicWon, G.splatT, !won, MODE_CLASSIC);
G.best = b; G.newBest = b.newBest;
}
@@ -671,12 +689,12 @@ export function splatPoint(side, quiet = false) {
spawnRacketZombie();
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
&& zombies.filter(z => z.variant === 'm').length < machCap() && !G.bossFight)
spawnMachine();
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
// because the late game is where the rack has to carry you. Still one at a time: two glowing
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0
if (side === 'P' && G.runGames % barrelGap() === 0
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
spawnBarrel();
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
@@ -924,16 +942,40 @@ function transition(dt) {
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => {
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
enterSplatter();
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
G.idleHintT = TUNE.pullHintAfter;
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
});
}
// WHERE THE SPLATTER MODE ACTUALLY BEGINS. Shared by the transition's last beat and by the gore
// mode's own start, so the two doors cannot land the player in two slightly different games.
// NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull
// instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after
// the usual beat of inaction, and it keeps coming back whenever the player stalls.
function enterSplatter() {
G.mode = 'splatter'; G.everTransitioned = true;
score.server = 'P'; setupPoint();
voice('play', 0.4, 0.9); zombieMusic();
G.idleHintT = TUNE.pullHintAfter;
}
// THE GORE MODE STARTS IN THE GORE (Oliver, 2026-08-15: "the splatter gore should not have the
// normal tennis intro anymore, we should just start right in splatter mode"). The prologue was a
// game of real tennis you had to win before the mode you picked would begin — fine as a reveal the
// first time, a toll booth every time after it. The transition itself is NOT deleted: it is still
// what `forceZombies` plays, it is still the cinematic, and `everTransitioned` above keeps it from
// firing behind us. What is gone is it standing between the menu and the mode.
// The turned opponent still opens the run — he is the man you came to play, and the mode's whole
// premise is that he is already one of them.
export function startGore() {
if (G.mode !== 'tennis') return;
enterSplatter();
spawnZombie(O.x, O.z, 'c'); G.hideO = true;
G.shake = { t: 0.6, mag: 7 };
G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 };
}
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
if (G.serveNum === 1) {
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
+42 -16
View File
@@ -5,7 +5,7 @@
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, 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, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
import { netConnect, netHooks } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js';
@@ -283,6 +283,12 @@ document.getElementById('brBack').addEventListener('click', () => {
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
const boardFor = m => m === 'zombie' || m === 'cup' ? stats.MODE_MATCH
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
@@ -291,9 +297,7 @@ function summary() {
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
// (see cupWon), so its summary reads the same book it writes.
const mode = m.id === 'zombie' || m.id === 'cup' ? stats.MODE_MATCH
: m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
const best = stats.read('all', undefined, mode).longest;
const best = stats.read('all', undefined, boardFor(m.id)).longest;
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
: best === 1 ? ' GAME' : ' GAMES';
const name = m.id === 'classic'
@@ -330,6 +334,7 @@ function start(modeId = pickedMode) {
G.classic = modeId === 'classic';
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'gore') startGore(); // no tennis prologue: the mode you picked is the mode you get
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
@@ -339,8 +344,7 @@ let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
// came to look at, and it saves a tap on the way in every single time
const openStats = () => {
stMode = pickedMode === 'zombie' || pickedMode === 'cup' ? stats.MODE_MATCH
: pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
stMode = boardFor(pickedMode);
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
@@ -348,17 +352,34 @@ const openStats = () => {
const goMode = () => { buildTops(); go('mode'); };
document.getElementById('mStart').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
// whose names you came to look at
let rkMode = stats.MODE_GORE;
const openRanks = () => {
rkMode = boardFor(pickedMode);
for (const el of document.getElementById('rkModes').children) el.classList.toggle('on', el.dataset.m === rkMode);
go('ranks'); stats.drawRanks(null, rkMode);
};
document.getElementById('mRanks').addEventListener('click', openRanks);
document.getElementById('rkModes').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
rkMode = b.dataset.m;
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
stats.drawRanks(null, rkMode);
});
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best kill
// count, on the tap that takes you off the death screen. Anything more often is a form standing
// between you and the next match.
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best on ITS
// OWN board (every mode has one now), on the tap that takes you off the death screen. Anything
// more often is a form standing between you and the next match.
const nbName = document.getElementById('nbName'), nbMsg = document.getElementById('nbMsg');
let nbRun = null;
function askName(run) {
nbRun = run;
const B = stats.BOARDS[run.mode] || stats.BOARDS[stats.MODE_GORE];
// the prompt names the BOARD's number, not the gore mode's — a tournament run that said
// "5 KILLS" would be describing something nobody was playing for
document.getElementById('nbScore').innerHTML =
`<b>${run.kills.toLocaleString('en-GB')}</b> KILLS · <b>${run.games}</b> GAME${run.games === 1 ? '' : 'S'}`;
`<b>${run.score.toLocaleString('en-GB')}</b> ${B.unit} · <b>${run.sub}</b> ${B.subUnit}`;
nbName.value = stats.savedName();
nbMsg.textContent = 'PUT IT ON THE WORLD BOARD.';
document.getElementById('nbSend').disabled = false;
@@ -376,10 +397,13 @@ document.getElementById('nbSend').addEventListener('click', async () => {
const btn = document.getElementById('nbSend');
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
try {
const r = await stats.submit(name, nbRun.kills, nbRun.games);
const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode);
rkMode = nbRun.mode; // ...and the board that opens is the one you just wrote your name on
for (const el of document.getElementById('rkModes').children)
el.classList.toggle('on', el.dataset.m === rkMode);
nbRun = null;
go('ranks');
stats.drawRanks(r.scores); // the board came back with the reply — no second round trip
stats.drawRanks(r.scores, rkMode); // the board came back with the reply — no second round trip
} catch (e) {
btn.disabled = false;
nbMsg.textContent = e.taken ? 'THAT NAME IS SPOKEN FOR. PICK ANOTHER.'
@@ -430,8 +454,8 @@ document.getElementById('hot').addEventListener('pointerdown', () => {
// This is laserScene's trick: force the turn and then step the transition out in one go, because
// the cinematic is ten seconds you did not come here to watch.
function intoSplatter() {
if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60);
startGore(); // the direct door. It used to force the turn and then step the whole 8-second
// cinematic out in 900 hidden updates — every sound it makes fired in one frame.
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;
zombies.length = 0; shots.length = 0; pellets.length = 0;
@@ -580,7 +604,9 @@ const maybeRestart = () => {
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
const run = stats.lastRun();
if (run && run.newKillBest && run.kills > 0) askName(run);
// ...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
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
+1 -1
View File
@@ -221,7 +221,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 129;
export const SPRV = 130;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
+3
View File
@@ -119,6 +119,9 @@ export const G = {
// a set, TUNE.classicSets sets is the match. It is the FOUNDATION mode, not a warm-up: if the
// core tennis is good, everything built on it is good.
classic: false,
classicWon: 0, // games YOU have taken off the CPU this classic match. G.sets is sets and
// score.games resets every set, so nothing else counted this — and it is what
// the classic world board ranks (a 12-2 win beats a 12-10 one).
done: null, // {won, t} — the run ENDED without a corpse (a classic match played out).
// The one end-of-run state that is not 'eaten': same summary, same gated tap.
bench: null, // THE SPRITE BENCH (back room only): {i, stage, dir} — one opponent on an
+59 -20
View File
@@ -95,6 +95,28 @@ export function multiKill() { run.mk++; }
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c';
const modeOf = r => r.m || MODE_GORE;
// ---- WHAT A RUN IS WORTH, per mode. The world board used to be the gore mode's alone, which made
// it "the leaderboard" for a game that is three games (Oliver, 2026-08-15: "the world rank is still
// about the splatter gore which is not the only thing"). There is now one board per mode, and this
// table is the only place that knows what each one ranks — the three do not share a currency (see
// the record-shape note in endRun), so a single board would be comparing a 300-kill massacre with a
// five-opponent ladder run and calling one of them better.
// score the number ranked, off a run record. Ties break on `sub`, then on age.
// unit what that number IS, printed on the board and in the name prompt
// sub the second column: small, and only ever context for the first
// The RECORD's slots are unchanged, which is why no migration is needed — each mode simply reads
// the slot it has always written: gore's kills, the match's opponents felled (banked in the games
// slot by matchGameWon), classic's games taken off the CPU.
export const BOARDS = {
[MODE_GORE]: { name: 'SPLATTER GORE', score: r => r.k || 0, sub: r => r.g || 0,
unit: 'KILLS', subUnit: 'G', head: 'KILLS · GAMES' },
[MODE_MATCH]: { name: 'ZOMBIE TENNIS', score: r => r.g || 0, sub: r => r.s || 0,
unit: 'FELLED', subUnit: 'G', head: 'FELLED · GAMES' },
[MODE_CLASSIC]: { name: 'CLASSIC', score: r => r.s || 0, sub: r => r.g || 0,
unit: 'GAMES WON', subUnit: 'PLAYED', head: 'WON · PLAYED' },
};
const boardOf = m => BOARDS[m] || BOARDS[MODE_GORE];
export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
const all = load();
const prev = all.filter(r => modeOf(r) === mode); // you are only ever compared against your own mode
@@ -102,22 +124,23 @@ export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
// the world board ranks — so it is the one that earns you a name-entry prompt.
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
const kills = run.k;
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
const newBest = games > best.games;
// THE WORLD BOARD IS THE GORE MODE'S. It ranks kills in a run, and a match run has exactly one
// kill per opponent felled — a five-body match would post a "5 kills" row next to a 300-kill
// massacre and mean something completely different by it. No name prompt from a match.
// ponytail: when zombie tennis earns a board of its own it ranks FELLED (with points won as the
// tiebreak — felled alone is a small integer and would be a wall of ties), and that is a server
// change, not this line.
const newKillBest = mode === MODE_GORE && kills > bestKills;
all.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
// EVERY MODE HAS A BOARD NOW, so every mode can earn the name prompt — against ITS OWN board's
// number and its own history (BOARDS above), never against the other two. `newKillBest` keeps its
// name because the death screen and main.askName both read it; what it means is "this run beat
// your best on the board it belongs to".
const row = { t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w };
const B = boardOf(mode);
const newKillBest = B.score(row) > prev.reduce((m, r) => Math.max(m, B.score(r)), 0);
all.push(row);
save(all); // ...the WHOLE record, not the filtered view — that would delete the other mode
run = bag(); banked = true;
last = { kills, games, newKillBest };
// ...and WHICH board it belongs on, plus the two numbers that board prints, so the name prompt
// and the submission never have to re-derive them from a record shape they do not own
last = { kills, games, newKillBest, mode, score: B.score(row), sub: B.sub(row) };
// `games`/`sets` here are THE BEST RUN'S, which is the only thing a caller wants back — the
// caller already knows what the run it just handed over did. They used to be trailed by the
// ENDED run's own `kills, games`, and the later key won: `G.best.games` was the run that had just
@@ -173,8 +196,8 @@ function myId() {
let last = null;
export function lastRun() { const r = last; last = null; return r; }
export async function board() {
const r = await fetch('api/scores', { cache: 'no-store' });
export async function board(mode = MODE_GORE) {
const r = await fetch('api/scores?m=' + mode, { cache: 'no-store' });
if (!r.ok) throw new Error('board ' + r.status);
return (await r.json()).scores || [];
}
@@ -186,13 +209,15 @@ export async function board() {
export const tidyName = s =>
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
export async function submit(rawName, kills, games) {
// `k` is the BOARD's number and `g` its second column — what those mean is the mode's business
// (BOARDS), and the server only ever sorts them. `m` is which board the row goes on.
export async function submit(rawName, k, g, mode = MODE_GORE) {
const name = tidyName(rawName);
if (!name) throw new Error('empty name');
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
const r = await fetch('api/scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
if (!r.ok) throw new Error('submit ' + r.status);
return r.json(); // {ok, rank, scores}
@@ -276,18 +301,23 @@ export function drawStats(period, v, mode = MODE_GORE) {
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
// the server being down must never be indistinguishable from nobody having played.
export async function drawRanks(rows) {
const el = document.getElementById('rkList');
// THREE BOARDS, one screen, same furniture as the stats screen's three modes — and the column
// heading changes with the tab, because "300" means bodies here, opponents there and games in the
// third and a board that did not say so would be lying by omission.
export async function drawRanks(rows, mode = MODE_GORE) {
const el = document.getElementById('rkList'), B = boardOf(mode);
const hdr = document.getElementById('rkHdr');
if (hdr) hdr.innerHTML = `BEST SINGLE RUN<span>${B.head}</span>`;
if (!rows) {
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
try { rows = await board(); }
try { rows = await board(mode); }
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
}
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THIS BOARD YET. BE FIRST.</p>'; return; }
const me = savedName();
el.innerHTML = rows.map((r, i) =>
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
+ `<i>${n(r.k || 0)} · ${r.g || 0}</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
@@ -329,5 +359,14 @@ export function check() {
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
// ONE BOARD PER MODE. Each ranks its own currency off the slot its own runs already write, so a
// ladder run and a massacre are never sorted against each other. The fixture above carries three
// gore runs and one match run; the match run is 4 opponents felled over 9 games.
const B = BOARDS;
eq(B.g.score(fx[0]), 10, 'gore ranks kills');
eq(B.z.score(fx[3]), 4, 'zombie tennis ranks opponents felled');
eq(B.z.sub(fx[3]), 9, '...with the games it took as the tiebreak');
eq(B.c.score({ g: 15, s: 12 }), 12, 'classic ranks the games you took off the CPU');
eq(Object.keys(B).sort().join(''), 'cgz', 'three boards, no more and no fewer');
return 'ok';
}
+69 -4
View File
@@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap } = 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 { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -95,7 +95,9 @@ const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const cfg = await import('./src/config.js');
const { TUNE, bossArt, COURT, racketStage } = cfg;
const { read: readStats, check: statsCheck } = await import('./src/stats.js');
const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js');
const { read: readStats, check: statsCheck } = statsMod;
const DT = 1 / 60;
function freshSplatter() {
@@ -849,6 +851,66 @@ test('an ordinary far winner keeps its ordinary ending, and the match plays on (
assert.equal(score.pts[0], pts + 2, 'the match plays on');
});
test('the menus own the theme, and hand the court back its silence', () => {
// Oliver, 2026-08-15: "after some games — or returning to the main menu via the close button —
// the background music does not play any more, but you sometimes still hear the court background
// noise." loopBed no-ops once a file is playing, so menuMusic() could only ever START the theme,
// never bring it BACK from the duck start() put it under — and nothing ducked the court's own
// beds at all. The menu was silent and noisy at once, permanently, from the first run onward.
const d = audio.bedTest.ducked, THEME = 'background-music.m4a',
AMB = 'tennis-ambient-loop.wav', ZOM = 'zombie-mode.m4a';
audio.menuMusic(); // on the menu
assert.ok(!d.has(THEME), 'the theme plays on the menu');
audio.menuDuck(true); audio.ambient(); audio.zombieMusic(); // ...a run starts
assert.ok(d.has(THEME), 'the theme hands the court over');
assert.ok(!d.has(AMB) && !d.has(ZOM), 'and the court gets its own beds');
audio.menuMusic(); // ...and the run ends, however it ended
assert.ok(!d.has(THEME), 'the theme is BACK — this is the bug');
assert.ok(d.has(AMB) && d.has(ZOM), '...and the court does not follow you into the menu');
audio.ambient(); audio.zombieMusic(); // ...and the NEXT run is not played in silence
assert.ok(!d.has(AMB) && !d.has(ZOM), 'a second run sounds like the first');
audio.menuMusic();
});
test('SPLATTER GORE opens in the gore — no tennis prologue to win first', () => {
// Oliver, 2026-08-15: "it should not have the normal tennis intro anymore, we should just start
// right in splatter mode." The transition is not deleted (forceZombies still plays it) — what is
// gone is it standing between the menu and the mode.
restartRun();
assert.equal(G.mode, 'tennis', 'a fresh run starts where every run starts');
startGore();
assert.equal(G.mode, 'splatter', 'the mode you picked is the mode you get');
assert.ok(G.everTransitioned, '...and the prologue can never fire behind it');
assert.ok(zombies.some(z => z.variant === 'c'), 'the man you came to play is already one of them');
assert.ok(G.hideO, 'so the tennis opponent is gone from the far court');
assert.equal(score.server, 'P', 'and the serve is yours');
startGore();
assert.equal(zombies.filter(z => z.variant === 'c').length, 1, 'a second call is a no-op, not a second corpse');
});
test('the gore economy: barrels stop being income, and the late court keeps getting worse', () => {
// "in splatter gore it now gets too easy to just play through... maybe we should reduce the
// barrels again — pretty early in the game every won game gives a barrel." At 3/8 the gap hit ONE
// at game 16 and stayed, so the back half of a run paid a weapon per game won. The floor is the
// fix, and it is the property worth pinning: a barrel is a reward, never an income.
const gapAt = g => { G.runGames = g; return barrelGap(); };
for (const g of [0, 5, 12, 20, 40, 120])
assert.ok(gapAt(g) >= 2, `game ${g}: a barrel is never one-per-game (gap ${gapAt(g)})`);
assert.ok(gapAt(40) <= gapAt(0), 'the late game still earns them faster than the early one');
assert.equal(gapAt(400), TUNE.barrelFloor, '...down to the floor and no further');
// ...and the pressure keeps climbing where the barrels stop
const capAt = g => { G.runGames = g; return machCap(); };
assert.equal(capAt(0), TUNE.machMax, 'game one gets the old two guns');
assert.ok(capAt(40) > capAt(0), 'a long run faces more of them');
assert.equal(capAt(400), TUNE.machMaxCap, '...to a ceiling, because the court still has to read');
// the two ramps that used to stop dead around game 25 now run past it
assert.ok(1 + Math.min(TUNE.zSpeedMax, 30 * TUNE.zSpeedPerGame)
> 1 + Math.min(TUNE.zSpeedMax, 22 * TUNE.zSpeedPerGame), 'shamblers keep gaining speed past game 22');
assert.equal(Math.min(TUNE.nastyMax, 40 * TUNE.nastyPerGame), 1,
'and by the end every body that walks out is off the advanced roster');
G.runGames = 0;
});
test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
@@ -1148,7 +1210,7 @@ test('the toss strike has stakes: flush is fast, a miss is slow, the old free wi
test('classic tennis: the second set ends the run, with no corpse', () => {
G.mode = 'tennis'; G.classic = true;
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14;
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14; G.classicWon = 12;
assert.equal(classicGame('P'), true, 'the match is over');
assert.ok(G.done && G.done.won, 'and you won it');
assert.notEqual(G.mode, 'eaten', 'nobody died — this is the other ending');
@@ -1156,7 +1218,10 @@ test('classic tennis: the second set ends the run, with no corpse', () => {
const r = runs[runs.length - 1];
assert.equal(r.m, 'c', 'banked as a classic run');
assert.equal(r.dead, 0, 'a match WON is not a death — that is how wins are counted');
assert.equal(r.s, 2, 'sets won ride in the second headline slot');
// GAMES YOU WON, not sets: it is what classic's world board ranks, because every win is two sets
// and a board of nothing but 2s is a wall of ties. Nothing ever displayed the old value.
assert.equal(r.s, 12, 'games won ride in the second headline slot');
assert.equal(statsMod.BOARDS.c.score(r), 12, "...and that is the classic board's number");
});
test('classic tennis: losing the match ends it the same way, and counts as a loss', () => {