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
Check · Review · Land · Live done — Landed and live in production.
torque/pixel-plays:torque/309-welly-toss-rules-a-tested-welly-toss-thr → main 2 commits fork 2 threads · 2 unresolved
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 | |
| 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 | }); |
new above
offline
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.