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

Migrated from github.com/amirmushichge/pixel-plays

Village Darts rules: a tested `village-darts/board.js` where you count down from 301 and finish on a double against a seeded rival, in a two-file pull with a test file that runs in under a second #337

Merged opened this

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

Merged

torque/pixel-plays:torque/325-village-darts-rules-a-tested-village-dar → main 2 commits fork

  • Merged. torque/325-village-darts-rules-a-tested-village-dar was merged into main as c4973b7.

Review

Sign in to review this change.
2 files changed +361 −0
village-darts/board.js added +168
@@ -0,0 +1,168 @@
1 'use strict';
2 // Village Darts rules: a 301 leg at the village hall against a seeded rival. Count down from 301 and finish exactly on
3 // zero with a double (the bull counts as one). Your aim marker drifts on a stated wobble; a dart lands where the marker
4 // is when you release, scattered by a seeded spread. Plain data in, plain data out, no DOM, no timers, no Math.random:
5 // every random draw comes from the seed. A refused call returns the very same state and says why.
6 //
7 // Units: millimetres on the board face, x to the right, y up, the centre at (0, 0). One tick = one step of the wobble.
8 // Board (regulation dartboard, World Darts Federation / BDO dimensions, radii from the centre):
9 // bull 6.35 · outer bull 15.9 · treble ring 99–107 · double ring 162–170 · the board ends at 170.
10 // A point exactly on a ring's radius belongs to the inner ring. Twenty 18° segments, numbered clockwise from the top:
11 // 20 1 18 4 13 6 10 15 2 17 3 19 7 16 8 11 14 9 12 5; segment 20 is centred on straight up, so a boundary lies at
12 // 9° + 18°·k clockwise from up, and a point exactly on one belongs to the next segment clockwise.
13 // Points: single = n, double = 2n, treble = 3n, outer bull = 25, bull = 50.
14 // Aim wobble (a Lissajous figure, so the marker never rests): aim(t) = (WOBBLE_X·sin(2πt/PERIOD_X), WOBBLE_Y·sin(2πt/PERIOD_Y)).
15 // Scatter: a thrown dart lands at aim + (ux, uy), each uniform in ±SCATTER mm. The rival aims at a stated target
16 // (see rivalTarget) and scatters uniformly in ±RIVAL_SCATTER mm.
17 // Visit: three darts. Bust: a dart that would take the remaining score below zero, to exactly one (no double can
18 // finish from one), or to zero without a double scores nothing: the score returns to its value before the visit and
19 // the visit ends at once. The first player to reach zero on a double wins the leg.
20 (function (root) {
21 const START_SCORE = 301, DARTS_PER_VISIT = 3;
22 const ORDER = [20, 1, 18, 4, 13, 6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5];
23 const SEG_DEG = 18;
24 const R_BULL = 6.35, R_OUTER_BULL = 15.9, R_TREBLE_IN = 99, R_TREBLE_OUT = 107, R_DOUBLE_IN = 162, R_DOUBLE_OUT = 170;
25 const WOBBLE_X = 120, WOBBLE_Y = 120, PERIOD_X = 36, PERIOD_Y = 50;
26 const SCATTER = 12, RIVAL_SCATTER = 22;
27
28 // mulberry32 as a pure step: next(seed) -> [value in [0,1), nextSeed]
29 function next(r) {
30 r = (r + 0x6D2B79F5) >>> 0;
31 let t = r;
32 t = Math.imul(t ^ (t >>> 15), t | 1);
33 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
34 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, r];
35 }
36
37 const round = (x, d) => { const k = Math.pow(10, d); const v = Math.round(x * k) / k; return v === 0 ? 0 : v; };
38
39 // score(x, y) -> {segment, ring, points, double, label}; ring is bull, outer bull, single, treble, double or miss
40 function score(x, y) {
41 const miss = { segment: null, ring: 'miss', points: 0, double: false, label: 'miss' };
42 if (!Number.isFinite(x) || !Number.isFinite(y)) return miss;
43 const r = Math.hypot(x, y);
44 if (r > R_DOUBLE_OUT) return miss;
45 if (r <= R_BULL) return { segment: null, ring: 'bull', points: 50, double: true, label: 'bull' };
46 if (r <= R_OUTER_BULL) return { segment: null, ring: 'outer bull', points: 25, double: false, label: 'outer bull' };
47 const deg = (Math.atan2(x, y) * 180 / Math.PI + 360) % 360; // clockwise from straight up
48 const n = ORDER[Math.floor(((deg + SEG_DEG / 2) % 360) / SEG_DEG)];
49 if (r > R_TREBLE_IN && r <= R_TREBLE_OUT) return { segment: n, ring: 'treble', points: 3 * n, double: false, label: 'T' + n };
50 if (r > R_DOUBLE_IN) return { segment: n, ring: 'double', points: 2 * n, double: true, label: 'D' + n };
51 return { segment: n, ring: 'single', points: n, double: false, label: String(n) };
52 }
53
54 // target(n, ring) -> the centre of segment n's ring as {x, y}; ring is single, treble, double, bull or outer bull
55 function target(n, ring) {
56 if (ring === 'bull') return { x: 0, y: 0 };
57 if (ring === 'outer bull') return { x: 0, y: (R_BULL + R_OUTER_BULL) / 2 };
58 const i = ORDER.indexOf(n);
59 const r = ring === 'treble' ? (R_TREBLE_IN + R_TREBLE_OUT) / 2
60 : ring === 'double' ? (R_DOUBLE_IN + R_DOUBLE_OUT) / 2 : (R_TREBLE_OUT + R_DOUBLE_IN) / 2;
61 const a = i * SEG_DEG * Math.PI / 180;
62 return { x: round(r * Math.sin(a), 3), y: round(r * Math.cos(a), 3) };
63 }
64
65 // aimAt(tick) -> where the marker is at that tick
66 function aimAt(t) {
67 return {
68 x: round(WOBBLE_X * Math.sin(2 * Math.PI * t / PERIOD_X), 3),
69 y: round(WOBBLE_Y * Math.sin(2 * Math.PI * t / PERIOD_Y), 3),
70 };
71 }
72
73 // rivalTarget(remaining) -> the rival's stated aim: treble 20 while 100 or more remain; the bull on 50; the double
74 // of half the score on an even 2–40; single 1 on an odd 3–41 (to leave an even score); otherwise single 20.
75 function rivalTarget(rem) {
76 if (rem >= 100) return target(20, 'treble');
77 if (rem === 50) return target(0, 'bull');
78 if (rem >= 2 && rem <= 40 && rem % 2 === 0) return target(rem / 2, 'double');
79 if (rem >= 3 && rem <= 41) return target(1, 'single');
80 return target(20, 'single');
81 }
82
83 function describe(s) {
84 if (s.status === 'done') {
85 const e = s.end, w = e.winner === 'you' ? 'You win' : 'The rival wins';
86 return `${w} the leg in ${e.darts} dart${e.darts === 1 ? '' : 's'}, finishing on ${e.double}`;
87 }
88 const t = s.turn === 'you' ? `Your visit, dart ${s.visit.length + 1} of ${DARTS_PER_VISIT}` : `The rival's visit, dart ${s.visit.length + 1} of ${DARTS_PER_VISIT}`;
89 return `${t}: you ${s.scores.you}, rival ${s.scores.rival}`;
90 }
91
92 // start(seed) -> both on 301, the board's stated layout, a seeded rival, the marker at tick 0, your visit
93 function start(seed) {
94 const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0;
95 const s = {
96 seed: sd, rng: sd, tick: 0, aim: aimAt(0),
97 board: { order: ORDER.slice(), radii: { bull: R_BULL, outerBull: R_OUTER_BULL, trebleIn: R_TREBLE_IN, trebleOut: R_TREBLE_OUT, doubleIn: R_DOUBLE_IN, doubleOut: R_DOUBLE_OUT } },
98 scores: { you: START_SCORE, rival: START_SCORE }, darts: { you: 0, rival: 0 },
99 turn: 'you', visit: [], visitStart: START_SCORE, status: 'playing', end: null, line: '',
100 };
101 s.line = describe(s);
102 return s;
103 }
104
105 // land(state, x, y) -> {n, dart}: the state after a dart lands at (x, y) for the player whose turn it is
106 function land(s, x, y) {
107 const w = s.turn, d = score(x, y), dart = { by: w, x: round(x, 2), y: round(y, 2), ...d };
108 const n = { ...s, scores: { ...s.scores }, darts: { ...s.darts, [w]: s.darts[w] + 1 }, visit: s.visit.concat([dart]) };
109 const left = s.scores[w] - d.points;
110 if (left === 0 && d.double) {
111 n.scores[w] = 0; n.status = 'done';
112 n.end = { winner: w, darts: n.darts[w], double: d.label };
113 n.line = describe(n);
114 return { n, dart };
115 }
116 const bust = left <= 1; // the double finish has returned: below zero, one, or zero without a double
117 if (bust) { n.scores[w] = s.visitStart; dart.bust = true; } else n.scores[w] = left;
118 if (bust || n.visit.length >= DARTS_PER_VISIT) {
119 n.turn = w === 'you' ? 'rival' : 'you';
120 n.visit = []; n.visitStart = n.scores[n.turn];
121 }
122 n.line = describe(n);
123 return { n, dart };
124 }
125
126 // throwDart(state) -> {ok, state, why, dart}: release from the marker, scattered by the seeded spread
127 function throwDart(s) {
128 if (s.status === 'done') return { ok: false, state: s, why: 'the leg is over', dart: null };
129 if (s.turn !== 'you') return { ok: false, state: s, why: "the rival is throwing its visit", dart: null };
130 let a, b, rng = s.rng;
131 [a, rng] = next(rng); [b, rng] = next(rng);
132 const { n, dart } = land({ ...s, rng }, s.aim.x + (a * 2 - 1) * SCATTER, s.aim.y + (b * 2 - 1) * SCATTER);
133 return { ok: true, state: n, why: '', dart };
134 }
135
136 // rivalDart(state) -> the state after the rival throws one dart at its stated target with its seeded scatter
137 function rivalDart(s) {
138 if (s.status === 'done' || s.turn !== 'rival') return s;
139 const t = rivalTarget(s.scores.rival);
140 let a, b, rng = s.rng;
141 [a, rng] = next(rng); [b, rng] = next(rng);
142 const { n } = land({ ...s, rng }, t.x + (a * 2 - 1) * RIVAL_SCATTER, t.y + (b * 2 - 1) * RIVAL_SCATTER);
143 return n;
144 }
145
146 // rivalVisit(state) -> the state after the rival's whole visit (up to three darts, fewer on a bust or a win)
147 function rivalVisit(s) {
148 while (s.status === 'playing' && s.turn === 'rival') s = rivalDart(s);
149 return s;
150 }
151
152 // step(state) -> one tick: the marker moves along its wobble; in the rival's visit it also throws one dart
153 function step(s) {
154 if (s.status === 'done') return s;
155 let n = { ...s, tick: s.tick + 1 };
156 n.aim = aimAt(n.tick);
157 if (n.turn === 'rival') n = rivalDart(n);
158 n.line = describe(n);
159 return n;
160 }
161
162 const api = {
163 START_SCORE, DARTS_PER_VISIT, ORDER, R_BULL, R_OUTER_BULL, R_TREBLE_IN, R_TREBLE_OUT, R_DOUBLE_IN, R_DOUBLE_OUT,
164 SCATTER, RIVAL_SCATTER,
165 start, step, throwDart, land, rivalDart, rivalVisit, rivalTarget, score, target, aimAt,
166 };
167 if (typeof module !== 'undefined') module.exports = api; else root.VillageDarts = api;
168 })(typeof window !== 'undefined' ? window : globalThis);
village-darts/board.test.mjs added +193
@@ -0,0 +1,193 @@
1 import { test, expect } from 'bun:test';
2 import {
3 START_SCORE, ORDER, SCATTER, RIVAL_SCATTER, R_DOUBLE_OUT, R_TREBLE_IN, R_DOUBLE_IN,
4 start, step, throwDart, land, rivalDart, rivalVisit, rivalTarget, score, target, aimAt,
5 } from './board.js';
6
7 const at = (s, over) => ({ ...s, ...over, scores: { ...s.scores, ...(over.scores || {}) } });
8 const throwAt = (s, x, y) => land(s, x, y); // {n, dart}: a dart placed exactly
9 const fullVisit = s => { for (let i = 0; i < 3; i++) s = throwDart(s).state; return s; };
10
11 test('start: both on 301, the stated board, the marker at tick 0; same seed same leg', () => {
12 const s = start(5);
13 expect(s.scores).toEqual({ you: START_SCORE, rival: START_SCORE });
14 expect(START_SCORE).toBe(301);
15 expect(s.board.order).toEqual(ORDER);
16 expect(ORDER.length).toBe(20);
17 expect([...ORDER].sort((a, b) => a - b)).toEqual(Array.from({ length: 20 }, (_, i) => i + 1));
18 expect(s.board.radii.doubleOut).toBe(170);
19 expect([s.turn, s.status, s.tick]).toEqual(['you', 'playing', 0]);
20 expect(start(NaN).seed).toBe(0);
21 const play = seed => { let t = start(seed); for (let i = 0; i < 400 && t.status === 'playing'; i++) { t = step(t); if (t.turn === 'you' && i % 7 === 0) t = throwDart(t).state; } return t; };
22 expect(play(9)).toEqual(play(9));
23 expect(play(9)).not.toEqual(play(10));
24 });
25
26 test('score: the centre of each ring', () => {
27 const p = (n, ring) => { const t = target(n, ring); return score(t.x, t.y); };
28 expect(p(20, 'single')).toMatchObject({ segment: 20, ring: 'single', points: 20, double: false });
29 expect(p(20, 'treble')).toMatchObject({ segment: 20, ring: 'treble', points: 60, double: false, label: 'T20' });
30 expect(p(16, 'double')).toMatchObject({ segment: 16, ring: 'double', points: 32, double: true, label: 'D16' });
31 expect(p(3, 'treble')).toMatchObject({ segment: 3, points: 9 });
32 expect(score(0, 11)).toMatchObject({ ring: 'outer bull', points: 25, double: false });
33 expect(score(0, 0)).toMatchObject({ ring: 'bull', points: 50, double: true });
34 for (const n of ORDER) for (const ring of ['single', 'treble', 'double']) {
35 const t = target(n, ring);
36 expect(score(t.x, t.y)).toMatchObject({ segment: n, ring });
37 }
38 });
39
40 test('score: on a segment boundary and off the board', () => {
41 // 9° clockwise from up is the 20/1 boundary; a point exactly on it belongs to the next segment clockwise, so just
42 // either side of it lands in 20 and in 1
43 const pt = (deg, r) => [r * Math.sin(deg * Math.PI / 180), r * Math.cos(deg * Math.PI / 180)];
44 expect(score(...pt(8.99, 130)).segment).toBe(20);
45 expect(score(...pt(9.01, 130)).segment).toBe(1);
46 expect(score(...pt(-9.01, 130)).segment).toBe(5);
47 expect(score(0, 130).segment).toBe(20);
48 // exactly on a boundary: 45° clockwise from up lies between 18 (index 2) and 4 (index 3); the next clockwise is 4
49 expect(score(100, 100).segment).toBe(4);
50 // mirror: 45° anticlockwise lies between 9 and 12; clockwise from it comes 12
51 expect(score(-100, 100).segment).toBe(12);
52 // radii: on the ring radius belongs to the inner ring
53 expect(score(0, R_TREBLE_IN).ring).toBe('single');
54 expect(score(0, R_TREBLE_IN + 0.01).ring).toBe('treble');
55 expect(score(0, R_DOUBLE_IN).ring).toBe('single');
56 expect(score(0, R_DOUBLE_OUT).ring).toBe('double');
57 expect(score(0, R_DOUBLE_OUT + 0.01)).toEqual({ segment: null, ring: 'miss', points: 0, double: false, label: 'miss' });
58 expect(score(500, -500).points).toBe(0);
59 expect(score(NaN, 0).ring).toBe('miss');
60 });
61
62 test('step moves the marker along its wobble, one tick at a time', () => {
63 let s = start(1);
64 const s1 = step(s);
65 expect([s1.tick, s1.aim]).toEqual([1, aimAt(1)]);
66 expect(s1.aim).not.toEqual(s.aim);
67 expect(aimAt(9).x).toBeCloseTo(120, 2); // quarter of the 36-tick x period: the top of the sine
68 expect(step(step(s)).aim).toEqual(aimAt(2));
69 });
70
71 test('throwDart lands within the scatter of the marker, spends one dart, and is seeded', () => {
72 let s = start(3);
73 for (let i = 0; i < 5; i++) s = step(s);
74 const r = throwDart(s);
75 expect(r.ok).toBe(true);
76 expect(Math.abs(r.dart.x - s.aim.x)).toBeLessThanOrEqual(SCATTER + 0.01);
77 expect(Math.abs(r.dart.y - s.aim.y)).toBeLessThanOrEqual(SCATTER + 0.01);
78 expect(r.state.darts.you).toBe(1);
79 expect(r.state.visit.length).toBe(1);
80 expect(r.state.scores.you).toBe(301 - r.dart.points);
81 expect(throwDart(s).dart).toEqual(r.dart);
82 expect(s.scores.you).toBe(301); // the input is not mutated
83 });
84
85 test('a visit is three darts, then it is the rival\'s turn; throwDart refuses meanwhile and returns the same state', () => {
86 let s = fullVisit(start(4));
87 expect(s.turn).toBe('rival');
88 expect(s.darts.you).toBe(3);
89 const r = throwDart(s);
90 expect(r.ok).toBe(false);
91 expect(r.why).toBe('the rival is throwing its visit');
92 expect(r.state).toBe(s);
93 });
94
95 test('the rival throws its visit at its stated aim, one dart per tick, then hands back', () => {
96 expect(rivalTarget(301)).toEqual(target(20, 'treble'));
97 expect(rivalTarget(50)).toEqual(target(0, 'bull'));
98 expect(rivalTarget(32)).toEqual(target(16, 'double'));
99 expect(rivalTarget(37)).toEqual(target(1, 'single'));
100 expect(rivalTarget(70)).toEqual(target(20, 'single'));
101 const s0 = fullVisit(start(4));
102 const a = step(s0);
103 expect(a.darts.rival).toBe(1);
104 expect(a.turn).toBe('rival');
105 const d = rivalDart(s0); // a rival step needs no throwDart
106 expect(d.darts.rival).toBe(1);
107 const v = rivalVisit(s0);
108 expect([v.turn, v.darts.rival, v.visit.length]).toEqual(['you', 3, 0]);
109 expect(v.scores.rival).toBeLessThan(301);
110 expect(v).toEqual(rivalVisit(s0));
111 // each rival dart lands within its scatter of the target
112 const t = rivalTarget(301), dart = d.visit[0];
113 expect(dart.by).toBe('rival');
114 expect(Math.abs(dart.x - t.x)).toBeLessThanOrEqual(RIVAL_SCATTER + 0.01);
115 expect(Math.abs(dart.y - t.y)).toBeLessThanOrEqual(RIVAL_SCATTER + 0.01);
116 // rival scatter is wider than the player's
117 expect(RIVAL_SCATTER).toBeGreaterThan(SCATTER);
118 expect(rivalVisit(start(4)).turn).toBe('you'); // no-op outside the rival's turn
119 });
120
121 test('bust: below zero restores the visit\'s starting score', () => {
122 let s = at(start(1), { scores: { you: 40 }, visitStart: 40 });
123 s = land(s, ...Object.values(target(20, 'single'))).n; // 40 -> 20
124 expect(s.scores.you).toBe(20);
125 const r = land(s, ...Object.values(target(20, 'treble'))); // 60 > 20
126 expect(r.dart.bust).toBe(true);
127 expect(r.n.scores.you).toBe(40);
128 expect(r.n.turn).toBe('rival');
129 });
130
131 test('bust: to exactly one restores the visit\'s starting score', () => {
132 const s = at(start(1), { scores: { you: 21 }, visitStart: 21 });
133 const r = land(s, ...Object.values(target(20, 'single')));
134 expect(r.dart.bust).toBe(true);
135 expect(r.n.scores.you).toBe(21);
136 expect(r.n.turn).toBe('rival');
137 });
138
139 test('bust: to zero on anything but a double restores the visit\'s starting score, even mid-visit', () => {
140 let s = at(start(1), { scores: { you: 30 }, visitStart: 30 });
141 s = land(s, ...Object.values(target(5, 'single'))).n; // 30 -> 25
142 s = land(s, ...Object.values(target(0, 'outer bull'))).n; // 25 -> 0 on the outer bull, not a double
143 expect(s.scores.you).toBe(30);
144 expect(s.status).toBe('playing');
145 expect(s.turn).toBe('rival');
146 const t = land(at(start(1), { scores: { you: 60 }, visitStart: 60 }), ...Object.values(target(20, 'treble')));
147 expect(t.dart.bust).toBe(true); // a treble to zero is no finish either
148 expect(t.n.scores.you).toBe(60);
149 });
150
151 test('a finish on a double wins the leg, and the end line says so', () => {
152 const s = at(start(1), { scores: { you: 32 }, visitStart: 32 });
153 const r = land(s, ...Object.values(target(16, 'double')));
154 expect(r.n.status).toBe('done');
155 expect(r.n.scores.you).toBe(0);
156 expect(r.n.end).toMatchObject({ winner: 'you', darts: 1, double: 'D16' });
157 expect(r.n.line).toBe('You win the leg in 1 dart, finishing on D16');
158 const again = throwDart(r.n);
159 expect(again.ok).toBe(false);
160 expect(again.why).toBe('the leg is over');
161 expect(again.state).toBe(r.n);
162 expect(step(r.n)).toBe(r.n);
163 });
164
165 test('a finish on the bull wins the leg', () => {
166 const s = at(start(1), { scores: { you: 50 }, visitStart: 50 });
167 const r = land(s, 0, 0);
168 expect(r.n.status).toBe('done');
169 expect(r.n.end).toMatchObject({ winner: 'you', double: 'bull' });
170 });
171
172 test('the rival can win: the end line reports the loss, the darts it threw and its double', () => {
173 let s = fullVisit(start(2));
174 s = at(s, { scores: { you: 100, rival: 8 }, visitStart: 8 });
175 const t = target(4, 'double');
176 const r = land(s, t.x, t.y);
177 expect(r.n.status).toBe('done');
178 expect(r.n.end).toMatchObject({ winner: 'rival', darts: 1, double: 'D4' });
179 expect(r.n.line).toBe('The rival wins the leg in 1 dart, finishing on D4');
180 expect(r.n.scores.you).toBe(100);
181 });
182
183 test('a rival visit can finish the leg by itself: a seeded rival on a double eventually wins', () => {
184 let s = at(fullVisit(start(6)), { scores: { rival: 2 }, visitStart: 2 });
185 let guard = 0;
186 while (s.status === 'playing' && guard++ < 200) {
187 s = rivalVisit(s);
188 if (s.status === 'playing') s = at(fullVisit(s), {});
189 }
190 expect(s.status).toBe('done');
191 expect(s.end.winner).toBe('rival');
192 expect(s.end.double).toBe('D1');
193 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs