Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Apple Bobbing rules: a tested `apple-bobbing/tub.js` where you dunk for seeded bobbing apples at the top of their bob before your breaths run out, in a two-file pull with a test file that runs in under a second #357
Check · Review · Land · Live done — Landed and live in production.
torque/pixel-plays:torque/333-apple-bobbing-rules-a-tested-apple-bobbi → main 1 commit fork
2 files
changed +182
−0
apple-bobbing/tub.js added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | 'use strict'; | |
| 2 | // The Apple Bobbing rules: apples bob in a tub on seeded periods; aim at one and dunk on a tick. The dunk catches | |
| 3 | // it only at the top of its bob; a miss splashes and costs a breath. Plain data in, plain data out, no DOM, no | |
| 4 | // timers, no Math.random: every random draw comes from the seed, so one seed and the same calls give one round. | |
| 5 | // A refused call returns the very same state and says why. | |
| 6 | // | |
| 7 | // Units: time is whole ticks; an apple has period P (ticks per bob) and phase F (ticks), and is at the top of its | |
| 8 | // bob on every tick t where (t + F) mod P = 0. Its offset from the top at tick t is the circular distance | |
| 9 | // d(t) = min(m, P - m), m = (t + F) mod P. | |
| 10 | // A dunk catches when d <= WINDOW (the catch window, in ticks either side of the top); d = 0 is the exact top. | |
| 11 | // Score: EXACT_POINTS for d = 0, WINDOW_POINTS for 0 < d <= WINDOW. | |
| 12 | // PRNG: mulberry32 (Tommy Ettinger's public-domain 32-bit generator). | |
| 13 | (function (root) { | |
| 14 | const APPLES = 5, BREATHS = 3, TICKS = 60, WINDOW = 1, EXACT_POINTS = 30, WINDOW_POINTS = 10; | |
| 15 | const MIN_PERIOD = 6, MAX_PERIOD = 12; | |
| 16 | ||
| 17 | const reject = (state, why) => ({ ok: false, state, why }); | |
| 18 | ||
| 19 | function rng(seed) { | |
| 20 | let a = seed >>> 0; | |
| 21 | return () => { | |
| 22 | a = (a + 0x6D2B79F5) >>> 0; | |
| 23 | let t = a; | |
| 24 | t = Math.imul(t ^ (t >>> 15), t | 1); | |
| 25 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| 26 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 30 | // offset(apple, tick) -> ticks from the top of the bob, 0 = exact top | |
| 31 | function offset(apple, tick) { | |
| 32 | const m = (tick + apple.phase) % apple.period; | |
| 33 | return Math.min(m, apple.period - m); | |
| 34 | } | |
| 35 | ||
| 36 | const pl = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; | |
| 37 | ||
| 38 | function describe(s) { | |
| 39 | const tail = `score ${s.score}, ${pl(s.caught, 'apple')} caught of ${s.apples.length}, ${pl(s.breaths, 'breath')} left`; | |
| 40 | if (s.status === 'won') return `You win: every apple is caught; ${tail}`; | |
| 41 | if (s.status === 'out-of-breath') return `Out of breath: ${tail}`; | |
| 42 | if (s.status === 'out-of-time') return `Time is up: ${tail}`; | |
| 43 | return `Tick ${s.tick} of ${s.ticks}: ${tail}`; | |
| 44 | } | |
| 45 | ||
| 46 | const finish = s => { s.line = describe(s); return s; }; | |
| 47 | ||
| 48 | // start(seed) -> a fresh tub; the seed is coerced to an unsigned 32-bit integer (non-numbers become 0) | |
| 49 | function start(seed) { | |
| 50 | const n = Number(seed); | |
| 51 | const sd = Number.isFinite(n) ? Math.trunc(n) >>> 0 : 0; | |
| 52 | const next = rng(sd); | |
| 53 | const apples = []; | |
| 54 | for (let i = 0; i < APPLES; i++) { | |
| 55 | const period = MIN_PERIOD + Math.floor(next() * (MAX_PERIOD - MIN_PERIOD + 1)); | |
| 56 | apples.push({ period, phase: Math.floor(next() * period), caught: false }); | |
| 57 | } | |
| 58 | return finish({ | |
| 59 | seed: sd, apples, window: WINDOW, tick: 0, ticks: TICKS, breaths: BREATHS, aimed: null, | |
| 60 | score: 0, caught: 0, status: 'playing', line: '', last: null, | |
| 61 | }); | |
| 62 | } | |
| 63 | ||
| 64 | const over = s => s.status !== 'playing'; | |
| 65 | ||
| 66 | // step(state) -> { ok, state, why }: one tick; the round ends when the ticks run out | |
| 67 | function step(state) { | |
| 68 | if (over(state)) return reject(state, 'round-over'); | |
| 69 | const s = { ...state, tick: state.tick + 1, last: null }; | |
| 70 | if (s.tick >= s.ticks) s.status = 'out-of-time'; | |
| 71 | return { ok: true, state: finish(s), why: '' }; | |
| 72 | } | |
| 73 | ||
| 74 | // aim(state, i) -> { ok, state, why }: pick apple i (a caught apple cannot be aimed) | |
| 75 | function aim(state, i) { | |
| 76 | if (over(state)) return reject(state, 'round-over'); | |
| 77 | if (!Number.isInteger(i) || i < 0 || i >= state.apples.length) return reject(state, 'unknown-apple'); | |
| 78 | if (state.apples[i].caught) return reject(state, 'already-caught'); | |
| 79 | return { ok: true, state: finish({ ...state, aimed: i }), why: '' }; | |
| 80 | } | |
| 81 | ||
| 82 | // dunk(state) -> { ok, state, why }: try the aimed apple at the current tick | |
| 83 | function dunk(state) { | |
| 84 | if (over(state)) return reject(state, 'round-over'); | |
| 85 | if (state.aimed === null) return reject(state, 'nothing-aimed'); | |
| 86 | const i = state.aimed, d = offset(state.apples[i], state.tick); | |
| 87 | const s = { ...state, apples: state.apples.map(a => ({ ...a })) }; | |
| 88 | if (d <= state.window) { | |
| 89 | s.apples[i].caught = true; | |
| 90 | s.caught += 1; | |
| 91 | s.score += d === 0 ? EXACT_POINTS : WINDOW_POINTS; | |
| 92 | s.aimed = null; | |
| 93 | s.last = { apple: i, hit: true, offset: d }; | |
| 94 | if (s.caught === s.apples.length) s.status = 'won'; | |
| 95 | } else { | |
| 96 | s.breaths -= 1; | |
| 97 | s.last = { apple: i, hit: false, offset: d }; | |
| 98 | if (s.breaths <= 0) s.status = 'out-of-breath'; | |
| 99 | } | |
| 100 | return { ok: true, state: finish(s), why: '' }; | |
| 101 | } | |
| 102 | ||
| 103 | const api = { APPLES, BREATHS, TICKS, WINDOW, EXACT_POINTS, WINDOW_POINTS, start, step, aim, dunk, offset, describe }; | |
| 104 | if (typeof module !== 'undefined') module.exports = api; else root.AppleBobbing = api; | |
| 105 | })(typeof window !== 'undefined' ? window : globalThis); |
apple-bobbing/tub.test.mjs added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { start, step, aim, dunk, offset, APPLES, BREATHS, TICKS, WINDOW, EXACT_POINTS, WINDOW_POINTS } from './tub.js'; | |
| 3 | ||
| 4 | const ok = r => { expect(r.ok).toBe(true); return r.state; }; | |
| 5 | const refused = (r, s, why) => { expect(r.ok).toBe(false); expect(r.why).toBe(why); expect(r.state).toBe(s); }; | |
| 6 | // advance to the first tick where apple i is `d` ticks from the top | |
| 7 | const until = (s, i, d) => { while (offset(s.apples[i], s.tick) !== d) s = ok(step(s)); return s; }; | |
| 8 | ||
| 9 | test('same seed gives the same tub and round; stated numbers and periods', () => { | |
| 10 | const a = start(7), b = start(7); | |
| 11 | expect(a).toEqual(b); | |
| 12 | expect(start(8).apples).not.toEqual(a.apples); | |
| 13 | expect([a.apples.length, a.breaths, a.ticks, a.window]).toEqual([APPLES, BREATHS, TICKS, WINDOW]); | |
| 14 | for (const p of a.apples) { | |
| 15 | expect(p.period).toBeGreaterThanOrEqual(6); expect(p.period).toBeLessThanOrEqual(12); | |
| 16 | expect(p.phase).toBeGreaterThanOrEqual(0); expect(p.phase).toBeLessThan(p.period); | |
| 17 | } | |
| 18 | const play = s => { s = ok(aim(s, 0)); s = until(s, 0, 0); return ok(dunk(s)); }; | |
| 19 | expect(play(a)).toEqual(play(b)); | |
| 20 | }); | |
| 21 | ||
| 22 | test('step advances one tick', () => { | |
| 23 | const s = start(1); | |
| 24 | expect(ok(step(s)).tick).toBe(1); | |
| 25 | expect(s.tick).toBe(0); | |
| 26 | }); | |
| 27 | ||
| 28 | test('dunk at the exact top catches and scores most', () => { | |
| 29 | const s = until(ok(aim(start(3), 1)), 1, 0); | |
| 30 | const t = ok(dunk(s)); | |
| 31 | expect([t.apples[1].caught, t.caught, t.score, t.breaths, t.aimed]).toEqual([true, 1, EXACT_POINTS, BREATHS, null]); | |
| 32 | expect(t.last).toEqual({ apple: 1, hit: true, offset: 0 }); | |
| 33 | }); | |
| 34 | ||
| 35 | test('dunk at the window edge catches for the smaller score; just outside misses', () => { | |
| 36 | const edge = ok(dunk(until(ok(aim(start(3), 2)), 2, WINDOW))); | |
| 37 | expect([edge.caught, edge.score, edge.breaths]).toEqual([1, WINDOW_POINTS, BREATHS]); | |
| 38 | const out = ok(dunk(until(ok(aim(start(3), 2)), 2, WINDOW + 1))); | |
| 39 | expect([out.caught, out.score, out.breaths, out.apples[2].caught, out.aimed]).toEqual([0, 0, BREATHS - 1, false, 2]); | |
| 40 | expect(out.last.hit).toBe(false); | |
| 41 | }); | |
| 42 | ||
| 43 | test('refusals return the same state with a reason', () => { | |
| 44 | const s = start(5); | |
| 45 | refused(aim(s, 99), s, 'unknown-apple'); | |
| 46 | refused(aim(s, -1), s, 'unknown-apple'); | |
| 47 | refused(aim(s, 1.5), s, 'unknown-apple'); | |
| 48 | refused(dunk(s), s, 'nothing-aimed'); | |
| 49 | const c = ok(dunk(until(ok(aim(s, 0)), 0, 0))); | |
| 50 | refused(aim(c, 0), c, 'already-caught'); | |
| 51 | let o = start(5); o = { ...o, status: 'out-of-time' }; | |
| 52 | refused(step(o), o, 'round-over'); | |
| 53 | refused(aim(o, 0), o, 'round-over'); | |
| 54 | refused(dunk(o), o, 'round-over'); | |
| 55 | }); | |
| 56 | ||
| 57 | test('ending: every apple caught', () => { | |
| 58 | let s = start(11); | |
| 59 | for (let i = 0; i < APPLES; i++) s = ok(dunk(until(ok(aim(s, i)), i, 0))); | |
| 60 | expect(s.status).toBe('won'); | |
| 61 | expect(s.line).toBe(`You win: every apple is caught; score ${EXACT_POINTS * APPLES}, 5 apples caught of 5, ${s.breaths} breaths left`); | |
| 62 | }); | |
| 63 | ||
| 64 | test('ending: breaths run out', () => { | |
| 65 | let s = ok(aim(start(2), 0)); | |
| 66 | const miss = t => { while (offset(t.apples[0], t.tick) <= WINDOW) t = ok(step(t)); return ok(dunk(t)); }; | |
| 67 | for (let i = 0; i < BREATHS; i++) s = miss(s); | |
| 68 | expect([s.status, s.breaths]).toEqual(['out-of-breath', 0]); | |
| 69 | expect(s.line).toBe('Out of breath: score 0, 0 apples caught of 5, 0 breaths left'); | |
| 70 | }); | |
| 71 | ||
| 72 | test('ending: ticks run out', () => { | |
| 73 | let s = start(4); | |
| 74 | for (let i = 0; i < TICKS; i++) s = ok(step(s)); | |
| 75 | expect([s.status, s.tick]).toEqual(['out-of-time', TICKS]); | |
| 76 | expect(s.line).toBe(`Time is up: score 0, 0 apples caught of 5, ${BREATHS} breaths left`); | |
| 77 | }); |
new above
offline