Public: anyone can read this; the crew can change it.

Migrated from github.com/amirmushichge/pixel-plays

Welly Toss rules: a tested `welly-toss/throw.js` where you swing a boot and let go at the right moment of the arc to land it past the marker flags, against a seeded crosswind, from plain data, in a two-file pull with a test file that runs in under a second #323

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/pixel-plays:torque/309-welly-toss-rules-a-tested-welly-toss-thr → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/309-welly-toss-rules-a-tested-welly-toss-thr was merged into main as 79548c6.

Review

Sign in to review this change.
2 files changed +271 −0
welly-toss/throw.js added +136
@@ -0,0 +1,136 @@
1 'use strict';
2 // The Welly Toss rules: swing a rubber boot back and forth on a pendulum, let go at the right moment of the arc, and
3 // land it past the marker flags. A seeded crosswind drifts each throw sideways; land outside the side lines and it is
4 // a foul. Five throws; the best three distances count. Plain data in, plain data out, no DOM, no timers, no
5 // Math.random: the winds come only from the seed. A refused call returns the very same state and says why.
6 //
7 // Units: metres, seconds, degrees, m/s. One tick = TICK_S seconds; the swing has PERIOD_TICKS ticks per period.
8 // Swing (a pendulum driven as simple harmonic motion, small-angle form):
9 // arm angle θ(t) = −AMP·cos(2πt/T) degrees from hanging straight down; t = 0 is the back extreme, forward is +
10 // arm speed ∝ sin(2πt/T) forward while 0 ≤ t ≤ T/2 (the forward swing), negative after (the backswing)
11 // Release (the boot leaves along the tangent of the arc):
12 // launch elevation α = θ, launch speed v = VMAX·sin(2πt/T)
13 // Releasing on the backswing (t > T/2) throws the boot backwards: a foul. α ≤ 0 drops it at your feet (0 m).
14 // Flight over flat ground (projectile motion, no drag; e.g. Halliday & Resnick, ch. 4):
15 // time of flight tf = 2·v·sin α / g
16 // distance d = v²·sin 2α / g
17 // drift y = WIND_DRAG·wind·tf (the boot is only part-carried by the wind: WIND_DRAG of its speed)
18 // Foul when |y| > SIDE (the side lines at ±SIDE m). Score = sum of the best three non-foul distances.
19 (function (root) {
20 const THROWS = 5, BEST_OF = 3;
21 const TICK_S = 0.05, PERIOD_TICKS = 60; // period T = 3 s
22 const AMP = 65; // degrees, swing amplitude
23 const VMAX = 22; // m/s, speed at the bottom of the arc
24 const G = 9.81; // m/s²
25 const WIND_MAX = 4; // m/s: each throw's crosswind is within ±WIND_MAX, to 0.1
26 const WIND_DRAG = 0.6; // fraction of the wind speed the boot picks up
outdated

WIND_MAX 4 with WIND_DRAG 0.3 caps drift near 2.75 m, below SIDE 5, so the side-line foul is unreachable from start(). Retune so an in-range wind can foul.

27 const SIDE = 3; // m, side lines at ±SIDE
28 const FLAGS = [10, 20, 30, 40]; // m, marker flags
29
30 // mulberry32 as a pure step: next(seed) -> [value in [0,1), nextSeed]
31 function next(r) {
32 r = (r + 0x6D2B79F5) >>> 0;
33 let t = r;
34 t = Math.imul(t ^ (t >>> 15), t | 1);
35 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
36 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, r];
37 }
38
39 const round = (x, d) => { const k = Math.pow(10, d); const v = Math.round(x * k) / k; return v === 0 ? 0 : v; };
40 const rad = d => d * Math.PI / 180;
41 const phaseOf = tick => tick / PERIOD_TICKS;
42 const angleAt = tick => -AMP * Math.cos(2 * Math.PI * phaseOf(tick));
43 const speedAt = tick => VMAX * Math.sin(2 * Math.PI * phaseOf(tick));
44
45 // flagFor(distance) -> the furthest flag at or before the landing point, or null when short of the first
46 function flagFor(d) { let f = null; for (const m of FLAGS) if (d >= m) f = m; return f; }
47
48 // fly(tick, wind) -> the resolved throw for a release at `tick` in `wind`
49 function fly(tick, wind) {
50 const v = speedAt(tick), a = angleAt(tick);
51 const base = { tick, wind, angle: round(a, 1), speed: round(v, 2) };
52 if (tick > PERIOD_TICKS / 2) return { ...base, foul: true, why: 'backswing', distance: 0, drift: 0, flag: null, time: 0 };
53 if (a <= 0 || v <= 0) return { ...base, foul: false, why: '', distance: 0, drift: 0, flag: null, time: 0 };
54 const time = 2 * v * Math.sin(rad(a)) / G;
55 const distance = v * v * Math.sin(2 * rad(a)) / G;
56 const drift = WIND_DRAG * wind * time;
57 if (Math.abs(drift) > SIDE) return { ...base, foul: true, why: 'side line', distance: round(distance, 2), drift: round(drift, 2), flag: null, time: round(time, 3) };
58 return { ...base, foul: false, why: '', distance: round(distance, 2), drift: round(drift, 2), flag: flagFor(distance), time: round(time, 3) };
59 }
60
61 // The forward-swing tick that lands furthest in still air (the stated best phase), found once from the formulas.
62 let BEST_TICK = 0, bestD = -1;
63 for (let t = 0; t <= PERIOD_TICKS / 2; t++) { const d = fly(t, 0).distance; if (d > bestD) { bestD = d; BEST_TICK = t; } }
64 const BEST_PHASE = BEST_TICK / PERIOD_TICKS;
65
66 // score(throws) -> sum of the best BEST_OF non-foul distances, to the centimetre
67 function score(throws) {
68 const ds = throws.filter(t => !t.foul).map(t => t.distance).sort((a, b) => b - a).slice(0, BEST_OF);
69 return round(ds.reduce((s, d) => s + d, 0), 2);
70 }
71
72 // furthestFlag(throws) -> the furthest flag any non-foul throw reached, or null
73 function furthestFlag(throws) {
74 let f = null;
75 for (const t of throws) if (!t.foul && t.flag !== null && (f === null || t.flag > f)) f = t.flag;
76 return f;
77 }
78
79 function describe(s) {
80 if (s.status === 'done') {
81 const f = furthestFlag(s.throws);
82 return `Final: ${s.score} m from the best ${BEST_OF} of ${THROWS} throws; furthest flag ${f === null ? 'none reached' : f + ' m'}`;
83 }
84 if (s.status === 'flying') return `Throw ${s.throws.length + 1} of ${THROWS} in the air`;
85 return `Throw ${s.throws.length + 1} of ${THROWS}: wind ${s.winds[s.throws.length]} m/s, swing tick ${s.tick}`;
86 }
87
88 // start(seed) -> THROWS winds drawn from the seed, the lines and the flags, the swing at its back extreme
89 function start(seed) {
90 const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0;
91 let rng = sd;
92 const winds = [];
93 for (let i = 0; i < THROWS; i++) {
94 let v; [v, rng] = next(rng);
95 winds.push(round((v * 2 - 1) * WIND_MAX, 1));
96 }
97 const s = { seed: sd, winds, side: SIDE, flags: FLAGS.slice(), tick: 0, throws: [], flight: null, status: 'swinging', score: 0, line: '' };
98 s.line = describe(s);
99 return s;
100 }
101
102 // step(state) -> the next tick: the swing advances (and wraps), or a flight in the air lands when its time is up
103 function step(s) {
104 if (s.status === 'done') return s;
105 const n = { ...s };
106 if (s.status === 'flying') {
107 const left = s.flight.left - 1;
108 if (left > 0) n.flight = { ...s.flight, left };
109 else {
110 n.throws = s.throws.concat([s.flight.result]);
111 n.flight = null;
112 n.tick = 0;
113 n.score = score(n.throws);
114 n.status = n.throws.length >= THROWS ? 'done' : 'swinging';
115 }
116 } else n.tick = (s.tick + 1) % PERIOD_TICKS;
117 n.line = describe(n);
118 return n;
119 }
120
121 // release(state) -> {ok, state, why, throw}: let go now. Refused during a flight or once the contest is over.
122 function release(s) {
123 if (s.status === 'done') return { ok: false, state: s, why: 'the contest is over: five throws taken', throw: null };
124 if (s.status === 'flying') return { ok: false, state: s, why: 'the boot is still in the air', throw: null };
125 const result = fly(s.tick, s.winds[s.throws.length]);
126 const n = { ...s, status: 'flying', flight: { result, left: Math.max(1, Math.ceil(result.time / TICK_S)) } };
127 n.line = describe(n);
128 return { ok: true, state: n, why: '', throw: result };
129 }
130
131 const api = {
132 THROWS, BEST_OF, TICK_S, PERIOD_TICKS, AMP, VMAX, G, WIND_MAX, WIND_DRAG, SIDE, FLAGS, BEST_TICK, BEST_PHASE,
133 start, step, release, fly, score, furthestFlag, flagFor, describe,
134 };
135 if (typeof module !== 'undefined') module.exports = api; else root.WellyToss = api;
136 })(typeof window !== 'undefined' ? window : globalThis);
welly-toss/throw.test.mjs added +135
@@ -0,0 +1,135 @@
1 import { test, expect } from 'bun:test';
2 import {
3 THROWS, PERIOD_TICKS, WIND_MAX, SIDE, FLAGS, BEST_TICK, G, VMAX, AMP,
4 start, step, release, fly, score, furthestFlag, flagFor,
5 } from './throw.js';
6
7 // swing to `tick`, then let go
8 const FIXED_DISTANCES = [33.54, 0, 1.7, 0, 33.54], FIXED_SCORE = 68.78; // by hand: 33.54 + 33.54 + 1.70
9 const swingTo = (s, tick) => { while (s.tick !== tick) s = step(s); return s; };
10 // throw once at `tick` with a chosen wind, then fly it to the ground
11 const toss = (s, tick, wind) => {
12 const w = s.winds.slice(); w[s.throws.length] = wind;
13 s = swingTo({ ...s, winds: w }, tick);
14 s = release(s).state;
15 while (s.status === 'flying') s = step(s);
16 return s;
17 };
18
19 test('start: seeded winds within the stated range, side lines and flags stated; same seed, same contest', () => {
20 const a = start(7), b = start(7), c = start(8);
21 expect(a.winds.length).toBe(THROWS);
22 expect(a.winds).toEqual(b.winds);
23 expect(a.winds).not.toEqual(c.winds);
24 const all = [];
25 for (let sd = 0; sd < 200; sd++) all.push(...start(sd).winds);
26 expect(Math.min(...all)).toBeLessThan(-WIND_MAX * 0.9);
27 expect(Math.max(...all)).toBeGreaterThan(WIND_MAX * 0.9);
28 for (const w of all) expect(Math.abs(w)).toBeLessThanOrEqual(WIND_MAX);
29 expect([a.side, a.flags, a.tick, a.status]).toEqual([SIDE, FLAGS, 0, 'swinging']);
30 expect(start(NaN).seed).toBe(0);
31 const playA = toss(toss(a, 20, a.winds[0]), 25, a.winds[1]), playB = toss(toss(b, 20, b.winds[0]), 25, b.winds[1]);
32 expect(playA).toEqual(playB);
33 });
34
35 test('step: the swing advances one tick and wraps at the period', () => {
36 let s = start(1);
37 expect(step(s).tick).toBe(1);
38 s = swingTo(s, PERIOD_TICKS - 1);
39 expect(step(s).tick).toBe(0);
40 });
41
42 test('release at the best phase in still air matches the hand calculation and beats an early and a late release', () => {
43 expect(BEST_TICK).toBeGreaterThan(0);
44 expect(BEST_TICK).toBeLessThan(PERIOD_TICKS / 2);
45 const best = fly(BEST_TICK, 0), early = fly(BEST_TICK - 8, 0), late = fly(BEST_TICK + 8, 0);
46 expect(best.distance).toBeGreaterThan(early.distance);
47 expect(best.distance).toBeGreaterThan(late.distance);
48 for (let t = 0; t <= PERIOD_TICKS / 2; t++) expect(fly(t, 0).distance).toBeLessThanOrEqual(best.distance);
49 // by hand: d = v² sin(2α) / g with α = −AMP cos(2πt/T), v = VMAX sin(2πt/T)
50 const ph = 2 * Math.PI * BEST_TICK / PERIOD_TICKS, a = -AMP * Math.cos(ph) * Math.PI / 180, v = VMAX * Math.sin(ph);
51 expect(best.distance).toBeCloseTo(v * v * Math.sin(2 * a) / G, 2);
52 expect(best.foul).toBe(false);
53 expect(best.drift).toBe(0);
54 expect(best.flag).toBe(flagFor(best.distance));
55 expect(best.flag).toBeGreaterThanOrEqual(30);
56 });
57
58 test('the wind drifts a throw sideways, in its own direction, and not its distance', () => {
59 const calm = fly(BEST_TICK, 0), left = fly(BEST_TICK, -2), right = fly(BEST_TICK, 2);
60 expect(calm.drift).toBe(0);
61 expect(right.drift).toBeGreaterThan(0);
62 expect(left.drift).toBe(-right.drift);
63 expect(right.distance).toBe(calm.distance);
64 expect(right.foul).toBe(false);
65 });
66
67 test('a wind from start(seed) can foul a best-phase throw, and a mild one stays inside the lines', () => {
68 // a seed whose first wind fouls the best-phase throw, and one whose first wind keeps it clean
69 let foulSeed = -1, cleanSeed = -1;
70 for (let sd = 0; sd < 200 && (foulSeed < 0 || cleanSeed < 0); sd++) {
outdated

Uses a wind of WIND_MAX*2, outside what start() can produce. Use an in-range wind, or the test passes for a foul no player can get.

71 const f = fly(BEST_TICK, start(sd).winds[0]);
72 if (f.foul && foulSeed < 0) foulSeed = sd;
73 if (!f.foul && cleanSeed < 0) cleanSeed = sd;
74 }
75 expect(foulSeed).toBeGreaterThanOrEqual(0);
76 expect(cleanSeed).toBeGreaterThanOrEqual(0);
77 const s = toss(start(foulSeed), BEST_TICK, start(foulSeed).winds[0]);
78 const f = s.throws[0];
79 expect(Math.abs(f.drift)).toBeGreaterThan(SIDE);
80 expect(Math.abs(start(foulSeed).winds[0])).toBeLessThanOrEqual(WIND_MAX);
81 expect([f.foul, f.why, f.flag]).toEqual([true, 'side line', null]);
82 expect(score([f])).toBe(0);
83 expect(toss(start(cleanSeed), BEST_TICK, start(cleanSeed).winds[0]).throws[0].foul).toBe(false);
84 expect(fly(BEST_TICK, 2).foul).toBe(false);
85 });
86
87 test('a backswing release is a foul; the forward swing is not', () => {
88 const f = fly(PERIOD_TICKS / 2 + 1, 0);
89 expect([f.foul, f.why, f.distance]).toEqual([true, 'backswing', 0]);
90 expect(fly(PERIOD_TICKS / 2, 0).foul).toBe(false);
91 let s = swingTo(start(3), 40);
92 const r = release(s);
93 expect(r.ok).toBe(true);
94 expect(r.throw.why).toBe('backswing');
95 });
96
97 test('release refuses during a flight and after the fifth throw, with a reason and the same state', () => {
98 let s = swingTo(start(4), BEST_TICK);
99 const r = release(s);
100 expect(r.ok).toBe(true);
101 expect(r.state.status).toBe('flying');
102 const again = release(r.state);
103 expect([again.ok, again.state, again.why]).toEqual([false, r.state, 'the boot is still in the air']);
104 s = r.state;
105 while (s.status === 'flying') s = step(s);
106 expect(s.throws.length).toBe(1);
107 expect(s.status).toBe('swinging');
108 expect(s.tick).toBe(0);
109 for (let i = 1; i < THROWS; i++) s = toss(s, BEST_TICK, 0);
110 expect(s.status).toBe('done');
111 const over = release(s);
112 expect([over.ok, over.state, over.why]).toEqual([false, s, 'the contest is over: five throws taken']);
113 expect(step(s)).toBe(s);
114 });
115
116 test('score: the best three non-foul distances, fouls among the throws ignored', () => {
117 const d = (distance, foul = false) => ({ distance, foul, flag: foul ? null : flagFor(distance) });
118 expect(score([d(12.5), d(0, true), d(33.25), d(21), d(5)])).toBe(66.75);
119 expect(score([d(10), d(0, true), d(0, true), d(0, true), d(4)])).toBe(14);
120 expect(score([d(0, true)])).toBe(0);
121 expect(furthestFlag([d(12.5), d(45, true), d(33.25)])).toBe(30);
122 expect(furthestFlag([d(3)])).toBe(null);
123 expect([flagFor(9.99), flagFor(10), flagFor(41)]).toEqual([null, 10, 40]);
124 });
125
126 test('the end line reports the best-three score and the furthest flag reached', () => {
127 let s = start(5);
128 const plan = [[BEST_TICK, 0], [BEST_TICK - 8, 0], [BEST_TICK + 8, 0], [40, 0], [BEST_TICK, 0]];
129 for (const [t, w] of plan) s = toss(s, t, w);
130 expect(s.status).toBe('done');
131 expect(s.throws.map(t => t.foul)).toEqual([false, false, false, true, false]);
132 expect(s.throws.map(t => t.distance)).toEqual(FIXED_DISTANCES);
133 expect(s.score).toBe(FIXED_SCORE);
134 expect(s.line).toBe(`Final: ${FIXED_SCORE} m from the best 3 of 5 throws; furthest flag 30 m`);
135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs