SERVE UP was dropping you into gore whatever you picked
THE MENU BUG, and it explains three reports at once. cStart's handler was
`addEventListener('click', start)` — and addEventListener hands a listener the
MouseEvent as its first argument, so `start(modeId = pickedMode)` never saw its
default: modeId was an EVENT OBJECT. `G.classic = modeId === 'classic'` was
therefore always false and the zombie branch never fired. Picking CLASSIC or
ZOMBIE TENNIS on the setup screen did nothing at all; SERVE UP always started
the gore mode. A default parameter only applies to `undefined`, and a click
handler always passes something. `() => start()`.
That is also why "in normal tennis i dont see the ball circles and the serve
hint bar": there was no normal tennis to be in. Both are drawn unconditionally
in render() and the tennis struggle hints are already mode-gated correctly.
Verified through the real menu after the fix: CLASSIC gives mode=tennis with
no bodies on court, ZOMBIE gives match=true against the SHAMBLER in its own
body. Every other bare handler in the codebase takes no parameters — checked.
THE MOP BOY had the same class of bug one layer down. sendBoy was called and
mopWorthIt() refused: it tests `d.z >= COURT.netZ - 2`, the NEAR half, because
in the gore mode everything dies on the near court. A match's blood is all on
the FAR side, where the body stands and bleeds through four wound stages, so
the boy could never qualify. The near-side bias stays; the match is the
exception.
DANIEL was being dropped, not missing. Two causes, both mine: the asides were
queued at 0.7-1.5 s and matchPoint fires the score call at 0.4 s with a clip
over a second long — voice() drops anything that would start while he is still
talking (one man, one microphone). And asideEvery is 55 s, a whole match game.
Delays moved past the score call; matchAsideEvery is 15 s, which fits a mode
whose events are a limb and your blood rather than a wave of bodies.
THE FALL: quicker down, longer on the floor (0.09/0.11/2.0/0.30). And it was
genuinely SHRUNKEN — the generator draws fall rows smaller than standing rows
(the brute's buckle came back 190 px against a 224 px stance) and _zsheet's one
global scale preserved that faithfully, so the body shrank the moment it
started to fall. A fall row is anchored on its own buckle now, which must draw
at standing height. All seven generated families re-cut: 224/153/98/181 against
the hand-made racket family's 224/154/85/179 — the convergence is the check.
THE FLICK: 0.42 made going out impossible, which fixed the 8-in-10 bug and
overshot it ("too easy. i must be able to play out"). 0.62 puts risk back where
it belongs — ~15% out at quality 0, 5% at 0.75, and never on a perfect flick.
The test now asserts that shape: bounded, monotonically falling, zero at 1.
59 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -96,11 +96,28 @@ def cut(sheet, fam, outdir):
|
||||
px = big[big[..., 3] > 200][:, :3]
|
||||
pal = np.unique((px // 8) * 8, axis=0)
|
||||
|
||||
# A FALL ROW IS ANCHORED ON ITS OWN BUCKLE, not on the sheet. The generator draws the fall rows
|
||||
# noticeably smaller than the standing rows — the brute's buckle came back at 190 px against a
|
||||
# 224 px stance — and one global scale preserves that faithfully, so the body SHRANK the moment
|
||||
# it started to fall ("the fall animation seems shrunken"). The first frame of a fall is a body
|
||||
# still on its feet, so it must draw at standing height; the other three are scaled by the same
|
||||
# factor, which keeps the collapse's own proportions intact. A row that was already right (the
|
||||
# racket family's, at 224/224) gets a factor of 1.0 and is untouched — which is the check that
|
||||
# this rule is the right one rather than a fudge for one bad sheet.
|
||||
mult = [1.0] * len(crops)
|
||||
i = 0
|
||||
for row in want:
|
||||
if row[0].endswith('fall1') and crops[i].shape[0]:
|
||||
k_row = crops[0].shape[0] / crops[i].shape[0]
|
||||
for j in range(i, i + len(row)):
|
||||
mult[j] = k_row
|
||||
i += len(row)
|
||||
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
for c, name in zip(crops, flat):
|
||||
for c, name, m in zip(crops, flat, mult):
|
||||
h, w = c.shape[:2]
|
||||
snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
|
||||
im = snap.resize((max(1, round(snap.width * k)), max(1, round(snap.height * k))), Image.BOX)
|
||||
im = snap.resize((max(1, round(snap.width * k * m)), max(1, round(snap.height * k * m))), Image.BOX)
|
||||
arr = np.array(im).astype(int)
|
||||
solid = arr[..., 3] > 110
|
||||
f = arr[..., :3].reshape(-1, 3)
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=193"></script>
|
||||
<script type="module" src="src/main.js?v=194"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -364,7 +364,9 @@ function matchReady() {
|
||||
// fall3 is the one that HOLDS: "the opponent should fall and then lie there for a quick moment, the
|
||||
// player stands up too quick." A corpse that touches the ground and is instantly upright reads as a
|
||||
// stumble; the beat only lands if it is down long enough to look beaten.
|
||||
const FALL = [['fall1', 0.14], ['fall2', 0.16], ['fall3', 0.62], ['rise', 0.36]];
|
||||
// "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth
|
||||
// of a second — and the time is spent on the floor, where it reads.
|
||||
const FALL = [['fall1', 0.09], ['fall2', 0.11], ['fall3', 2.0], ['rise', 0.30]];
|
||||
export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0);
|
||||
|
||||
function startFall(from) {
|
||||
@@ -468,7 +470,10 @@ function matchPoint(side) {
|
||||
aww(0.8);
|
||||
// ...and the umpire notices, drily. Situational, never a pool on a timer: the LOW line only
|
||||
// exists once the blood is nearly gone, so it lands as a warning rather than as commentary.
|
||||
aside(G.hp <= 2 ? 'ump_low_blood' : (score.pts[1] % 2 ? 'ump_wound_you2' : 'ump_wound_you'), 0.7);
|
||||
// AFTER the score call, not under it. voice() drops any line that would start while the umpire
|
||||
// is still talking (one man, one microphone) — and matchPoint calls scoreKey() at 0.4 s with a
|
||||
// clip over a second long, so every one of these was being swallowed silently.
|
||||
aside(G.hp <= 2 ? 'ump_low_blood' : (score.pts[1] % 2 ? 'ump_wound_you2' : 'ump_wound_you'), 2.2);
|
||||
if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever
|
||||
}
|
||||
const over = advancePoint(side); // banks the game and resets the points itself
|
||||
@@ -479,7 +484,7 @@ function matchPoint(side) {
|
||||
// A LIMB IS WORTH A REMARK. Two lines, alternating on the stage taken, so the same sentence
|
||||
// never lands twice in one game — a repeated aside reads as a stuck HUD, the same reason the
|
||||
// praise words cycle rather than roll.
|
||||
aside(fellFrom ? 'ump_wound_opp2' : 'ump_wound_opp', 0.9);
|
||||
aside(fellFrom ? 'ump_wound_opp2' : 'ump_wound_opp', 2.2); // ...after the score call, see above
|
||||
return startFall(fellFrom); // it goes down, and gets up short of a limb
|
||||
}
|
||||
matchReady(); // next point, same opponent, both on their marks
|
||||
@@ -495,7 +500,7 @@ function matchGameWon(zb, banked = false) {
|
||||
G.runGames++; // the ladder metric, same as every mode
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
|
||||
voice('game_player', 0.3); cheer(2.4, 0.6);
|
||||
aside('ump_felled', 1.5); // ...over the applause, as he would
|
||||
aside('ump_felled', 2.8); // ...after 'game', over the applause
|
||||
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
|
||||
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
|
||||
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
|
||||
|
||||
@@ -31,7 +31,12 @@ export const TUNE = {
|
||||
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
|
||||
// so this is the corner with 17 cm to spare. Raising it past the line does
|
||||
// not make the flick punchier, it makes a perfect one go out.
|
||||
flickReach: 0.42, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
|
||||
flickReach: 0.62, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
|
||||
// 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then
|
||||
// overshot it: "flicking: too easy. i must be able to play out." At 0.62
|
||||
// a mistimed flick CAN sail (~15% of the time at quality 0, on the worst
|
||||
// half of the scatter) and a perfectly timed one never can — the risk is
|
||||
// back and it is timing, not a coin toss, that decides it.
|
||||
// Reach x quality must stay under the sideline once aimError is added on
|
||||
// top, or the flick goes out — which is exactly the bug this fixed:
|
||||
// 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
|
||||
@@ -184,6 +189,10 @@ export const TUNE = {
|
||||
bossHopX: 2.0, // m of ground one hop covers (the eased arc lands it at about 2/pi of this)
|
||||
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
|
||||
// is why he read as "the boss" and why a real boss would not have stood out
|
||||
matchAsideEvery: 15, // ...but 55 s is a whole match game. In the match his asides are tied to
|
||||
// things that happen every point or two — a limb, your blood — so the gore
|
||||
// spacing muted him almost entirely. Same ONE cooldown mechanism, a value
|
||||
// that fits the mode's own event rate.
|
||||
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
|
||||
// acknowledged that the match had changed; now he comments on it, disapprovingly.
|
||||
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
|
||||
|
||||
@@ -482,7 +482,7 @@ export function aside(name, delay = 0.2) {
|
||||
if (G.mode !== 'splatter' || G.modal || G.changeover || G.card) return;
|
||||
if (G.interlude && !G.match) return;
|
||||
if (G.asideT > 0) return;
|
||||
G.asideT = TUNE.asideEvery;
|
||||
G.asideT = G.match ? TUNE.matchAsideEvery : TUNE.asideEvery;
|
||||
voice(name, delay, 0.95);
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,13 @@ document.getElementById('nbSend').addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nbSend').click(); });
|
||||
document.getElementById('cStart').addEventListener('click', start);
|
||||
// () => start(), NEVER `start` — addEventListener hands the listener a MouseEvent as its first
|
||||
// argument, so `start(modeId = pickedMode)` never saw its default and modeId was an EVENT OBJECT.
|
||||
// `G.classic = modeId === 'classic'` was therefore always false and the zombie branch never fired:
|
||||
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
|
||||
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
|
||||
// handler always passes something.
|
||||
document.getElementById('cStart').addEventListener('click', () => start());
|
||||
for (const id of ['sBack', 'stBack', 'rkBack', 'scBack'])
|
||||
document.getElementById(id).addEventListener('click', () => go('splash'));
|
||||
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
|
||||
|
||||
@@ -195,7 +195,7 @@ export function proj(x, y, z) {
|
||||
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
|
||||
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
|
||||
// many: the one that does not get bumped is the one that serves a cached 404.
|
||||
export const SPRV = 103;
|
||||
export const SPRV = 104;
|
||||
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
|
||||
// already requested at import — it just makes the wait visible and holds the menu back until the
|
||||
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
|
||||
|
||||
@@ -198,7 +198,12 @@ export function setupPoint() {
|
||||
// Is there a stain worth walking out for? Must match mopRound's own target test, or he walks on,
|
||||
// finds nothing he can reach, and walks straight off again — which is exactly what Oliver saw:
|
||||
// "the mopboy sometimes moves over the court without anything to mop".
|
||||
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && d.z >= COURT.netZ - 2);
|
||||
// ...and in a MATCH the mess is on the FAR side, because that is where the body stands and bleeds
|
||||
// through four wound stages. This test was written for the gore mode, where everything dies on the
|
||||
// near court, and it silently made the boy impossible in the match: sendBoy was called, found no
|
||||
// stain it considered worth walking out for, and returned. The near-side bias is right everywhere
|
||||
// else, so the match is the exception rather than the rule being widened.
|
||||
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && (G.match || d.z >= COURT.netZ - 2));
|
||||
|
||||
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
|
||||
// The sudden-death duel is clean tennis: nobody mops through it. THE MATCH IS THE EXCEPTION — it
|
||||
|
||||
@@ -668,24 +668,29 @@ test('the first opponent cannot paint lines: its returns are width-clamped', ()
|
||||
assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung');
|
||||
});
|
||||
|
||||
test('an AIMED return cannot be sent out by its own aim, at any timing', () => {
|
||||
test('an aimed return can be played OUT, but only by mistiming it', () => {
|
||||
// The bug this pins: an aimed shot targeted a flat 3.3 m and THEN took err() on top — +-aimError
|
||||
// at quality 0 — so a flicked return landed as wide as 5.7 m against a 4.115 m singles line and
|
||||
// went out about eight times in ten, while an unaimed one (0 + the same scatter) was always in.
|
||||
// Flicking, the mode's one act of skill, was what lost the point. Reach scales with the same
|
||||
// quality the scatter shrinks with, so the two can never sum past the line — that sum is the
|
||||
// whole rule and it is arithmetic, so assert the arithmetic rather than a sampled rally.
|
||||
// The contract is NOT "never out" — that was the overcorrection ("too easy. i must be able to
|
||||
// play out"). It is: going out is possible and BOUNDED, it gets rarer as timing improves, and a
|
||||
// perfectly timed flick can never do it. Risk decided by skill rather than by a coin toss.
|
||||
const line = COURT.W / 2, aimW = TUNE.flickWidth; // doHit's far-court aim width
|
||||
for (let q = 0; q <= 1.0001; q += 0.05) {
|
||||
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
|
||||
const worst = aimW * reach + TUNE.aimError * (1 - q);
|
||||
assert.ok(worst <= line,
|
||||
`aimed reach + worst scatter must stay inside the line at quality ${q.toFixed(2)}: ` +
|
||||
`${worst.toFixed(2)} vs ${line.toFixed(3)}`);
|
||||
}
|
||||
// ...and it must still be worth flicking: perfect timing keeps the full width.
|
||||
assert.ok(aimW * (TUNE.flickReach + (1 - TUNE.flickReach)) > line * 0.75,
|
||||
'a perfectly timed flick still reaches the corner');
|
||||
const pOut = q => {
|
||||
const reach = aimW * (TUNE.flickReach + (1 - TUNE.flickReach) * q);
|
||||
const e = TUNE.aimError * (1 - q);
|
||||
return e <= 0 ? 0 : Math.max(0, Math.min(1, (e - (line - reach)) / (2 * e)));
|
||||
};
|
||||
assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%');
|
||||
assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)');
|
||||
assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out');
|
||||
for (let q = 0.05; q <= 1.0001; q += 0.05)
|
||||
assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);
|
||||
// ...and it must still be worth flicking: perfect timing reaches the corner.
|
||||
assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner');
|
||||
});
|
||||
|
||||
test('a wounding point knocks the body down and it gets up MINUS the limb', () => {
|
||||
|
||||