The menu text reduction: the design handoff, built

Sixty words on the wall became a dozen. The handoff's own summary of the rule:
Jersey 15 NAMES (screen titles, rooms, cards), Bangers CALLS exactly once per
screen (the fixture, the primary button, a modal headline), Silkscreen INFORMS
(numbers, labels, chips) — and a sentence survives only where it carries a
decision. Everything else moved into a bar, a pip or the modal that opens with
the mode.

  · Jersey 15 bundled subset (9 KB) and Bungee retired after one afternoon
  · SPLASH: the plaque is one row, the goal line is gone (the fixture button
    below said it already), the two doors carry a number each
  · PICK A GAME: rooms are one word, posters are their names, the fixture is a
    hero with the only second line — the whole wall fits one screen now
  · MATCH SETUP: unnumbered blocks, the meters' legend once instead of a
    description per court, no summary footer
  · DIFFICULTY: three pips instead of three sentences
  · TRAINING: the revanche is a full-width row, rungs are numbers, the list
    ends on the league that opens the rest of the ladder
  · VS: three paragraphs became YOU / HOST / JOIN
  · MODALS: one rule per row (the sub was the lead again in other words), the
    blood pool as pips on that same line, and the walk-on's two levels in ONE
    face at ONE size — a comparison drawn at two sizes is a headline with a
    footnote (Oliver)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-31 14:41:25 +02:00
1 parent 4c0ede33db
commit a331a1ae37
44 files changed
+3840 -187

No files matched your search

Binary file not shown.
@@ -1,4 +1,4 @@
Copyright 2023 The Bungee Project Authors (https://github.com/djrrb/Bungee) Copyright 2023 The Soft Type Project Authors (https://github.com/scfried/soft-type-jersey)
This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: This license is copied below, and is also available with a FAQ at:
Binary file not shown.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,242 @@
# Handoff: Menüführung — Text reduzieren (Splatter Tennis)
## Overview
Die funktionalen Screens von Splatter Tennis (Splash, PICK A GAME, MATCH SETUP, DIFFICULTY,
TRAINING, VS A FRIEND und die In-Game-Modals) tragen heute mehr Text als eine Entscheidung
braucht — auf `PICK A GAME` rund 60 Wörter, verteilt auf drei Rubriken mit Straps plus sechs
Poster mit je einer Regelzeile. Das Ergebnis dieser Session: eine reduzierte Fassung aller
funktionalen Screens (~12–18 Wörter auf der Wand) und ein Wechsel der aufrechten Display-Schrift
von **Bungee** auf **Jersey 15**.
Gameplay, Stats-Screens und Canvas-HUD sind bewusst NICHT Teil dieses Pakets.
## About the Design Files
`Menues.dc.html` ist eine **Design-Referenz in HTML** — ein Prototyp, der Aussehen und Struktur
zeigt, kein Produktionscode zum Kopieren. Splatter Tennis ist Vanilla JS ohne Build-Step
(`CLAUDE.md`, Punkt 5: „Zero dependencies, no build step, no framework"), also wird die Fassung
in der bestehenden Umgebung nachgebaut: DOM-Menüs in `index.html` + `src/main.js`, Canvas-Modals
in `src/render.js`, Texte in `src/config.js`. Kein neues Framework, keine neuen Dateien ausser
der Schriftdatei.
Die Datei enthält mehrere Runden untereinander, neueste oben. Verbindlich ist **Runde 5**
(„GESETZT — ALLE SCREENS IN JERSEY 15"). Darunter liegen als Historie: Runde 4 (Schrift-Kandidaten
Bebas Neue / Oswald / Jersey 15), Runde 3a (derselbe Satz in Bebas), Runde 2 (Bungee vs. Bangers),
Runde 1 (die zwei Grundrichtungen) und ganz unten der **IST-Stand**, pixelgenau aus dem heutigen
`index.html` nachgebaut — nützlich als Vor/Nach-Vergleich.
## Fidelity
**High-fidelity.** Palette, Masse, Schriftgrössen und Copy sind aus dem Repo übernommen
(`index.html` CSS, `config.MODES` / `MENU` / `COURTS` / `LEAGUES` / `OPPONENTS`, `beats.js`
Modal-Payloads). Alle Sprites/Poster im Prototyp sind echte Repo-Assets. Übernahme darf 1:1
erfolgen.
## Die drei Register (die eigentliche Entscheidung)
| Rolle | heute | neu |
|---|---|---|
| benennen — Screen-Titel, Rubriken, Karten- und Poster-Namen | Bungee | **Jersey 15** |
| rufen — einmal pro Screen: Fixture, Primär-Button, Modal-Headline | Bangers | Bangers (unverändert) |
| informieren — Zahlen, Labels, Chips | Silkscreen | Silkscreen (unverändert) |
Begründung Bungee → Jersey 15: Bungee ist Single-Weight und sehr fett/blockig; Jersey 15 sitzt
auf demselben Pixelraster wie Silkscreen (wirkt als Schriftfamilie mit der Sprite-Art), läuft
proportional und deutlich leichter. Beide sind SIL OFL.
**Grössen-Umrechnung Bungee → Jersey 15: ca. +40 %** (Jersey sitzt tiefer im Kasten und läuft
leichter). Konkrete Werte im Abschnitt „Design Tokens".
## Schrift einbauen
1. `Jersey15-Regular.ttf` von Google Fonts holen, wie Bungee subsetten (`pyftsubset`, Befehl
steht in `PROJECT.md`) → `assets/fonts/Jersey15-Subset.ttf`.
2. In `index.html` das `@font-face` von Bungee ersetzen (`?v=` mitziehen wie bei den anderen drei):
```css
@font-face { font-family: 'Jersey 15'; font-weight: 400; font-display: block;
src: url('assets/fonts/Jersey15-Subset.ttf?v=87') format('truetype'); }
```
3. Alle `Bungee`-Vorkommen in `index.html` auf `'Jersey 15'` umstellen (Liste unten),
`Bungee-Subset.ttf` und `Bungee-OFL.txt` löschen, `Jersey15-OFL.txt` daneben legen.
4. `main.js?v=N` in `index.html` bumpen (`CLAUDE.md`, Punkt 4).
## Screens / Views
### 1 — Splash (`#splash` in index.html)
**Zweck:** Eintritt; die Plakette zeigt wer du bist, der Primär-Button das nächste Fixture.
Änderungen:
- **Plakette (`#plaque`)** wird eine Zeile statt vier Grid-Areas: Wappen links (50px), rechts
Name + `LEVEL 12` in einer Baseline-Zeile, darunter der XP-Balken mit der Ziel-Zahl rechts.
**`#plGoal` entfällt komplett** — „WIN THE CUP TO REACH LEAGUE 4" sagt der Fixture-Button
darunter schon. Grid-Areas `name/lv/bar/goal` werden zu einem Flex-Row mit zwei Spalten.
- Name: Jersey 15 21px, `#ccd7ca`, `letter-spacing:0.5px` (war Silkscreen 700 13px)
- `LEVEL 12`: Silkscreen 700 10px, `#bcd455` (war Bangers 26px als `#plLv` — der Ruf gehört
dem Button unten, nicht der Plakette)
- Balken unverändert (6px, `#2c4238` Rahmen, Gradient `#3f9560 → #bcd455 55% → #e8b23a`)
- **Fixture-Button (`#mStart`)** bleibt Bangers 26px; die zweite Zeile (`#msLine`) verliert das
Rundenpräfix: `ROUND ONE: THE STRAWBERRY SELLER` statt
`4 ROUNDS · ROUND ONE: THE STRAWBERRY SELLER`. → in `config.cupFixture` die `line` kürzen
(`rounds.length + ' ROUNDS · '` fällt weg; die Rundenzahl steht im Modal).
- **`#mOther`** verliert seinen Strap (`<i>CLIMB · TRAINING · GORE · A FRIEND</i>` löschen),
Label auf Jersey 15 19px.
- **`#mStats` / `#mRanks`**: Straps ersetzt durch je eine Zahl aus lokalem Stand —
`STATS / 4 812` (Kill-Count, wie heute) und `BOARD / BEST 612` (eigener Bestwert; **kein**
Weltrang, der Splash lädt `/data/scores.json` nicht). Label Jersey 15 16px, Zahl
Silkscreen 700 10px `#bcd455`.
- Logo, Slogan (`Fifteen. Thirty. Forty. Dead.`), `#build` unverändert.
### 2 — PICK A GAME (`#stmode` / `#modeList`, gebaut in `main.buildTops`)
**Zweck:** einen Modus wählen. Das ist der Screen, der „erschlägt".
- **Rubriken** (`#modeList .mgroup > header`) verlieren ihren Strap (`<i>`) und werden ein Wort:
`THE SEASON → SEASON`, `THE TRAINING → TRAINING`, `OUT OF COMPETITION → CASUAL`.
Jersey 15 17px, `#bcd455`, `letter-spacing:2px`. → `config.MENU`: `sub` der drei `group`-Rows
löschen, `group`-Text kürzen; in `main.buildTops` das `<i>` aus dem `header`-Template nehmen.
- **Poster** tragen nur noch den Namen — `<i>${card.sub}</i>` fällt in `main.banner()` weg.
Damit werden die `sub`-Felder in `config.MODES` für die Wand irrelevant (Aufbewahren lohnt:
die Modals nutzen dieselben Sätze).
- **Hero-Poster** (das Fixture, `mode:'cup'`) ist der einzige mit einer zweiten Zeile und dem
einzigen Ruf: Name in **Bangers 32px**, darunter die Fixture-Zeile in Silkscreen 10px
`#f0cfc4`. Höhe 142px. `.way`-Ribbon („THE WAY UP") unverändert.
- **Übrige Poster** in Jersey 15, Höhe gestaffelt statt einheitlich 148px:
- TRAINING-Raum: zwei Poster **nebeneinander**, je `flex:1`, Höhe 106px, Name 27px
- CASUAL-Raum: drei Poster gestapelt, Höhe 84px, Name 30px
- **Regeln, die wirklich anders sind, bleiben als Chip** (`.last`-Optik, Silkscreen 8px, acid,
`rgba(0,0,0,0.72)` Grund): `18 RUNGS` auf TRAINING, `LAST` auf dem zuletzt gespielten.
- Ergebnis: der ganze Wall passt in 844px (heute zwei Bildschirme). Gerechnet mit
Header 64 + Padding 14 + 142/106/84 + Gaps 17/8.
### 3 — MATCH SETUP (`#setup`)
- `<h3 id="s01">01 · PLAYER</h3>` / `02 · COURT` → Rubrik-Zeile wie auf der Wand:
Jersey 15 17px acid, Text `PLAYER` / `COURT`. Die Nummerierung entfällt (es sind zwei Blöcke
auf einem Screen, keine Schritte).
- Über der Platzliste steht die Legende **einmal**: rechts in der Rubrik-Zeile
`BOUNCE · PACE` (Silkscreen 8px, dim).
- **Platz-Zeilen** (`#courtList button.court`) verlieren `<i>${court.sub}</i>` — Name
(Jersey 15 19px) plus die zwei Meter, die dasselbe wortlos sagen. Bildhöhe 66 → 60px.
- **Gesperrte Plätze** (`.court.lock`) behalten Bild und Namen, statt einer Erklärung nur
`LEAGUE 4` in `#cf5a4e`, Silkscreen 9px.
- **Tour-Tiles**: `MEN'S SINGLES` / `WOMEN'S SINGLES` → `MEN'S` / `WOMEN'S`, Jersey 15 16px.
- **`#sSum`** (die Zusammenfassungszeile über SERVE UP) entfällt — alle drei genannten Werte
sind auf dem Screen sichtbar markiert.
- `SERVE UP` bleibt Bangers 26px auf `#a4231f`.
### 4 — DIFFICULTY (`#stsub` / `#modeSub button.chip`, aus `config.DIFFS`)
- Chip wird zweispaltig: Name links (Jersey 15 30px), **drei Pips** rechts (je 10×10px, kein
Radius) — 1/2/3 gefüllt. EASY/MEDIUM acid `#bcd455`, HARD `#cf5a4e`; leere Pips
`rgba(255,255,255,0.12)`.
- Kein Erklärtext. Padding 26px/16px, `gap:10px`.
- `LAST`-Chip bleibt oben rechts im gewählten.
### 5 — TRAINING (`#train` / `#trainList`, gebaut in `main.buildTrain`)
- `<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>` entfällt — der Screen-Titel
`TRAINING` genügt.
- **Revanche-Tile** (`profile.prof.out`) wird eine **breite Zeile über dem Raster**: Kopf 52px
links, Name (Jersey 15 24px), darunter `PUT YOU OUT` (statt `PUT YOU OUT OF THE CUP`) in
`#e08a7c`, Chevron rechts. Rahmen `#a4231f`, Grund `rgba(164,35,31,0.16)`.
- **Übrige Rungs** bleiben das 2-spaltige Raster: Kopf 44px, Name Jersey 15 17px zentriert,
Rangnummer zweistellig (`01`, `02`, …) statt `No. 1` — Silkscreen 8px dim.
- **Letzte Kachel** ist ein Ziel, keine Liste: gestrichelter Rahmen `#2c4238`, `LEAGUE 4` und
`13 — 15` in `#4a5a52`. (Heute endet die Liste einfach am Band-Cap.)
### 6 — VS A FRIEND (`#vs` / `#vsPick`)
- Die drei `<h3>` mit Straps (`01 · YOUR NAME / ON THEIR SCOREBOARD`, `02 · HOST A MATCH /
THEY COME TO YOU`, `OR JOIN THEIRS / WITH THEIR CODE`) werden drei Wörter:
**`YOU` / `HOST` / `JOIN`**, Jersey 15 17px acid.
- Host-Buttons: `CLASSIC TENNIS` / `DEATH MATCH` → `CLASSIC` / `DEATH MATCH` nebeneinander,
Jersey 15 19px, Padding 20px.
- Join-Zeile: Code-Feld plus Button `GO` (statt `JOIN` — daneben steht die Rubrik JOIN),
Feld-Letterspacing 6px.
- `#vsErr`-Meldungen unverändert (`THE CODE IS FOUR LETTERS`).
### 7 — In-Game-Modals (`render.drawModal`, Payloads aus `beats.js`)
Die Box-Sprache aus dem 2026-08-31-Pass bleibt (`MODAL_PAD 14`, `MODAL_GAP 13`, `MODAL_BTN 30`,
`MODAL_FOOT 8`, Breite `min(W-44, 244)`, Fill `rgba(8,22,16,0.93)`, Edge `rgba(120,150,130,0.45)`,
Rules `rgba(120,150,130,0.22)`). Reduziert wird der **Inhalt einer Row**:
- **Eine Regel pro Row, keine Unterzeile.** Heute rendert `modalRows` pro `m.rules`-Eintrag
`lead` + `sub`; der `sub` formuliert in fast allen Fällen den `lead` nur um
(`YOUR LEAGUE, ROUND BY ROUND` / `WIN IT ALL — MOVE UP A LEAGUE`). Neu: **eine** Zeile, die
das Ergebnis nennt — `WIN IT ALL — MOVE UP A LEAGUE`. Also in `beats.js` die `rules`-Arrays
auf je einen String reduzieren und `modalRows` entsprechend vereinfachen; `m.body` bleibt als
Fallback unverändert.
- **Blut ist Pips, kein Satz**: Row `POINTS DRAW BLOOD` (gekürzt von `ITS POINTS DRAW BLOOD`)
plus `TUNE.matchPlayerHp` Pips **in derselben Zeile rechts**, statt Zeile + Unterzeile
(`AT ZERO YOU ARE EATEN`) + Pips darunter.
- **Masthead**: Titel Bangers 27px in der Modal-Farbe, darunter EINE Silkscreen-10px-Zeile.
Beim Rules-Modal ist das die Sachzeile (`LEAGUE 3 · 4 ROUNDS`) — hier landet die Rundenzahl,
die aus `cupFixture.line` genommen wurde. Beim Walk-on bleibt es `foe.line`
(`CREAM WITH THAT?`).
- **Walk-on** (Gegner): Kopf-Sprite 56px, Name, `foe.line`, dann EINE Row mit den zwei Leveln
gegenüber (`LEVEL 14` hot links, `YOURS 12` dim rechts) — statt zwei Textzeilen plus
Split-Figur.
- Footer-Button unverändert (`WALK ON` / `GOT IT` / `PLAY ON`, `m.btn` überschreibt).
### 8 — Quit-Modal (`#qAsk` / `.qBox`)
Unverändert übernehmen — es ist schon lean. Nur `LEAVE THE MATCH?` von Silkscreen 12px auf
**Bangers 20px** (es ist eine Frage, die ruft), `PLAY ON` auf Jersey 15 17px.
## Interactions & Behavior
Keine Änderung an Navigation oder Zustandslogik. Alles Bestehende bleibt:
- `go(stage)` / `#intro[data-stage]`, ein Overlay, vier Panels
- Tap auf ein Poster antwortet UND geht weiter (`main.buildTops`), `pickMode` schreibt
`localStorage.mode`
- Die Deal-in-Animation (`#intro.deal`, `dealIn` 0.28s `cubic-bezier(0.34,1.56,0.64,1)`,
Delays 0.02/0.09/0.16/0.23/0.30s, `.mgroup`-Staffel 0.09/0.17/0.25s) gilt weiter — die
Rubriken bleiben `<section class="mgroup">`, damit sie greift
- `prefers-reduced-motion: reduce` schaltet sie weiter ab
- Modal-Dismiss-Regeln unverändert (`m.in < 1` schluckt die Gesture, Tap überall entlässt,
`G.seen.<key>` gated)
- Swipe-links-zurück im Shell bleibt unberührt
## State Management
Nichts Neues. Zwei Werte werden neu GELESEN, beide existieren schon:
- `profile.prof.out` (der Rung, der dich aus dem Cup geworfen hat) → Revanche-Zeile; wird
bereits von `buildTrain` genutzt
- der eigene Bestwert für die BOARD-Kachel → aus `stats` (lokal), **nicht** vom Server
## Design Tokens
Unverändert aus `index.html`:
```
--ink #0e1714 --panel rgba(42,62,54,0.42) --line #2c4238
--acid #bcd455 --cream #ccd7ca --dim #6c7f74
--red #a4231f --bad #cf5a4e --solid #16211d
Poster-Veil: linear-gradient(0deg, rgba(6,10,8,0.94) 0%, rgba(6,10,8,0.86) 22%,
rgba(6,10,8,0.5) 58%, rgba(6,10,8,0) 100%) — Höhe 52–62 % je Kachelhöhe
Modal: fill rgba(8,22,16,0.93) · edge rgba(120,150,130,0.45) · rule rgba(120,150,130,0.22)
Rahmen: 2px überall in den Menüs, 1px im Canvas-Modal
```
Typografie neu (Bungee-Wert → Jersey-15-Wert):
```
Screen-Titel (.hdr h2) 23px → 33px/1 letter-spacing 1px
Rubrik / Raum-Label 15px → 17px letter-spacing 2px
Poster-Name, Hero — → Bangers 32px/0.95 (der eine Ruf)
Poster-Name, TRAINING-Paar 17px → 27px/1
Poster-Name, CASUAL-Stapel 19px → 30px/1
Karten-/Platz-Name 15px → 22px
Difficulty-Chip 22px → 30px
Training-Tile-Name 12px → 17px/1
Sekundär-Button 13px → 18px
Zahlen, Labels, Chips Silkscreen 8–10px (unverändert)
Primär-Button Bangers 26px (unverändert)
```
## Assets
Alle Assets im Prototyp stammen aus dem Repo, unverändert — nichts muss neu gezeichnet werden:
`assets/logo.png`, `menu_bg.png`, `mode_cup|arcade|train|gore|classic|vs.png`,
`court_clay|hard|grass|gloom.png`, `badge_l3.png`, `z_{sh,bb,sp,gk,rm,st,ma,ru,sb,sg,lj,cu}head.png`,
`fonts/{Silkscreen-Regular,Silkscreen-Bold,Bangers-Regular}.ttf`.
NEU nötig: `fonts/Jersey15-Subset.ttf` (Google Fonts, SIL OFL) — ersetzt `Bungee-Subset.ttf`.
## Files
- `Menues.dc.html` — der Prototyp. Runde 5 oben ist verbindlich; darunter Runde 4 (Schriftwahl),
3a (Bebas-Fassung), 2 (Bungee vs. Bangers), 1 (Grundrichtungen), IST-Stand.
- `support.js` — Runtime des Prototyps, für die Implementierung irrelevant.
- `assets/` — die Repo-Assets, die der Prototyp lädt.
Betroffene Repo-Dateien: `index.html` (CSS + Markup der Panels), `src/main.js`
(`banner`, `buildTops`, `buildSub`, `buildTrain`, `refreshSplash`), `src/config.js`
(`MENU`-Gruppen, `cupFixture.line`, ggf. `MODES.sub`), `src/render.js` (`modalRows`,
`modalLayout`), `src/beats.js` (die `rules`-Payloads).
## Vor dem Commit
`node test.js` muss grün sein (`CLAUDE.md`, Punkt 2) — die Fixture-Deskriptor-Tests hängen an
`cupFixture`, also die `line`-Kürzung dort mit anpassen. `main.js?v=N` bumpen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because it is too large. Load diff
+116 -64
View File
@@ -47,13 +47,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
@font-face { font-family: 'Bangers'; font-weight: 400; font-display: block; @font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
src: url('assets/fonts/Bangers-Regular.ttf?v=86') format('truetype'); } src: url('assets/fonts/Bangers-Regular.ttf?v=86') format('truetype'); }
/* ...and the UPRIGHT display face (2026-08-31). Bangers is drawn slanted — there is no upright /* ...and the UPRIGHT display face (2026-08-31). Bangers is drawn slanted — there is no upright
cut of it, which is also why the chevrons are Silkscreen — so the shelf labels that wanted to cut of it, which is also why the chevrons are Silkscreen — so everything that NAMES rather
shout without leaning needed a third voice. Bungee is an arcade/signage face, upper case by than announces needed a third voice. Bungee held it for an afternoon; JERSEY 15 took it in
design, and it stays in-world where a system sans did not. SIL OFL, licence beside it. the menu-text-reduction handoff, because it sits on the same pixel grid as Silkscreen (it
SUBSET to the characters this game actually sets (ASCII plus the few marks the copy uses): reads as one family with the sprite art) and runs proportionally lighter, where Bungee was
10 KB instead of 119 — pyftsubset, and the command is in PROJECT.md if it ever needs redoing. */ single-weight and blocky. SIL OFL, licence beside it, SUBSET to the characters this game
@font-face { font-family: 'Bungee'; font-weight: 400; font-display: block; actually sets — 9 KB; the pyftsubset command is in PROJECT.md. Sizes run ~+40% against the
src: url('assets/fonts/Bungee-Subset.ttf?v=86') format('truetype'); } Bungee values: Jersey sits lower in its box. */
@font-face { font-family: 'Jersey 15'; font-weight: 400; font-display: block;
src: url('assets/fonts/Jersey15-Subset.ttf?v=87') format('truetype'); }
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is /* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
laid out in the root's own box — and with the canvas position:fixed the root has no content and laid out in the root's own box — and with the canvas position:fixed the root has no content and
collapses, so the gradient render.js sets had nowhere to go. */ collapses, so the gradient render.js sets had nowhere to go. */
@@ -121,7 +123,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#quit[hidden], #qAsk[hidden] { display: none; } #quit[hidden], #qAsk[hidden] { display: none; }
.qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px; .qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px;
width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; } width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; }
.qBox p { margin: 0 0 6px; font: 400 12px Silkscreen, monospace; color: var(--cream); /* it is a QUESTION, and a question calls — the one Bangers line on this overlay */
.qBox p { margin: 0 0 10px; font: 400 20px Bangers, cursive; color: var(--cream);
text-align: center; letter-spacing: 1px; } text-align: center; letter-spacing: 1px; }
@media (pointer: coarse) { #hint { display: none; } } @media (pointer: coarse) { #hint { display: none; } }
/* ...and it is UI like any other: gone while the splash is still loading (the keyboard hints /* ...and it is UI like any other: gone while the splash is still loading (the keyboard hints
@@ -236,14 +239,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* THREE REGISTERS NOW (2026-08-31). Bangers still ANNOUNCES — the primary button, PROMOTED., /* THREE REGISTERS NOW (2026-08-31). Bangers still ANNOUNCES — the primary button, PROMOTED.,
NEW PERSONAL BEST, and everything the canvas shouts (SET, CHAPTER, EPIC!). Silkscreen is NEW PERSONAL BEST, and everything the canvas shouts (SET, CHAPTER, EPIC!). Silkscreen is
still the HUD: numbers, labels, anything you read at leisure. And the SCREEN TITLES moved to still the HUD: numbers, labels, anything you read at leisure. And the SCREEN TITLES moved to
Bungee: upright signage, which is what the strip at the top of a screen actually is — you are Jersey 15: upright signage, which is what the strip at the top of a screen actually is — you
not being shouted at, you are being told which room you are in. */ are not being shouted at, you are being told which room you are in. */
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; } #intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
/* #intro h2 above carries an id, so this needs one too or the announcement face wins */ /* #intro h2 above carries an id, so this needs one too or the announcement face wins */
#intro .hdr h2 { font: 400 23px/1.2 Bungee, Bangers, cursive; letter-spacing: 0.5px; } #intro .hdr h2 { font: 400 33px/1 'Jersey 15', Bangers, cursive; letter-spacing: 1px; }
#intro h3 { display: flex; justify-content: space-between; margin: 20px 0 9px; /* A RUBRIC IS ONE WORD (2026-08-31, the text reduction), in the face that names rooms — and any
font: 400 10px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; hint it carries is a chip on the right, not a second sentence. */
text-align: left; } #intro h3 span { font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
#intro h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 20px 0 9px;
font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; color: var(--acid);
letter-spacing: 2px; text-align: left; }
/* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */ /* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */
.hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px; .hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px;
padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line); padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line);
@@ -284,8 +290,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; } font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; }
.two { display: flex; gap: 8px; } .two { display: flex; gap: 8px; }
.two .btn { margin-bottom: 0; } .two .btn { margin-bottom: 0; }
.btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace; /* the secondary doors NAME (Jersey) and carry a NUMBER (Silkscreen, acid) — the text reduction
font-style: normal; color: var(--dim); letter-spacing: 0.5px; } turned "RANKINGS / WORLD BOARD" into "BOARD / BEST 612": a label and a fact, no description */
.btn.sec { font: 400 18px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
#qNo { font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
.btn.sec i { display: block; margin-top: 3px; font: 700 10px Silkscreen, monospace;
font-style: normal; color: var(--acid); letter-spacing: 1px; }
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap /* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */ and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
#who, #who2 { display: flex; gap: 12px; align-items: flex-start; } #who, #who2 { display: flex; gap: 12px; align-items: flex-start; }
@@ -396,32 +406,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
crest on the left, name over the big LEVEL · LEAGUE line beside it, the chevron on the crest on the left, name over the big LEVEL · LEAGUE line beside it, the chevron on the
right; the bar runs the full width below and the goal line closes left-aligned. Translucent right; the bar runs the full width below and the goal line closes left-aligned. Translucent
surface, generous rows — the splash art, blood pool included, stays visible around it. */ surface, generous rows — the splash art, blood pool included, stays visible around it. */
#plaque { position: relative; display: grid; column-gap: 14px; align-items: center; text-align: left; /* ONE ROW (2026-08-31, the text reduction): crest, then name and level on one baseline, the bar
grid-template-columns: auto 1fr; under them with the level it fills toward at its right end. The GOAL LINE is gone — the
grid-template-areas: "badge name" "badge lv" "bar bar" "goal goal"; fixture button right below says what to do, and saying it twice was the whole complaint. */
#plaque { position: relative; display: flex; align-items: center; gap: 13px; text-align: left;
margin: 24px auto 0; width: min(330px, 92%); box-sizing: border-box; margin: 24px auto 0; width: min(330px, 92%); box-sizing: border-box;
padding: 18px 34px 15px 18px; background: rgba(8, 14, 10, 0.48); border: 0; padding: 13px 36px 13px 15px; background: rgba(8, 14, 10, 0.48); border: 0;
border-radius: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; } border-radius: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
#plBadge { grid-area: badge; height: 54px; image-rendering: pixelated; #plBadge { flex: none; height: 50px; image-rendering: pixelated;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); } filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
#plName { grid-area: name; align-self: end; padding-bottom: 3px; #plCol { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
font: 700 13px Silkscreen, monospace; color: var(--cream); letter-spacing: 2px; #plTop { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
white-space: nowrap; overflow: hidden; } #plName { font: 400 21px 'Jersey 15', Bangers, cursive; color: var(--cream); letter-spacing: 0.5px;
#plLv { grid-area: lv; align-self: start; font: 400 26px Bangers, cursive; color: var(--acid); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
letter-spacing: 1.5px; text-decoration: none; text-shadow: 2px 2px 0 rgba(0,0,0,0.5); } /* the CALL belongs to the button below, not to the plaque — the level is a number now */
/* the bar wears its ENDPOINTS — "20 ▓▓░░ 21" says "fill toward the next level" without a #plLv { flex: none; font: 700 10px Silkscreen, monospace; color: var(--acid); letter-spacing: 1px;
word of explanation (Oliver read the bare bar as total progress) */ text-decoration: none; }
#plRow { grid-area: bar; display: flex; align-items: center; gap: 7px; width: auto; /* the bar wears the level it fills TOWARD at its right end — "▓▓░░ 13" says which way it runs
margin-top: 15px; } without a word of explanation (Oliver read a bare bar as total progress) */
#plRow { display: flex; align-items: center; gap: 7px; }
#plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream); #plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream);
text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); } text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); }
/* NO nowrap here: the longest goal line is ~380px and a nowrap child sets the CARD's
min-content past the viewport — that was the phantom horizontal scrollbar Oliver spotted */
/* LITERAL gold — there is no --warm in the page palette (UI.warm is a CANVAS colour), and an
unresolvable var() painted this line black */
#plGoal { grid-area: goal; margin-top: 10px; font: 400 9px Silkscreen, monospace;
font-style: normal; color: #e0cc72; letter-spacing: 1px;
text-shadow: 1px 1px 0 rgba(0,0,0,0.5); text-align: left; line-height: 1.6; }
#plaque em { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); #plaque em { position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
font: 700 15px Silkscreen, monospace; font-style: normal; color: var(--acid); } font: 700 15px Silkscreen, monospace; font-style: normal; color: var(--acid); }
#plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); #plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10);
@@ -458,10 +463,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
text-align: center; line-height: 1.35; } text-align: center; line-height: 1.35; }
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal; #bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
letter-spacing: 1px; } letter-spacing: 1px; }
/* THE REVANCHE TILE (THE SEASON): the one that put you out of the cup, marked and standing /* THE REVANCHE ROW (THE SEASON, widened in the text reduction): the one that put you out of the
first. Red border and a red line, because it is the only tile on this screen with a reason. */ cup, across the top of the grid — one body out of eighteen with an actual reason, so it gets
#trainList button.put { border-color: var(--red); } the width to say so in three words. */
#trainList button.put i { color: #e08a7c; } #trainList button.put { grid-column: 1 / -1; flex-direction: row; align-items: center; gap: 12px;
padding: 10px 12px; text-align: left; position: relative;
border-color: var(--red); background: rgba(164,35,31,0.16); }
#trainList button.put img { height: 52px; }
#trainList button.put > span { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px;
text-align: left; }
#trainList button.put b { font: 400 24px/1.05 'Jersey 15', Silkscreen, monospace; text-align: left; }
#trainList button.put i { color: #e08a7c; letter-spacing: 1px; }
#trainList button.put em { position: absolute; right: 12px; font: 700 15px Silkscreen, monospace;
font-style: normal; color: var(--bad); }
/* the rung's NAME (Jersey) over its number — "No. 7" was three characters of nothing */
#trainList b { font: 400 17px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
/* ...and the list ends on the league that opens the rest of the ladder, drawn as the goal it is */
#trainList .goal { grid-column: 1 / -1; padding: 14px 6px; border: 2px dashed var(--line);
text-align: center; }
#trainList .goal b { display: block; color: #4a5a52; }
#trainList .goal i { font: 400 9px Silkscreen, monospace; font-style: normal; color: #4a5a52;
letter-spacing: 1px; }
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the /* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
draw and the walk-on wear), which reads at a third of the boss portrait's height */ draw and the walk-on wear), which reads at a third of the boss portrait's height */
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; } #trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
@@ -511,8 +533,26 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it
a label of a room. No rule, no left band either — the air above each heading does the a label of a room. No rule, no left band either — the air above each heading does the
grouping, which is what air is for. */ grouping, which is what air is for. */
#modeList .mgroup { margin: 0 0 12px; } #modeList .mgroup { margin: 0 0 10px; }
#modeList .mgroup + .mgroup { margin-top: 26px; } #modeList .mgroup + .mgroup { margin-top: 17px; }
/* HOW A ROOM DEALS ITS POSTERS (the text reduction). The wall used to be six identical 148px
slabs and ran over two screens; the fixture is a HERO now, the training pair stands side by
side, and the casual three stack shorter. All of it fits one screen. */
#modeList .mgroup .shelf { display: flex; flex-direction: column; }
#modeList .mgroup.pair .shelf { flex-direction: row; gap: 8px; }
#modeList .mgroup.pair button.mode { flex: 1; min-width: 0; min-height: 106px; margin-bottom: 0; }
#modeList .mgroup.stack button.mode { min-height: 96px; }
#modeList .mgroup.hero button.mode { min-height: 142px; }
/* the poster names: Jersey NAMES, and the hero is the one poster that CALLS (Bangers) */
#modeList .cb b { font: 400 30px/1 'Jersey 15', Bangers, cursive; letter-spacing: 0.5px; }
#modeList .mgroup.pair .cb b { font-size: 27px; }
#modeList .mgroup.hero .cb b { font: 400 32px/0.95 Bangers, cursive; letter-spacing: 1px; }
/* ...and the hero's second line is the fixture, the only strap left on the wall */
#modeList .mgroup.hero .cb i { color: #f0cfc4; font-size: 10px; margin-top: 3px; }
/* a fact that changes what a mode IS, not a description of it. Top right like LAST, and UNDER
it on the one poster that could wear both (the chip is appended after LAST in main.banner) */
#modeList .last.chip { top: 7px; }
#modeList .last + .last.chip { top: 30px; }
/* text-align rides down from the card, which centres everything on these screens — the section /* text-align rides down from the card, which centres everything on these screens — the section
header is a header, and headers on this screen are left-aligned (the .hdr title, the posters' header is a header, and headers on this screen are left-aligned (the .hdr title, the posters'
own captions) */ own captions) */
@@ -525,7 +565,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
descendant selector here restyled every caption on the wall (it stayed invisible while this descendant selector here restyled every caption on the wall (it stayed invisible while this
rule still named Silkscreen — the moment the family changed, the posters changed with it). */ rule still named Silkscreen — the moment the family changed, the posters changed with it). */
#modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid); #modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid);
text-shadow: none; font: 400 15px/1.3 Bungee, Bangers, cursive; } text-shadow: none; font: 400 17px/1.2 'Jersey 15', Bangers, cursive;
letter-spacing: 2px; }
/* ...and the strap goes back to the quiet register under it: a shouted line over a system-sans /* ...and the strap goes back to the quiet register under it: a shouted line over a system-sans
line was two foreign voices in one header */ line was two foreign voices in one header */
#modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px; #modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px;
@@ -541,18 +582,31 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#stmode button.mode, #modeSub button.mode { min-height: 148px; } #stmode button.mode, #modeSub button.mode { min-height: 148px; }
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; } #modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
#modeSub button.mode { background-position: center; } #modeSub button.mode { background-position: center; }
#modeSub button.chip { flex: 1 1 100%; padding: 22px 0; background: var(--panel); border: 2px solid var(--line); #modeSub button.chip { flex: 1 1 100%; display: flex; align-items: center; justify-content: space-between;
color: var(--cream); font: 400 13px Silkscreen, monospace; letter-spacing: 2px; gap: 10px; padding: 26px 16px; background: var(--panel); border: 2px solid var(--line);
cursor: pointer; -webkit-tap-highlight-color: transparent; position: relative; } color: var(--cream); cursor: pointer; -webkit-tap-highlight-color: transparent;
position: relative; text-align: left; }
#modeSub button.chip b { font: 400 30px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
/* how hard it is, counted: as many pips lit as the chip is steep, and the last one wears the
colour the game uses for a threat */
.pips { display: flex; gap: 5px; }
.pips i { width: 10px; height: 10px; background: rgba(255,255,255,0.12); }
.pips i.on { background: var(--acid); }
#modeSub button.chip.hot .pips i.on { background: var(--bad); }
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%; #courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
margin: 0 0 8px; padding: 7px; background: var(--panel); margin: 0 0 8px; padding: 7px; background: var(--panel);
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left; border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
cursor: pointer; -webkit-tap-highlight-color: transparent; } cursor: pointer; -webkit-tap-highlight-color: transparent; }
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); } #courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
#courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none; #courtList img { width: 84px; height: 60px; display: block; image-rendering: pixelated; flex: none;
object-fit: cover; } object-fit: cover; }
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; } #courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
#courtList b, #modeList b, #modeSub b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; } #courtList b, #modeList b, #modeSub b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
/* the court NAMES itself (Jersey) and its two meters say the rest — the description line is gone */
#courtList b { font: 400 22px/1.1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
#courtList i { margin: 3px 0 5px; font: 700 9px Silkscreen, monospace; color: var(--bad);
letter-spacing: 1px; }
#courtList i[hidden] { display: none; }
#courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; } #courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; }
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot /* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
drift away from what the ball actually does on it. */ drift away from what the ball actually does on it. */
@@ -613,7 +667,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
title and the court stay — they are the background and they are never undefined. */ title and the court stay — they are the background and they are never undefined. */
#splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none #splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
let the title slide down the screen and jump back up the moment the profile landed */ let the title slide down the screen and jump back up the moment the profile landed */
#sSum, #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px; #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; } font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; } .tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
.tabs button { flex: 1; padding: 11px 2px; background: var(--panel); border: 2px solid var(--line); .tabs button { flex: 1; padding: 11px 2px; background: var(--panel); border: 2px solid var(--line);
@@ -738,10 +792,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). --> so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). -->
<button id="plaque"> <button id="plaque">
<img id="plBadge" alt=""> <img id="plBadge" alt="">
<b id="plName"></b> <span id="plCol">
<u id="plLv">LEVEL 1</u> <span id="plTop"><b id="plName"></b><u id="plLv">LEVEL 1</u></span>
<span id="plRow"><u id="plFrom">1</u><s><i id="plFill"></i></s><u id="plTo">2</u></span> <span id="plRow"><s><i id="plFill"></i></s><u id="plTo">2</u></span>
<i id="plGoal"></i> </span>
<em>&gt;</em> <em>&gt;</em>
</button> </button>
</div> </div>
@@ -756,10 +810,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
already knows the answer to. It names the next cup fixture now and enters the draw; already knows the answer to. It names the next cup fixture now and enters the draw;
everything else lives one tap down, behind OTHER GAMES. --> everything else lives one tap down, behind OTHER GAMES. -->
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button> <button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
<button class="btn sec" id="mOther">OTHER GAMES<i>CLIMB · TRAINING · GORE · A FRIEND</i></button> <button class="btn sec" id="mOther">OTHER GAMES</button>
<div class="two"> <div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button> <button class="btn sec" id="mStats">STATS<i id="mKills">0</i></button>
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button> <button class="btn sec" id="mRanks">BOARD<i id="mBest">BEST 0</i></button>
</div> </div>
<p id="build"></p> <p id="build"></p>
</div> </div>
@@ -813,7 +867,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<h3 id="s02">02 · COURT</h3> <h3 id="s02">02 · COURT</h3>
<div id="courtList"></div> <div id="courtList"></div>
<div class="foot"> <div class="foot">
<p id="sSum"></p>
<button class="btn big" id="cStart">SERVE UP</button> <button class="btn big" id="cStart">SERVE UP</button>
</div> </div>
</div> </div>
@@ -893,7 +946,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. --> Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
<div id="train"> <div id="train">
<div class="hdr"><button class="chev" id="trBack">&lt;</button><h2>TRAINING</h2></div> <div class="hdr"><button class="chev" id="trBack">&lt;</button><h2>TRAINING</h2></div>
<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>
<div id="trainList"></div> <div id="trainList"></div>
</div> </div>
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player), <!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
@@ -903,17 +955,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<div id="vs"> <div id="vs">
<div class="hdr"><button class="chev" id="vsBack">&lt;</button><h2>VS A FRIEND</h2></div> <div class="hdr"><button class="chev" id="vsBack">&lt;</button><h2>VS A FRIEND</h2></div>
<div id="vsPick"> <div id="vsPick">
<h3>01 · YOUR NAME<span>ON THEIR SCOREBOARD</span></h3> <h3>YOU</h3>
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off" <input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false"> autocapitalize="characters" spellcheck="false">
<h3>02 · HOST A MATCH<span>THEY COME TO YOU</span></h3> <h3>HOST</h3>
<button class="btn" id="vsHostC">CLASSIC TENNIS</button> <button class="btn" id="vsHostC">CLASSIC</button>
<button class="btn" id="vsHostD">DEATH MATCH</button> <button class="btn" id="vsHostD">DEATH MATCH</button>
<h3>OR JOIN THEIRS<span>WITH THEIR CODE</span></h3> <h3>JOIN</h3>
<div class="joinrow"> <div class="joinrow">
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off" <input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
autocapitalize="characters" spellcheck="false"> autocapitalize="characters" spellcheck="false">
<button class="btn" id="vsJoin">JOIN</button> <button class="btn" id="vsJoin">GO</button>
</div> </div>
<p id="vsErr"></p> <p id="vsErr"></p>
</div> </div>
@@ -999,6 +1051,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=341"></script> <script type="module" src="src/main.js?v=342"></script>
</body> </body>
</html> </html>
+11 -15
View File
@@ -7,7 +7,7 @@
// (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, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, foeLevel } from './config.js'; import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } 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 } from './profile.js'; import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup } 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';
@@ -85,8 +85,8 @@ export function startDuel() {
showModal({ title: 'SUDDEN DEATH', color: UI.hot, showModal({ title: 'SUDDEN DEATH', color: UI.hot,
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'], body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'],
// both stakes named (the old card only sold the win): a lost duel is a real cost // both stakes named (the old card only sold the win): a lost duel is a real cost
rules: [{ lead: "IT'S TENNIS AGAIN — ONE POINT", sub: 'WIN: THEY HAND YOU A WEAPON' }, rules: ['WIN IT AND THEY HAND YOU A WEAPON',
{ lead: 'LOSE: THE DEAD TAKE A GAME', sub: 'AND FIVE MORE CLIMB THE FENCE', hot: true }] }); { lead: 'LOSE IT AND FIVE MORE CLIMB THE FENCE', hot: true }] });
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 }; } else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
} }
@@ -394,17 +394,15 @@ export function startMatch(cup = false, draw = null, train = 0) {
showModal({ title: 'THE TRAINING COURT', color: UI.hot, showModal({ title: 'THE TRAINING COURT', color: UI.hot,
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: [{ lead: 'ONE BODY, ONE GAME AT A TIME', sub: 'FELL IT OR BLEED OUT — IT COMES BACK' }, rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] });
{ lead: 'NO RUN IS BANKED', sub: 'REPS PAY QUARTER XP' }] });
} 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, showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
// plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys // plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys
body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE', body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'], 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
rules: [{ lead: 'YOUR LEAGUE, ROUND BY ROUND', sub: 'WIN IT ALL — MOVE UP A LEAGUE' }, rules: ['WIN IT ALL — MOVE UP A LEAGUE',
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true, { lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
pips: TUNE.matchPlayerHp }] });
} else if (!cup && !train && !G.seen.match) { } else if (!cup && !train && !G.seen.match) {
G.seen.match = true; G.seen.match = true;
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real // Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
@@ -416,15 +414,13 @@ export function startMatch(cup = false, draw = null, train = 0) {
? { title: 'DEATH MATCH', color: UI.hot, ? { title: 'DEATH MATCH', color: UI.hot,
body: ['WIN THE POINT — WOUND YOUR FRIEND', body: ['WIN THE POINT — WOUND YOUR FRIEND',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'], 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
rules: [{ lead: 'WIN THE POINT', sub: 'WOUND YOUR FRIEND' }, rules: ['WIN THE POINT — WOUND YOUR FRIEND',
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true, { lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }
pips: TUNE.matchPlayerHp }] }
: { title: 'THE CLIMB', color: UI.hot, : { title: 'THE CLIMB', color: UI.hot,
body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY', body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'], 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
rules: [{ lead: 'THE WHOLE CLUB, BOTTOM UP', sub: 'ONE GAME PER BODY — HOW FAR CAN YOU GET?' }, rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?',
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true, { lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
pips: TUNE.matchPlayerHp }] });
} }
matchOpponent(); matchOpponent();
matchReady(); matchReady();
+11 -4
View File
@@ -856,13 +856,17 @@ export const MODES = [
// canonical path does not live behind a fan-out. Its two former siblings (the climb, the training // canonical path does not live behind a fan-out. Its two former siblings (the climb, the training
// court) stand on their own in the training group, which also cost the second step its only other // court) stand on their own in the training group, which also cost the second step its only other
// job: CLASSIC's difficulty chips are all that still fan out. // job: CLASSIC's difficulty chips are all that still fan out.
// THE TEXT REDUCTION (2026-08-31, design handoff): a room is ONE WORD and the posters carry only
// their names. The straps went with the sentences — a shelf that has to explain itself is a shelf
// nobody reads. `layout` is how the room deals its posters: the fixture is a HERO with the one
// call on the screen, the training pair stands side by side, the casual three stack shorter.
export const MENU = [ export const MENU = [
{ group: 'THE SEASON', sub: 'THE ONLY WAY UP THE CLUB' }, { group: 'SEASON', layout: 'hero' },
{ mode: 'cup' }, { mode: 'cup' },
{ group: 'THE TRAINING', sub: 'SHARPEN UP · NOTHING HERE MOVES YOUR LEAGUE' }, { group: 'TRAINING', layout: 'pair' },
{ mode: 'zombie' }, { mode: 'zombie' },
{ mode: 'train' }, { mode: 'train' },
{ group: 'OUT OF COMPETITION', sub: 'NO LEAGUE, NO RECORD, NO STAKES' }, { group: 'CASUAL', layout: 'stack' },
{ mode: 'gore' }, { mode: 'gore' },
{ mode: 'classic', diffs: true }, { mode: 'classic', diffs: true },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN, // NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
@@ -963,7 +967,10 @@ export const cupFixture = (lg, champion = 0) => {
const held = champion && lg >= LEAGUES.length; // the champion has nothing above him left const held = champion && lg >= LEAGUES.length; // the champion has nothing above him left
return { lg, league: leagueOf(lg), rounds, rung: first.band[0], foe, return { lg, league: leagueOf(lg), rounds, rung: first.band[0], foe,
title: held ? 'DEFEND THE TITLE' : 'LEAGUE ' + lg + ' · THE CUP', title: held ? 'DEFEND THE TITLE' : 'LEAGUE ' + lg + ' · THE CUP',
line: rounds.length + ' ROUNDS · ' + first.name + ': ' + foe.name }; // the ROUND COUNT moved to `sub` (the text reduction): it belongs in the modal that
// explains the tournament, not on the button you press to enter it
line: first.name + ': ' + foe.name,
sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' };
}; };
// 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.
+84 -72
View File
@@ -124,15 +124,21 @@ const modeSub = document.getElementById('modeSub');
// checkbox and a button combined"). Every poster is full-bright and equal — all of them are live // checkbox and a button combined"). Every poster is full-bright and equal — all of them are live
// answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The // answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms. // select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
const banner = (card, cls, last) => { // `opts.line` is the ONE second line the wall still carries (the fixture, under the hero's name);
// `opts.chip` is a fact that changes what a mode IS rather than describing it (18 RUNGS).
const banner = (card, cls, last, opts = {}) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.className = cls; b.className = cls;
// JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the // JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the
// caption sits on a BLURRED veil now (index.html, button.mode::after) that fades in out of // caption sits on a veil now (index.html, button.mode::after) that rises out of the bottom edge,
// nothing from above, and stacking the old gradient under it only doubled the darkening. // and stacking the old gradient under it only doubled the darkening.
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`; b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>` // THE POSTER IS ITS NAME (2026-08-31, the text reduction). The mode's one-line rule used to sit
// under every name on the wall — sixty words to make one choice. The rules live in the modal
// that opens with the mode; MODES.sub is still their source, it just no longer prints here.
b.innerHTML = `<span class="cb"><b>${card.name}</b>${opts.line ? `<i>${opts.line}</i>` : ''}</span>`
+ (last ? '<u class="last">LAST</u>' : '') + (last ? '<u class="last">LAST</u>' : '')
+ (opts.chip ? `<u class="last chip">${opts.chip}</u>` : '')
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES) // the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
+ (card.tag ? `<u class="way">${card.tag}</u>` : ''); + (card.tag ? `<u class="way">${card.tag}</u>` : '');
return b; return b;
@@ -145,23 +151,26 @@ function buildSub(top) {
modeSub.innerHTML = ''; modeSub.innerHTML = '';
const t = top || topOf(pickedMode); const t = top || topOf(pickedMode);
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || ''); document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
if (t.diffs) for (const d of DIFFS) { // THE DIFFICULTY IS A COUNT, not a paragraph (2026-08-31, the text reduction): the name, and
// three pips of which as many are lit as the chip is hard. Nothing else — the CPU explains
// itself in the first rally, and it always did.
if (t.diffs) DIFFS.forEach((d, i) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.className = 'chip'; b.className = 'chip' + (i === DIFFS.length - 1 ? ' hot' : '');
b.innerHTML = d.name + (d.id === pickedDiff ? '<u class="last">LAST</u>' : ''); b.innerHTML = `<b>${d.name}</b><span class="pips">`
+ [0, 1, 2].map(k => `<i${k <= i ? ' class="on"' : ''}></i>`).join('') + '</span>'
+ (d.id === pickedDiff ? '<u class="last">LAST</u>' : '');
b.addEventListener('click', () => { b.addEventListener('click', () => {
pickedDiff = d.id; pickedDiff = d.id;
try { localStorage.setItem('diff', d.id); } catch {} try { localStorage.setItem('diff', d.id); } catch {}
summary();
go('setup'); go('setup');
}); });
modeSub.appendChild(b); modeSub.appendChild(b);
} });
else if (t.kids) for (const id of t.kids) { else if (t.kids) for (const id of t.kids) {
const b = banner(modeOf(id), 'mode', id === pickedMode); const b = banner(modeOf(id), 'mode', id === pickedMode);
b.addEventListener('click', () => { b.addEventListener('click', () => {
pickMode(id); pickMode(id);
summary();
go('setup'); go('setup');
}); });
modeSub.appendChild(b); modeSub.appendChild(b);
@@ -177,12 +186,18 @@ function buildTops() {
// merely floats above a stack is a label, and the wall needs three rooms, not three labels. // merely floats above a stack is a label, and the wall needs three rooms, not three labels.
if (t.group) { if (t.group) {
shelf = document.createElement('section'); shelf = document.createElement('section');
shelf.className = 'mgroup'; shelf.className = 'mgroup ' + (t.layout || '');
shelf.innerHTML = `<header><b>${t.group}</b><i>${t.sub}</i></header>`; shelf.innerHTML = `<header><b>${t.group}</b></header><div class="shelf"></div>`;
modeList.appendChild(shelf); modeList.appendChild(shelf);
shelf = shelf.querySelector('.shelf'); // the posters live in the row, not the section
continue; continue;
} }
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t); const fx = cupFixture(profile.prof.lg, profile.prof.ch);
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t,
// the fixture is the hero and carries the only second line on the wall; the training court
// carries the one fact that is not a description — how much of the club it holds
{ line: t.mode === 'cup' ? 'LEAGUE ' + fx.lg + ' · ' + fx.line : null,
chip: t.mode === 'train' ? LADDER + ' RUNGS' : null });
b.addEventListener('click', () => { b.addEventListener('click', () => {
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode // the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
// the stats screen or the summary could name, and the lobby starts the game itself // the stats screen or the summary could name, and the lobby starts the game itself
@@ -190,7 +205,6 @@ function buildTops() {
// a group remembers which child you were on; entering it fresh lands on its first card // a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0])); pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
buildSub(); buildSub();
summary();
go(t.diffs || t.kids ? 'modesub' : 'setup'); go(t.diffs || t.kids ? 'modesub' : 'setup');
}); });
shelf.appendChild(b); shelf.appendChild(b);
@@ -286,7 +300,6 @@ for (const t of TOURS) {
pickedTour = t.id; pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b); for (const el of tourList.children) el.classList.toggle('on', el === b);
refreshLook(); refreshLook();
summary();
}); });
tourList.appendChild(b); tourList.appendChild(b);
} }
@@ -398,15 +411,17 @@ function syncSetup() {
document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP'; document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP';
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex'; document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none'; document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none';
// THE TEXT REDUCTION: two blocks on one screen, not two numbered steps
document.getElementById('s01').textContent = document.getElementById('s01').textContent =
cupMode ? '01 · YOUR LEAGUE' : climbMode ? '01 · THE CLIMB' : '01 · PLAYER'; cupMode ? 'YOUR LEAGUE' : climbMode ? 'THE CLIMB' : 'PLAYER';
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel(); if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court; if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice // the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice
// (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list. // (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list.
const homeCourt = leagueOf(profile.prof.lg).court; const homeCourt = leagueOf(profile.prof.lg).court;
// ...and the meters get their legend ONCE, in the heading, instead of a line under every court
document.getElementById('s02').innerHTML = cupMode document.getElementById('s02').innerHTML = cupMode
? '02 · HOME COURT<span>SET BY YOUR LEAGUE</span>' : '02 · COURT'; ? 'HOME COURT<span>SET BY YOUR LEAGUE</span>' : 'COURT<span>PACE · BOUNCE</span>';
courtList.classList.toggle('fixed', cupMode); courtList.classList.toggle('fixed', cupMode);
for (const el of courtList.children) { for (const el of courtList.children) {
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id); const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
@@ -415,8 +430,11 @@ function syncSetup() {
el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt
: open && el.dataset.id === picked); : open && el.dataset.id === picked);
el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : ''; el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : '';
// the sub line carries the lock and names the price — the tile stays visible, as a goal should // an OPEN court says nothing — its two meters say it wordlessly (the text reduction). A locked
el.querySelector('i').textContent = open ? c.sub : 'LOCKED — REACH ' + l.name; // one names the league that opens it, and nothing else: the tile stays visible, as a goal should
const sub = el.querySelector('i');
sub.textContent = open ? '' : 'LEAGUE ' + l.id;
sub.hidden = open;
} }
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour); for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit], for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
@@ -425,8 +443,7 @@ function syncSetup() {
el.classList.toggle('on', el.dataset.id === cur); el.classList.toggle('on', el.dataset.id === cur);
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name; document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
} }
refreshLook(); summary(); refreshLook();}
}
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then // --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their // ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
@@ -477,8 +494,10 @@ pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElem
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest // SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest // BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics. // The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
// the label is a TITLE, not a printed word (the text reduction): the legend stands once over the
// list — two bars in a fixed order need naming once, not on every row
const meter = (label, v, cls = '') => const meter = (label, v, cls = '') =>
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${ `<div class="meter ${cls}" title="${label}"><s><i style="width:${
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`; Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
const courtList = document.getElementById('courtList'); const courtList = document.getElementById('courtList');
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the // THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
@@ -491,7 +510,7 @@ for (const c of COURTS) {
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.dataset.id = c.id; b.dataset.id = c.id;
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">` b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${c.name}</b><i>${c.sub}</i>` + `<b>${c.name}</b><i hidden></i>` // the sub only ever carries a LOCK now (syncSetup)
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26) + meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b') + meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
+ '</span>'; + '</span>';
@@ -502,7 +521,6 @@ for (const c of COURTS) {
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
picked = c.id; picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b); for (const el of courtList.children) el.classList.toggle('on', el === b);
summary();
}); });
courtList.appendChild(b); courtList.appendChild(b);
} }
@@ -538,18 +556,38 @@ function buildTrain() {
// in its own right — a practice list where every rung looks the same is a shelf, and this one // in its own right — a practice list where every rung looks the same is a shelf, and this one
// room in the game has an obvious reason to exist the moment you have just been put out. // room in the game has an obvious reason to exist the moment you have just been put out.
const out = profile.prof.out; const out = profile.prof.out;
const rungs = []; const rung = n => {
for (let n = 1; n <= top; n++) rungs.push(n);
if (out >= 1 && out <= top) rungs.splice(rungs.indexOf(out), 1), rungs.unshift(out);
for (const n of rungs) {
const f = matchFoe(n), b = document.createElement('button'); const f = matchFoe(n), b = document.createElement('button');
const put = n === out;
if (put) b.className = 'put';
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
+ `<i>${put ? 'PUT YOU OUT OF THE CUP' : 'No. ' + n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); }); b.addEventListener('click', () => { trainRung = n; start('train'); });
return [f, b];
};
// THE REVANCHE IS A ROW, not a tile in the grid (the text reduction): it is one body out of
// eighteen and the only one with a reason, so it gets the width to say so — and says it in
// three words, because the screen it sits on is already called TRAINING.
if (out >= 1 && out <= top) {
const [f, b] = rung(out);
b.className = 'put';
b.innerHTML = `<img src="${foeArt(f)}" alt=""><span><b>${f.name}</b><i>PUT YOU OUT</i></span>`
+ '<em>&gt;</em>';
trainList.appendChild(b); trainList.appendChild(b);
} }
for (let n = 1; n <= top; n++) {
if (n === out) continue;
const [f, b] = rung(n);
// the rung is a NUMBER on a list of numbers — two digits, no "No."
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
+ `<i>${String(n).padStart(2, '0')}</i>`;
trainList.appendChild(b);
}
// ...and the list ENDS somewhere: the next league's band is drawn as the goal it is, not left
// to stop dead at the cap with no explanation (the handoff's "Ziel, keine Liste")
if (top < LADDER) {
const next = LEAGUES.find(l => l.band[0] > top);
const goal = document.createElement('div');
goal.className = 'goal';
if (next) goal.innerHTML = `<b>LEAGUE ${next.id}</b><i>${next.band[0]} — ${next.band[1]}</i>`;
trainList.appendChild(goal);
}
} }
document.getElementById('trBack').addEventListener('click', () => go('setup')); document.getElementById('trBack').addEventListener('click', () => go('setup'));
@@ -679,62 +717,36 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE; : m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating // 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];
const lg = leagueOf(profile.prof.lg);
const cupMode = pickedMode === 'cup';
const c = COURTS.find(x => x.id === (cupMode ? lg.court : picked)) || COURTS[0];
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
// 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 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'
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${cupMode
? ' · LEAGUE ' + lg.id : ''}</span>`
// training banks nothing, so there is no mark to beat and the footer must not invent one
+ `<span>${m.id === 'train' ? 'NO RUN, NO DEATH — REPS PAY ¼ XP'
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the // The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
// top came out of the mockup and went again: it sat on the best part of the art and said twice what // top came out of the mockup and went again: it sat on the best part of the art and said twice what
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run // the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
// has just ended and the count has moved. // has just ended and the count has moved.
function refreshSplash() { function refreshSplash() {
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS'; // THE TEXT REDUCTION (2026-08-31): the two secondary doors carry a NUMBER each instead of a
// description — what you have done, and your own best on the board they open. The board figure
// is read locally on purpose: the splash does not fetch the world board.
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB');
document.getElementById('mBest').textContent =
'BEST ' + stats.bestScore(boardFor(pickedMode)).toLocaleString('en-GB');
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just // THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
// moved it (same rule as the kill count above). The goal line is the standing answer to "what // moved it (same rule as the kill count above). One row now: crest, name and level, and the bar
// do I do to advance": win the CURRENT league's cup, become champion, or rest on the crown. // under them wearing the level it fills toward. The goal LINE is gone — the fixture button below
// says what to do, and it said it twice.
const pr = profile.progress(), lg = leagueOf(profile.prof.lg); const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`; document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER'; document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER';
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level + ' · LEAGUE ' + lg.id; document.getElementById('plLv').textContent = 'LEVEL ' + pr.level;
document.getElementById('plFrom').textContent = pr.level;
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX'; document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%'; document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
// ...and the goal line carries THE RECORD now (THE SEASON): the fixture button below already
// says what to do, so repeating "win the cup to advance" over it was the same sentence twice.
// What the plaque can say that nothing else does is how far you have ever got.
const bestR = profile.cupBest(profile.prof.lg);
document.getElementById('plGoal').textContent = profile.prof.ch
? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME'
: bestR
? 'THIS CUP · BEST SO FAR: ' + cupRoundName(profile.prof.lg, bestR)
: 'THE CUP IS THE WAY UP — NEVER ENTERED';
// 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). The record rides the sub line when there is one — a lost cup is the only // (config.cupFixture) — minus the round count, which lives in the tournament's own modal now.
// thing the club has to say about your last attempt, and it belongs where the next one starts.
const fx = cupFixture(profile.prof.lg, profile.prof.ch); const fx = cupFixture(profile.prof.lg, profile.prof.ch);
document.getElementById('msTitle').textContent = fx.title; document.getElementById('msTitle').textContent = fx.title;
document.getElementById('msLine').textContent = fx.line; document.getElementById('msLine').textContent = fx.line;
summary();
} }
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK // `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was // ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently // taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
@@ -785,7 +797,7 @@ const goMode = () => {
// confirmation page with nothing on it to confirm // confirmation page with nothing on it to confirm
document.getElementById('mStart').addEventListener('click', () => { document.getElementById('mStart').addEventListener('click', () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
pickMode('cup'); summary(); start('cup'); pickMode('cup'); start('cup');
}); });
document.getElementById('mOther').addEventListener('click', goMode); document.getElementById('mOther').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats); document.getElementById('mStats').addEventListener('click', openStats);
@@ -1106,7 +1118,7 @@ const maybeRestart = e => {
if (run && run.newKillBest && run.score > 0) { askName(run); return; } if (run && run.newKillBest && run.score > 0) { askName(run); return; }
// the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN // the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN
// re-deals the mode that just ended (pickedMode is what started it, cup included) // re-deals the mode that just ended (pickedMode is what started it, cup included)
if (door === 'next') { pickMode('cup'); summary(); start('cup'); return; } if (door === 'next') { pickMode('cup'); start('cup'); return; }
if (door === 'again') { start(pickedMode); return; } if (door === 'again') { start(pickedMode); return; }
go('splash'); go('splash');
}; };
+32 -29
View File
@@ -2368,16 +2368,21 @@ function modalRows(m, inner, skipBody) {
const rows = []; const rows = [];
const push = r => { rows.push(r); return r; }; const push = r => { rows.push(r); return r; };
if (m.foe) { if (m.foe) {
push({ kind: 'split', h: 32 }); push({ kind: 'split', h: 26 });
const sub = []; const sub = [];
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 }); if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
} else if (m.rules) { } else if (m.rules) {
for (const r of m.rules) { // ONE RULE, ONE LINE (2026-08-31, the menu-text-reduction handoff). Every row used to carry a
const lead = wrapText(r.lead, F(8, 700), inner); // statement and a consequence, and the consequence was usually the statement again in other
const sub = r.sub ? wrapText(r.sub, F(8, 0), inner) : []; // words ("YOUR LEAGUE, ROUND BY ROUND" / "WIN IT ALL — MOVE UP A LEAGUE"). The line that
push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips, // names the RESULT is the one that survives. A rule is a string, or an object when it needs
h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 }); // the threat colour or the blood pool. `pips` ride the SAME line, hard right.
for (const raw of m.rules) {
const r = typeof raw === 'string' ? { lead: raw } : raw;
const room = inner - (r.pips ? r.pips * 9 + 12 : 0);
const lead = wrapText(r.lead, F(8, 700), room);
push({ kind: 'rule', lead, hot: r.hot, pips: r.pips, h: lead.length * 12 + 4 });
} }
} else if (!skipBody) { } else if (!skipBody) {
const lines = []; const lines = [];
@@ -2415,7 +2420,11 @@ function modalLayout(m) {
// ...and only when it actually FITS there: a line that wraps in the narrow column belongs under // ...and only when it actually FITS there: a line that wraps in the narrow column belongs under
// the head as an ordinary row, not as a ragged column beside a picture // the head as an ordinary row, not as a ragged column beside a picture
const beside = pairs && wrapped.length <= bodyLines.length + 1; const beside = pairs && wrapped.length <= bodyLines.length + 1;
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : beside ? wrapped : []; // ONE line under the title: the body's slogan on a walk-on, the mode's own fact on a rules card
// (`m.strap` — LEAGUE 3 · 4 ROUNDS, the round count the fixture button gave up), or the two
// lines of an illustrated announcement.
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW)
: m.strap ? wrapText(m.strap, F(8, 0), headW) : beside ? wrapped : [];
const headH = Math.max(artH, 22 + strap.length * 12); const headH = Math.max(artH, 22 + strap.length * 12);
const rows = modalRows(m, boxW - pad * 2, beside); const rows = modalRows(m, boxW - pad * 2, beside);
let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad; let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad;
@@ -2464,34 +2473,28 @@ function drawModal(m) {
} else if (r.kind === 'rule') { } else if (r.kind === 'rule') {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.font = F(8, 700); ctx.font = F(8, 700);
r.lead.forEach((l, i) => shadowText(l, inX, y + 7 + i * 12, UI.cream)); // the rule wears the modal's hot colour only when it threatens; otherwise it is a statement
let ry = y + 7 + r.lead.length * 12; r.lead.forEach((l, i) => shadowText(l, inX, y + 8 + i * 12, r.hot ? col : UI.cream));
// the consequence line: DIM for information, the modal's hot colour only when it threatens if (r.pips) { // the pool, in the HUD's own pip language, ON THE SAME LINE
ctx.font = F(8, 0); const px = x + L.boxW - pad - (r.pips - 1) * 9;
r.sub.forEach((l, i) => shadowText(l, inX, ry + i * 12, r.hot ? col : UI.dim));
if (r.pips) { // the pool, in the HUD's own pip language
const py = ry + r.sub.length * 12 + 4;
for (let k = 0; k < r.pips; k++) { for (let k = 0; k < r.pips; k++) {
ctx.beginPath(); ctx.arc(inX + 3 + k * 9, py, 2.6, 0, 7); ctx.beginPath(); ctx.arc(px + k * 9, y + 8, 2.6, 0, 7);
ctx.fillStyle = '#8a1a12'; ctx.fill(); ctx.fillStyle = '#8a1a12'; ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke(); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke();
} }
} }
} else if (r.kind === 'split') { } else if (r.kind === 'split') {
// HIS LEVEL AGAINST YOURS — one frame, two figures, a hairline between them. The number // HIS LEVEL AGAINST YOURS, on ONE line: his on the left, yours hard right. SAME FACE, SAME
// wears the modal's hot colour while the body outranks you and the pick green once it // SIZE for both (Oliver) — a comparison drawn at two sizes is not a comparison, it is a
// does not, which is the whole point of showing it. // headline with a footnote. Only the COLOUR differs, and only where it means something: his
const half = Math.round((L.boxW - pad * 2) / 2), f = m.foe; // number wears the modal's hot colour while he outranks you, the pick green once he does not.
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; const f = m.foe;
ctx.beginPath(); ctx.moveTo(inX + half + 0.5, y + 4); ctx.lineTo(inX + half + 0.5, y + r.h - 4); ctx.font = F(10, 700);
ctx.stroke(); ctx.textAlign = 'left';
ctx.textAlign = 'center'; shadowText('LEVEL ' + f.lvl, inX, y + 9, f.lvl > f.mine ? col : UI.pick);
ctx.font = F(7, 0); ctx.textAlign = 'right';
shadowText('ITS LEVEL', inX + half / 2, y + 8, UI.dim); shadowText('YOURS ' + f.mine, x + L.boxW - pad, y + 9, UI.dim);
shadowText('YOURS', inX + half * 1.5, y + 8, UI.dim); ctx.textAlign = 'left';
ctx.font = F(13, 700);
shadowText(String(f.lvl), inX + half / 2, y + 23, f.lvl > f.mine ? col : UI.pick);
shadowText(String(f.mine), inX + half * 1.5, y + 23, UI.cream);
} }
y += r.h + MODAL_GAP; y += r.h + MODAL_GAP;
} }
+7
View File
@@ -165,6 +165,13 @@ export function read(period, runs = load(), mode = MODE_GORE) {
return out; return out;
} }
// YOUR best number on a board, from your own runs. The splash shows it beside the world board's
// door (the text reduction) — a local read on purpose: the splash does not fetch the board.
export function bestScore(mode = MODE_GORE) {
const B = boardOf(mode);
return load().filter(r => modeOf(r) === mode).reduce((m, r) => Math.max(m, B.score(r)), 0);
}
// the two figures the splash carries // the two figures the splash carries
export function total() { export function total() {
const a = read('all'); const a = read('all');
+5 -2
View File
@@ -1247,8 +1247,11 @@ test('THE SEASON: the fixture names the league, its rounds and the body that ope
assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body'); assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body');
assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE', assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE',
l.name + ': the title carries the league'); l.name + ': the title carries the league');
assert.ok(fx.line.includes(R[0].name) && fx.line.startsWith(String(R.length)), // the text reduction moved the round COUNT off the button and into the modal's sub line
l.name + ': the line counts the rounds and names the first'); assert.ok(fx.line.includes(R[0].name) && fx.line.includes(fx.foe.name),
l.name + ': the line names the first round and the body in it');
assert.ok(fx.sub.includes(String(R.length)) && fx.sub.includes(String(l.id)),
l.name + ': the sub carries the league and how many rounds it runs');
} }
// the champion has nothing above him: his fixture is a defence, not a promotion // the champion has nothing above him: his fixture is a defence, not a promotion
assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE'); assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE');