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

Migrated from github.com/amirmushichge/pixel-plays

Ticket Booth rules: a tested `ticket-booth/till.js` where you hand exact change from a limited till to a seeded queue of pier visitors, in a two-file pull with a test file that runs in under a second #330

Merged opened this

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

Merged

ledger/pixel-plays:ledger/324-ticket-booth-rules-a-tested-ticket-booth → main 2 commits fork 1 thread · 1 unresolved

  • Merged. ledger/324-ticket-booth-rules-a-tested-ticket-booth was merged into main as 4143ff3.

Review

Sign in to review this change.
2 files changed +288 −0
ticket-booth/till.js added +137
@@ -0,0 +1,137 @@
1 'use strict';
2 // The Ticket Booth rules: a seeded queue of pier visitors each buys tickets and pays with a coin or a note; you hand
3 // back their change from a till that holds limited coins. Plain data in, plain data out, no DOM, no timers, no
4 // Math.random: the queue and the starting till come from the seed. A refused call returns the very same state and says why.
5 //
6 // Units: money is whole pence (integers). `values` lists every coin and note, ascending; the till and the tray are
7 // counts per value, index-aligned with `values`. A visitor's payment enters the till when they are served (hand), so
8 // it can pay for later change, never their own. `short` sends a visitor away: their payment is not taken.
9 (function (root) {
10 const VALUES = [5, 10, 20, 50, 100, 200, 500, 1000];
11 const PRICES = { adult: 175, child: 90 };
12 const QUEUE_LENGTH = 12;
13 const LOSS_LIMIT = 3;
14 const SCORE_EXACT = 10; // exact change
15 const SCORE_BONUS = 5; // extra when it also uses the fewest coins possible from the till as it stands
16 // Starting count of each value: [min, max] drawn from the seed
17 const START_RANGE = [[4, 8], [4, 8], [3, 6], [2, 4], [2, 4], [1, 2], [0, 1], [0, 0]];
18
19 // mulberry32 as a pure step: next(seed) -> [value in [0,1), nextSeed]
20 function next(seed) {
21 const a = (seed + 0x6D2B79F5) >>> 0;
22 let t = a;
23 t = Math.imul(t ^ (t >>> 15), t | 1);
24 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
25 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, a];
26 }
27
28 const reject = (state, why) => ({ ok: false, state, why });
29 const sum = counts => counts.reduce((t, n, i) => t + n * VALUES[i], 0);
30 const coinsIn = counts => counts.reduce((t, n) => t + n, 0);
31 const idx = coin => VALUES.indexOf(coin);
32 const money = n => `${Math.floor(n / 100)}.${String(n % 100).padStart(2, '0')}`;
33
34 // fewest(counts, amount) -> the fewest coins from `counts` that sum to `amount`, or null when it cannot be made
35 function fewest(counts, amount) {
36 const best = new Array(amount + 1).fill(Infinity);
37 best[0] = 0;
38 VALUES.forEach((v, i) => {
39 for (let k = 0; k < counts[i]; k++) { // bounded: each physical coin used at most once
40 for (let a = amount; a >= v; a--) if (best[a - v] + 1 < best[a]) best[a] = best[a - v] + 1;
41 }
42 });
43 return best[amount] === Infinity ? null : best[amount];
44 }
45
46 function describe(s) {
47 if (s.status === 'lost') return `You lose: ${s.lost} visitors went away without their change, score ${s.score}`;
48 if (s.status === 'won') {
49 return `You win: score ${s.score}, ${s.served} visitors served, ${s.fewest} with fewest-coins change, ${s.lost} lost`;
50 }
51 const v = s.queue[s.at];
52 return `${v.name} owes ${money(v.due)} change from ${money(v.pays)}; ${s.queue.length - s.at} in the queue, ${s.lost} of ${LOSS_LIMIT} lost`;
53 }
54
55 // judge(s): sets status and line after a visitor leaves the front of the queue
56 function judge(s) {
57 s.status = s.lost >= LOSS_LIMIT ? 'lost' : s.at >= s.queue.length ? 'won' : 'playing';
58 s.line = describe(s);
59 return s;
60 }
61
62 // start(seed) -> a fresh game
63 function start(seed) {
64 const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0;
65 let rng = sd, r;
66 const draw = (lo, hi) => { [r, rng] = next(rng); return lo + Math.floor(r * (hi - lo + 1)); };
67 const till = START_RANGE.map(([lo, hi]) => draw(lo, hi));
68 const queue = [];
69 for (let i = 0; i < QUEUE_LENGTH; i++) {
70 let adult = draw(0, 2), child = draw(0, 3);
71 if (adult + child === 0) child = 1;
72 const total = adult * PRICES.adult + child * PRICES.child;
73 const options = VALUES.filter(v => v >= total);
74 const pays = options[draw(0, Math.min(1, options.length - 1))];
75 queue.push({ name: `Visitor ${i + 1}`, tickets: { adult, child }, total, pays, due: pays - total });
76 }
77 const s = {
78 seed: sd, values: [...VALUES], prices: { ...PRICES }, till, tray: VALUES.map(() => 0), queue, at: 0,
79 served: 0, fewest: 0, lost: 0, score: 0, status: 'playing', line: '',
80 };
81 s.line = describe(s);
82 return s;
83 }
84
85 // give(state, coin) -> { ok, state, why }: one coin from the till to the change tray
86 function give(state, coin) {
87 if (state.status !== 'playing') return reject(state, 'game-over');
88 const i = idx(coin);
89 if (i < 0) return reject(state, 'no-such-coin');
90 if (state.till[i] < 1) return reject(state, 'none-in-till');
91 const s = { ...state, till: [...state.till], tray: [...state.tray] };
92 s.till[i] -= 1; s.tray[i] += 1;
93 return { ok: true, state: s, why: '' };
94 }
95
96 // take(state, coin) -> { ok, state, why }: one coin from the tray back to the till
97 function take(state, coin) {
98 if (state.status !== 'playing') return reject(state, 'game-over');
99 const i = idx(coin);
100 if (i < 0) return reject(state, 'no-such-coin');
101 if (state.tray[i] < 1) return reject(state, 'none-in-tray');
102 const s = { ...state, till: [...state.till], tray: [...state.tray] };
103 s.till[i] += 1; s.tray[i] -= 1;
104 return { ok: true, state: s, why: '' };
105 }
106
107 // hand(state) -> { ok, state, why }: the tray goes to the front visitor, whose payment enters the till
108 function hand(state) {
109 if (state.status !== 'playing') return reject(state, 'game-over');
110 const v = state.queue[state.at];
111 if (sum(state.tray) !== v.due) return reject(state, 'wrong-change');
112 const pool = state.till.map((n, i) => n + state.tray[i]); // the till as it stood before this change
113 const best = fewest(pool, v.due);
114 const cheap = coinsIn(state.tray) === best;
115 const s = {
116 ...state, till: [...state.till], tray: VALUES.map(() => 0), at: state.at + 1, served: state.served + 1,
117 fewest: state.fewest + (cheap ? 1 : 0), score: state.score + SCORE_EXACT + (cheap ? SCORE_BONUS : 0),
118 };
119 s.till[idx(v.pays)] += 1;
120 return { ok: true, state: judge(s), why: '' };
121 }
122
123 // short(state) -> { ok, state, why }: send away the front visitor, allowed only when their change cannot be made
124 function short(state) {
125 if (state.status !== 'playing') return reject(state, 'game-over');
126 const v = state.queue[state.at];
127 const pool = state.till.map((n, i) => n + state.tray[i]);
128 if (fewest(pool, v.due) !== null) return reject(state, 'change-possible');
129 const s = { ...state, till: pool, tray: VALUES.map(() => 0), at: state.at + 1, lost: state.lost + 1 };
130 return { ok: true, state: judge(s), why: '' };
131 }
132
133 const api = {
134 VALUES, PRICES, LOSS_LIMIT, SCORE_EXACT, SCORE_BONUS, start, give, take, hand, short, fewest, describe,
135 };
136 if (typeof module !== 'undefined') module.exports = api; else root.TicketBooth = api;
137 })(typeof window !== 'undefined' ? window : globalThis);
ticket-booth/till.test.mjs added +151
@@ -0,0 +1,151 @@
1 import { test, expect } from 'bun:test';
2 import { VALUES, LOSS_LIMIT, SCORE_EXACT, SCORE_BONUS, start, give, take, hand, short, fewest } from './till.js';
3
4 const ok = r => { expect(r.ok).toBe(true); return r.state; };
5 const V = VALUES;
6 const counts = o => V.map(v => o[v] || 0);
7 // a state with a chosen till and queue, everything else from a real start
8 const rig = (till, queue, extra = {}) => ({
9 ...start(1), till: counts(till), tray: counts({}), at: 0,
10 queue: queue.map((q, i) => ({ name: `V${i}`, tickets: { adult: 0, child: 1 }, total: q.pays - q.due, ...q })), ...extra,
11 });
12 const gives = (s, ...coins) => coins.reduce((t, c) => ok(give(t, c)), s);
13
14 test('start: stated values, starting till, prices, and at least ten visitors', () => {
15 const s = start(7);
16 expect(s.values).toEqual(V);
17 expect(s.till.length).toBe(V.length);
18 expect(s.till.some(n => n > 0)).toBe(true);
19 expect(s.prices.adult).toBeGreaterThan(0);
20 expect(s.queue.length).toBeGreaterThanOrEqual(10);
21 for (const v of s.queue) {
22 expect(v.tickets.adult + v.tickets.child).toBeGreaterThan(0);
23 expect(V).toContain(v.pays);
24 expect(v.total).toBe(v.tickets.adult * s.prices.adult + v.tickets.child * s.prices.child);
25 expect(v.due).toBe(v.pays - v.total);
26 expect(v.due).toBeGreaterThanOrEqual(0);
27 }
28 expect(s.status).toBe('playing');
29 });
30
31 test('the same seed gives the same queue; another seed another', () => {
32 expect(start(42)).toEqual(start(42));
33 expect(start(42).queue).not.toEqual(start(43).queue);
34 expect(start(42).seed).toBe(42);
35 });
36
37 test('give and take move one coin between till and tray', () => {
38 const s = rig({ 10: 2 }, [{ pays: 100, due: 20 }]);
39 const t = ok(give(s, 10));
40 expect([t.till[V.indexOf(10)], t.tray[V.indexOf(10)]]).toEqual([1, 1]);
41 const u = ok(take(t, 10));
42 expect(u.till).toEqual(s.till);
43 expect(u.tray).toEqual(s.tray);
44 expect(s.till[V.indexOf(10)]).toBe(2); // the input is not mutated
45 });
46
47 test('refusals give a reason and return the same state', () => {
48 const s = rig({ 10: 1 }, [{ pays: 100, due: 20 }]);
49 for (const [r, why] of [
50 [give(s, 7), 'no-such-coin'], [take(s, 7), 'no-such-coin'],
51 [give(s, 20), 'none-in-till'], [take(s, 10), 'none-in-tray'],
52 [hand(s), 'wrong-change'], [hand(gives(s, 10)), 'wrong-change'],
53 ]) { expect(r.ok).toBe(false); expect(r.why).toBe(why); }
54 expect(give(s, 7).state).toBe(s);
55 expect(hand(s).state).toBe(s);
56 const over = { ...s, status: 'won' };
57 for (const r of [give(over, 10), take(over, 10), hand(over), short(over)]) {
58 expect([r.ok, r.why, r.state]).toEqual([false, 'game-over', over]);
59 }
60 });
61
62 test('hand: exact change serves the visitor and their payment enters the till', () => {
63 const s = rig({ 10: 2, 20: 1 }, [{ pays: 100, due: 40 }, { pays: 50, due: 0 }]);
64 const t = ok(hand(gives(s, 20, 10, 10)));
65 expect(t.at).toBe(1);
66 expect(t.served).toBe(1);
67 expect(t.tray).toEqual(counts({}));
68 expect(t.till).toEqual(counts({ 100: 1 }));
69 expect(t.status).toBe('playing');
70 });
71
72 test('fewest-coins bonus is given for the smallest handful and withheld for a larger correct one', () => {
73 const till = { 10: 4, 20: 2 };
74 const q = [{ pays: 100, due: 40 }];
75 const small = ok(hand(gives(rig(till, q), 20, 20)));
76 expect([small.score, small.fewest]).toEqual([SCORE_EXACT + SCORE_BONUS, 1]);
77 const big = ok(hand(gives(rig(till, q), 10, 10, 10, 10)));
78 expect([big.score, big.fewest, big.served]).toEqual([SCORE_EXACT, 0, 1]);
79 });
80
81 test('fewest is judged from the till as it stands, so scarce big coins change what is fewest', () => {
82 expect(fewest(counts({ 10: 4, 20: 2 }), 40)).toBe(2);
83 expect(fewest(counts({ 10: 4, 20: 1 }), 40)).toBe(3);
84 expect(fewest(counts({ 20: 1 }), 30)).toBe(null);
85 const s = rig({ 10: 4, 20: 1 }, [{ pays: 100, due: 40 }]);
86 expect(ok(hand(gives(s, 20, 10, 10))).fewest).toBe(1);
87 });
88
89 test('short is refused when change could be made and allowed when it cannot', () => {
90 const can = rig({ 10: 2 }, [{ pays: 100, due: 20 }, { pays: 100, due: 20 }]);
91 const r = short(can);
92 expect([r.ok, r.why, r.state]).toEqual([false, 'change-possible', can]);
93 // coins already on the tray still count as the till's
94 expect(short(gives(can, 10, 10)).why).toBe('change-possible');
95
96 const cannot = rig({ 20: 1 }, [{ pays: 100, due: 30 }, { pays: 100, due: 30 }]);
97 const t = ok(short(cannot));
98 expect([t.lost, t.at, t.served, t.score, t.status]).toEqual([1, 1, 0, 0, 'playing']);
99 expect(t.till).toEqual(cannot.till); // their payment is not taken
100 });
101
102 test('the third lost visitor ends the game with the loss line', () => {
103 let s = rig({}, [1, 2, 3, 4].map(() => ({ pays: 100, due: 30 })));
104 for (let i = 1; i < LOSS_LIMIT; i++) { s = ok(short(s)); expect(s.status).toBe('playing'); }
105 s = ok(short(s));
106 expect(s.status).toBe('lost');
107 expect(s.line).toBe('You lose: 3 visitors went away without their change, score 0');
108 expect(give(s, 10).why).toBe('game-over');
109 });
110
111 test('serving the whole queue wins and reports score, served and fewest-coins counts', () => {
112 const s0 = rig({ 10: 4, 20: 3 }, [{ pays: 100, due: 40 }, { pays: 100, due: 40 }, { pays: 50, due: 20 }]);
113 let s = ok(hand(gives(s0, 20, 20))); // fewest
114 s = ok(hand(gives(s, 10, 10, 10, 10))); // correct but larger
115 expect(s.status).toBe('playing');
116 s = ok(hand(gives(s, 20))); // fewest
117 expect(s.status).toBe('won');
118 expect(s.score).toBe(3 * SCORE_EXACT + 2 * SCORE_BONUS);
119 expect(s.line).toBe(`You win: score ${s.score}, 3 visitors served, 2 with fewest-coins change, 0 lost`);
120 });
121
122 // find coin counts from `till` summing to `due` (plain DFS; the test's own player, not the rules)
123 const pick = (till, due, i = V.length - 1) => {
124 if (due === 0) return V.map(() => 0);
125 if (i < 0) return null;
126 for (let n = Math.min(till[i], Math.floor(due / V[i])); n >= 0; n--) {
127 const rest = pick(till, due - n * V[i], i - 1);
128 if (rest) { rest[i] = n; return rest; }
129 }
130 return null;
131 };
132
133 test('a seeded game is playable end to end by the rules alone', () => {
134 for (const seed of [1, 5, 9, 42]) {
135 let s = start(seed), moves = 0;
136 while (s.status === 'playing') {
outdated

This hand-edits the state (a rule bypass), so the test no longer exercises the rules. The final expect(s.at).toBeGreaterThan(0) is also near-vacuous. Drive the game with give, take, hand and short only, and assert a terminal status and its line. Otherwise drop the test.

137 expect(++moves).toBeLessThan(10000);
138 const r = short(s);
139 if (r.ok) { s = r.state; continue; }
140 const plan = pick(s.till, s.queue[s.at].due);
141 expect(plan).not.toBeNull(); // short refused, so change exists
142 plan.forEach((n, i) => { for (let k = 0; k < n; k++) s = ok(give(s, V[i])); });
143 s = ok(hand(s));
144 }
145 expect(['won', 'lost']).toContain(s.status);
146 expect(s.line).toBe(s.status === 'won'
147 ? `You win: score ${s.score}, ${s.served} visitors served, ${s.fewest} with fewest-coins change, ${s.lost} lost`
148 : `You lose: ${s.lost} visitors went away without their change, score ${s.score}`);
149 if (s.status === 'lost') expect(s.lost).toBe(LOSS_LIMIT); else expect(s.served + s.lost).toBe(s.queue.length);
150 }
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs