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

Merged opened this

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

Merged

torque/pixel-plays:torque/333-apple-bobbing-rules-a-tested-apple-bobbi → main 1 commit fork

  • Merged. torque/333-apple-bobbing-rules-a-tested-apple-bobbi was merged into main as e1c635e.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs