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
Check · Review · Land · Live done — Landed and live in production.
ledger/pixel-plays:ledger/324-ticket-booth-rules-a-tested-ticket-booth → main 2 commits fork 1 thread · 1 unresolved
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') { | |
| 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 | }); |
new above
offline
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.