The weapon licence, the peer line, and the steward gets his vest back

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-30 15:52:51 +02:00
1 parent 7e2649c888
commit 7d39032907
40 files changed
+62 -24

No files matched your search

+22
View File
@@ -491,6 +491,28 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: the licence, the peer line, the steward's vest (2026-08-29, Oliver's small-finds batch)
Three of five items from his post-merge test built (training xp and the level/xp display are
under discussion):
- **THE WEAPON LICENCE hardened.** His catch: the duel's tin was league-gated but the BARRELS
still paid every weapon. Root fix, not a barrel patch: the league is a LICENCE and the run's
`from` gates are the PACING — both must hold, in `tinLadder()` AND `pickBarrelWeapon()`. The
tins' old OR (league unlocks early) just moved his complaint one screen over, so it went:
below league 3 there are no lasers anywhere, ever; rockets need league 4; the gore director's
tuned income curve is untouched for licensed players. Tin test rewritten to licence semantics.
- **THE PEER LINE**: every walk-on modal now carries "LEVEL 34 — YOURS: 20" (config.foeLevel —
the ladder position spread over the profile's 50 levels, display only, nothing scales off it),
so you can feel whether you are that body's equal yet. His ask, verbatim.
- **THE STEWARD GOT HIS VEST BACK.** The tennis family was rolled off z_d1 (which wears the
orange hi-vis) but the FAMS words said "dark uniform jacket" — and the words won. The words
name the vest now ("on him in EVERY single pose"); family re-rolled through
_famroll -> _zsheet, review-cut checked, installed (SPRV 149 -> 150). z_sthead was already cut
from the gore frame and stayed byte-identical. NOTE: a full `_heads.py --install` rebuilds SIX
OTHER head icons differently from what is installed (script drift, br/ch/dr/gk/ma/r) — those
were reverted, not shipped as a side effect; whoever touches heads next should reconcile first.
## Session log: THE LEVEL IS YOU, IN THE LEAGUE — legs and window (2026-08-29, after the merge) ## Session log: THE LEVEL IS YOU, IN THE LEAGUE — legs and window (2026-08-29, after the merge)
Oliver's deep-test question ("wie wirkt sich mein level aus? werde ich schneller, genauer?") Oliver's deep-test question ("wie wirkt sich mein level aus? werde ich schneller, genauer?")
+5 -1
View File
@@ -33,7 +33,11 @@ FAMS = {
'rounded shoulders. Far bulkier, heavier and wider than the figure in image 1'), 'rounded shoulders. Far bulkier, heavier and wider than the figure in image 1'),
'sh': ('z_a1', 'a lean SHAMBLING ZOMBIE in a filthy dark red tennis shirt and grey shorts, gaunt and stooped'), 'sh': ('z_a1', 'a lean SHAMBLING ZOMBIE in a filthy dark red tennis shirt and grey shorts, gaunt and stooped'),
'bb': ('z_e1', 'a small young ZOMBIE BALL BOY in a pale tournament polo and shorts, slight and short'), 'bb': ('z_e1', 'a small young ZOMBIE BALL BOY in a pale tournament polo and shorts, slight and short'),
'st': ('z_d1', 'a ZOMBIE STEWARD in a dark uniform jacket with a high collar, stiff and official'), # 2026-08-29 (Oliver): the roll dropped his HI-VIS VEST — the words said "dark uniform jacket"
# while the anchor frame z_d1 wears bright orange over it, and the words won. Name the vest.
'st': ('z_d1', 'a ZOMBIE STEWARD wearing a BRIGHT ORANGE HI-VIS SAFETY VEST with grey reflective '
'stripes OVER a dark uniform jacket, stiff and official. The orange vest is on him in '
'EVERY single pose — it is his uniform'),
'ru': ('z_f1', 'a wiry FAST ZOMBIE RUNNER in a sleeveless tennis top, lean and forward-leaning'), 'ru': ('z_f1', 'a wiry FAST ZOMBIE RUNNER in a sleeveless tennis top, lean and forward-leaning'),
# THE GROUNDSKEEPER ANCHORS ON THE HAND-FINISHED FRAME, not on z_boss1 (2026-08-16). His boss art # THE GROUNDSKEEPER ANCHORS ON THE HAND-FINISHED FRAME, not on z_boss1 (2026-08-16). His boss art
# is the last of the coarse legacy sheets, and the old description here ("work overalls and a # is the last of the coarse legacy sheets, and the old description here ("work overalls and a
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -864,6 +864,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=333"></script> <script type="module" src="src/main.js?v=334"></script>
</body> </body>
</html> </html>
+6 -3
View File
@@ -7,9 +7,9 @@
// (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 } from './config.js'; import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, 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 } from './profile.js'; import { award, prof, save as profSave, push as profPush, setPromo, levelAt } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js'; import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -649,8 +649,11 @@ function matchOpponent() {
// 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore. // 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore.
// In the cup the round names itself on the second line. // In the cup the round names itself on the second line.
const cup = G.match.cup; const cup = G.match.cup;
// the "am I its peer yet?" line (Oliver, 2026-08-29): the body's ladder position spread over
// the profile's own level scale, beside yours — display only, nothing scales off it
const vs = 'LEVEL ' + foeLevel(G.match.opp) + ' — YOURS: ' + levelAt(prof.xp);
showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64, showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
body: [zb.foe.line].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 body: [zb.foe.line, vs].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1
? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')]
// the top of the band walking BACK on is the arcade's natural "what now?" moment — the // the top of the band walking BACK on is the arcade's natural "what now?" moment — the
// intro answers it in place (THE LEAGUES: the cup is the only promotion) // intro answers it in place (THE LEAGUES: the cup is the only promotion)
+11 -5
View File
@@ -729,11 +729,13 @@ export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e
// palette is near it, so "pink means a weapon is in there" needs no explaining // palette is near it, so "pink means a weapon is in there" needs no explaining
pink: '#ff5cf0' }; pink: '#ff5cf0' };
// THE TIN TIERS (the leagues, phase 4; settled: tin-tier, NO starting loadout). The league // THE WEAPON LICENCE (the leagues; settled: tin-tier, NO starting loadout; hardened 2026-08-29,
// unlocks what a won duel's tin can PAY, independent of the run's own barrel gates — "weapons are // Oliver: "in den Truhen sind nach wie vor alle Waffen verfügbar"). The league is a LICENCE and
// only ever won" stays literal, the league just deepens the pot: lasers join the duel ladder at // the run's `from` gates stay the PACING: a weapon exists for you only when the league says it is
// league 3, rockets at 4, and league 5 duels pay DOUBLE tins. The world board is not devalued by // yours AND the run has reached it — barrels and duel tins agree (an OR on the tins just moved
// starting inventory, and every weapon still arrives through resolveInterlude('P'). // his complaint one screen over). Below league 3 there are no lasers anywhere, ever; the gore
// director's tuned income curve is untouched for licensed players. League 5 duels pay DOUBLE
// tins. "Weapons are only ever won" stays literal.
export const TIN_LEAGUE = { laser: 3, rocket: 4 }; export const TIN_LEAGUE = { laser: 3, rocket: 4 };
export const TIN_DOUBLE_LEAGUE = 5; export const TIN_DOUBLE_LEAGUE = 5;
@@ -915,6 +917,10 @@ export const leagueOf = lg => LEAGUES[Math.min(LEAGUES.length, Math.max(1, lg |
// which league a LADDER POSITION belongs to — THE CLIMB's tempo reads this (the world speeds up // which league a LADDER POSITION belongs to — THE CLIMB's tempo reads this (the world speeds up
// as the run crosses each band border), and it is what makes the club read as five territories // as the run crosses each band border), and it is what makes the club read as five territories
export const leagueOfRung = n => LEAGUES.find(l => n <= l.band[1]) || LEAGUES[LEAGUES.length - 1]; export const leagueOfRung = n => LEAGUES.find(l => n <= l.band[1]) || LEAGUES[LEAGUES.length - 1];
// the DISPLAYED level of a ladder position (the walk-on card's "am I its peer yet?" line,
// 2026-08-29): the ladder spread over the profile's 50 levels. Display only — nothing scales
// off it. 49 = profile.LEVEL_CAP - 1 (config cannot import profile; the cycle runs the other way).
export const foeLevel = n => Math.round(1 + (n - 1) / (LADDER - 1) * 49);
// The tournament, PER LEAGUE. Same shape CUP_ROUNDS always had (name/band/need) — round one is a // The tournament, PER LEAGUE. Same shape CUP_ROUNDS always had (name/band/need) — round one is a
// single game so a run opens fast, and the body's pool still scales with `need` (matchOpponent). // single game so a run opens fast, and the body's pool still scales with `need` (matchOpponent).
+4 -4
View File
@@ -913,11 +913,11 @@ function spendSlot(i) {
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde // (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
// instead of spending it now. G.weapon picks which tin the next serve throws. // instead of spending it now. G.weapon picks which tin the next serve throws.
const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
// THE TIN TIERS (the leagues): the league deepens the pot the duel pays from — a barrel weapon // THE WEAPON LICENCE (the leagues): the league licenses a weapon, the run's `from` gate paces
// joins the ladder when the RUN unlocks it (as ever) OR when the LEAGUE says it is yours // it — BOTH must hold (config.TIN_LEAGUE; barrels apply the same licence in pickBarrelWeapon).
// (config.TIN_LEAGUE). Pure and exported so the suite can pin the pot. // Pure and exported so the suite can pin the pot.
export const tinLadder = () => [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => export const tinLadder = () => [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w =>
G.runGames >= BARREL_WEAPONS[w].from || prof.lg >= (TIN_LEAGUE[w] || 99))]; G.runGames >= BARREL_WEAPONS[w].from && prof.lg >= (TIN_LEAGUE[w] || 0))];
export function deliverTin() { export function deliverTin() {
// WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that // WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that
// is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won // is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won
+1 -1
View File
@@ -236,7 +236,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // 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 // 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. // many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 149; export const SPRV = 150;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // 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 // 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". // last sheet has landed. See the onerror note below for the other half of "sometimes missing".
+5 -2
View File
@@ -3,7 +3,7 @@
// your baseline — one crossing it is a point for THEM. // your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
@@ -11,7 +11,7 @@ import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
import { award, bonus } from './profile.js'; import { award, bonus, prof } from './profile.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -86,6 +86,9 @@ function pickBarrelWeapon() {
const pool = []; const pool = [];
for (const [id, b] of Object.entries(BARREL_WEAPONS)) { for (const [id, b] of Object.entries(BARREL_WEAPONS)) {
if (G.runGames < b.from) continue; if (G.runGames < b.from) continue;
// THE WEAPON LICENCE (the leagues): the league licenses, `from` paces — a barrel must not
// hand a league-1 player the laser his duels rightly refuse (config.TIN_LEAGUE)
if (prof.lg < (TIN_LEAGUE[id] || 0)) continue;
if (id === lastBarrel) continue; // never twice running... if (id === lastBarrel) continue; // never twice running...
for (let k = 0; k < b.weight; k++) pool.push(id); for (let k = 0; k < b.weight; k++) pool.push(id);
} }
+7 -7
View File
@@ -2296,19 +2296,19 @@ test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shippe
assert.ok(TUNE.racketQMax + TUNE.duelQBonus < 1, 'its returns never reach perfect quality'); assert.ok(TUNE.racketQMax + TUNE.duelQBonus < 1, 'its returns never reach perfect quality');
}); });
test('THE TIN TIERS: the league deepens the duel pot, and league 5 pays double', () => { test('THE WEAPON LICENCE: the league licenses, the run paces — tins and barrels agree, league 5 pays double', () => {
G.runGames = 0; G.runGames = 0;
prof.prof.lg = 5;
assert.ok(!tinLadder().includes('laser'), 'even the top league waits for the run to reach lasers');
G.runGames = 12;
prof.prof.lg = 1; prof.prof.lg = 1;
assert.ok(!tinLadder().includes('laser') && !tinLadder().includes('rocket'), assert.ok(!tinLadder().includes('laser') && !tinLadder().includes('rocket'),
'league one, game zero: the shipped pot'); 'league one NEVER sees a laser, however deep the run — that was the barrel complaint');
prof.prof.lg = 3; prof.prof.lg = 3;
assert.ok(tinLadder().includes('laser') && !tinLadder().includes('rocket'), assert.ok(tinLadder().includes('laser') && !tinLadder().includes('rocket'),
'league three adds the laser, not the rocket'); 'league three licenses the laser, not the rocket');
prof.prof.lg = 4; prof.prof.lg = 4;
assert.ok(tinLadder().includes('rocket'), 'league four adds the rocket'); assert.ok(tinLadder().includes('rocket'), 'league four licenses the rocket');
prof.prof.lg = 1;
G.runGames = 12;
assert.ok(tinLadder().includes('laser'), 'the run\'s own barrel gates still work without a league');
G.runGames = 0; G.runGames = 0;
// the double tin: an empty rack, one delivery, twice the stock at league five // the double tin: an empty rack, one delivery, twice the stock at league five
const stockOf = id => { const i = G.rack.findIndex(r => r.id === id); return i < 0 ? 0 : G.rack[i].n; }; const stockOf = id => { const i = G.rack.findIndex(r => r.id === id); return i < 0 ? 0 : G.rack[i].n; };