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