Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Cake Stall rules: a tested `cake-stall/order.js` where you memorise a seeded order of cake layers shown for a few ticks and stack them back in that order, in a two-file pull with a test file that runs in under a second #366
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/354-cake-stall-rules-a-tested-cake-stall-ord → main 1 commit fork
2 files
changed +191
−0
cake-stall/order.js added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | 'use strict'; | |
| 2 | // The Cake Stall rules: a customer's order, a seeded stack of cake layers, is shown for a few ticks and then hidden; | |
| 3 | // stack the layers back from memory. A wrong layer costs a life and the same order is shown afresh; a finished stack | |
| 4 | // scores the round and the next order is one layer longer. Plain data in, plain data out, no DOM, no timers, no | |
| 5 | // Math.random: every draw comes from the seed, so one seed gives one run of orders. A refused call returns the very | |
| 6 | // same state and says why. | |
| 7 | // | |
| 8 | // Units: time is whole ticks. Round r (1-based) has an order of r + 2 layers drawn from LAYERS, shown for | |
| 9 | // SHOW_BASE + length ticks. A finished round scores 10 points per layer in the order. Lives start at 3; the game is | |
| 10 | // won when round 8 is finished and lost when the last life goes. | |
| 11 | // PRNG: mulberry32 (public domain), seeded per round from the seed and the round number, so an order depends only on | |
| 12 | // (seed, round), never on how the earlier rounds went. | |
| 13 | (function (root) { | |
| 14 | const LAYERS = ['sponge', 'jam', 'cream', 'icing']; | |
| 15 | const LIVES = 3, ROUNDS = 8, FIRST_LENGTH = 3, SHOW_BASE = 3, POINTS_PER_LAYER = 10; | |
| 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 | const pl = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; | |
| 31 | ||
| 32 | // orderFor(seed, round) -> the round's layers, a pure function of the two | |
| 33 | function orderFor(seed, round) { | |
| 34 | const next = rng((seed >>> 0) ^ Math.imul(round, 0x9E3779B1)); | |
| 35 | const out = []; | |
| 36 | for (let i = 0; i < FIRST_LENGTH + round - 1; i++) out.push(LAYERS[Math.floor(next() * LAYERS.length)]); | |
| 37 | return out; | |
| 38 | } | |
| 39 | ||
| 40 | function describe(s) { | |
| 41 | if (s.status === 'won') return `You win: all ${ROUNDS} orders served, score ${s.score}`; | |
| 42 | if (s.status === 'lost') return `Out of lives: ${pl(s.round - 1, 'round')} served, score ${s.score}`; | |
| 43 | const tail = `round ${s.round} of ${ROUNDS}, ${pl(s.lives, 'life')} left, score ${s.score}`; | |
| 44 | if (s.shown > 0) return `Memorise the order (${pl(s.shown, 'tick')} left): ${tail}`; | |
| 45 | return `Stack the order, ${s.placed.length} of ${s.order.length} placed: ${tail}`; | |
| 46 | } | |
| 47 | ||
| 48 | const finish = s => { s.line = describe(s); return s; }; | |
| 49 | ||
| 50 | function openRound(s, round) { | |
| 51 | s.round = round; | |
| 52 | s.order = orderFor(s.seed, round); | |
| 53 | s.shown = SHOW_BASE + s.order.length; | |
| 54 | s.showTicks = s.shown; | |
| 55 | s.placed = []; | |
| 56 | } | |
| 57 | ||
| 58 | // start(seed) -> round one; the seed is coerced to an unsigned 32-bit integer (non-numbers become 0) | |
| 59 | function start(seed) { | |
| 60 | const n = Number(seed); | |
| 61 | const s = { seed: Number.isFinite(n) ? Math.trunc(n) >>> 0 : 0, lives: LIVES, score: 0, status: 'playing' }; | |
| 62 | openRound(s, 1); | |
| 63 | return finish(s); | |
| 64 | } | |
| 65 | ||
| 66 | // tick(state) -> { ok, state, why }: count the showing time down; the order hides when it reaches zero | |
| 67 | function tick(state) { | |
| 68 | if (state.status !== 'playing') return reject(state, 'game-over'); | |
| 69 | if (state.shown <= 0) return reject(state, 'order-hidden'); | |
| 70 | return { ok: true, why: '', state: finish({ ...state, shown: state.shown - 1 }) }; | |
| 71 | } | |
| 72 | ||
| 73 | // place(state, layer) -> { ok, state, why }: add a layer to the stack once the order is hidden | |
| 74 | function place(state, layer) { | |
| 75 | if (state.status !== 'playing') return reject(state, 'game-over'); | |
| 76 | if (typeof layer !== 'string' || !LAYERS.includes(layer)) return reject(state, 'unknown-layer'); | |
| 77 | if (state.shown > 0) return reject(state, 'order-shown'); | |
| 78 | const s = { ...state, placed: [...state.placed] }; | |
| 79 | if (layer !== s.order[s.placed.length]) { | |
| 80 | s.lives -= 1; | |
| 81 | if (s.lives <= 0) s.status = 'lost'; | |
| 82 | else { s.shown = s.showTicks; s.placed = []; } | |
| 83 | return { ok: true, why: '', state: finish(s) }; | |
| 84 | } | |
| 85 | s.placed.push(layer); | |
| 86 | if (s.placed.length === s.order.length) { | |
| 87 | s.score += POINTS_PER_LAYER * s.order.length; | |
| 88 | if (s.round >= ROUNDS) s.status = 'won'; | |
| 89 | else openRound(s, s.round + 1); | |
| 90 | } | |
| 91 | return { ok: true, why: '', state: finish(s) }; | |
| 92 | } | |
| 93 | ||
| 94 | const restart = state => start(state && state.seed); | |
| 95 | ||
| 96 | const api = { LAYERS, LIVES, ROUNDS, start, tick, place, restart, orderFor, describe }; | |
| 97 | if (typeof module !== 'undefined') module.exports = api; else root.CakeStall = api; | |
| 98 | })(typeof window !== 'undefined' ? window : globalThis); |
cake-stall/order.test.mjs added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { LAYERS, LIVES, ROUNDS, start, tick, place, restart, orderFor } from './order.js'; | |
| 3 | ||
| 4 | const ok = r => { expect(r.ok).toBe(true); return r.state; }; | |
| 5 | const hide = s => { while (s.shown > 0) s = ok(tick(s)); return s; }; | |
| 6 | const wrongFor = s => LAYERS.find(l => l !== s.order[s.placed.length]); | |
| 7 | const stack = s => { for (const l of s.order) s = ok(place(s, l)); return s; }; | |
| 8 | const serve = s => stack(hide(s)); | |
| 9 | ||
| 10 | test('round one: at least three layers from at least four kinds, shown for some ticks', () => { | |
| 11 | const s = start(1); | |
| 12 | expect(LAYERS.length).toBeGreaterThanOrEqual(4); | |
| 13 | expect(s.order.length).toBeGreaterThanOrEqual(3); | |
| 14 | s.order.forEach(l => expect(LAYERS).toContain(l)); | |
| 15 | expect(s.shown).toBeGreaterThan(0); | |
| 16 | expect(s.round).toBe(1); expect(s.lives).toBe(3); expect(s.score).toBe(0); expect(s.placed).toEqual([]); | |
| 17 | }); | |
| 18 | ||
| 19 | test('same seed same orders, other seeds differ', () => { | |
| 20 | const run = seed => { let s = start(seed), o = []; for (let i = 0; i < 4; i++) { o.push(s.order); s = serve(s); } return o; }; | |
| 21 | expect(run(7)).toEqual(run(7)); | |
| 22 | expect(run(7)).toEqual(run('7')); | |
| 23 | expect(Array.from({ length: 20 }, (_, i) => start(i).order.join()).filter((v, i, a) => a.indexOf(v) === i).length).toBeGreaterThan(5); | |
| 24 | expect(orderFor(7, 1)).toEqual(start(7).order); | |
| 25 | }); | |
| 26 | ||
| 27 | test('tick counts down and the order hides at zero; ticking a hidden order is refused', () => { | |
| 28 | let s = start(2); | |
| 29 | const n = s.shown; | |
| 30 | s = ok(tick(s)); | |
| 31 | expect(s.shown).toBe(n - 1); | |
| 32 | s = hide(s); | |
| 33 | expect(s.shown).toBe(0); | |
| 34 | const r = tick(s); | |
| 35 | expect(r.ok).toBe(false); expect(r.why).toBe('order-hidden'); expect(r.state).toBe(s); | |
| 36 | }); | |
| 37 | ||
| 38 | test('refusals give a reason and the very same state', () => { | |
| 39 | const s = start(3); | |
| 40 | let r = place(s, 'sponge'); | |
| 41 | expect([r.ok, r.why, r.state]).toEqual([false, 'order-shown', s]); | |
| 42 | const h = hide(s); | |
| 43 | for (const bad of ['marzipan', 5, null, undefined, 'constructor']) { | |
| 44 | r = place(h, bad); | |
| 45 | expect([r.ok, r.why, r.state]).toEqual([false, 'unknown-layer', h]); | |
| 46 | } | |
| 47 | let o = hide(start(3)); | |
| 48 | for (let i = 0; i < LIVES; i++) { o = hide(o); o = ok(place(o, wrongFor(o))); } | |
| 49 | expect(o.status).toBe('lost'); | |
| 50 | for (const r2 of [place(o, 'jam'), tick(o)]) expect([r2.ok, r2.why, r2.state]).toEqual([false, 'game-over', o]); | |
| 51 | }); | |
| 52 | ||
| 53 | test('a wrong layer costs a life and reshows the same order afresh', () => { | |
| 54 | let s = hide(start(4)); | |
| 55 | const order = s.order; | |
| 56 | s = ok(place(s, order[0])); | |
| 57 | s = ok(place(s, wrongFor(s))); | |
| 58 | expect(s.lives).toBe(LIVES - 1); | |
| 59 | expect(s.order).toEqual(order); | |
| 60 | expect(s.placed).toEqual([]); | |
| 61 | expect(s.shown).toBe(s.showTicks); | |
| 62 | expect(s.round).toBe(1); | |
| 63 | }); | |
| 64 | ||
| 65 | test('a right stack scores the round and lengthens the next order by one', () => { | |
| 66 | let s = start(5); | |
| 67 | const len = s.order.length; | |
| 68 | s = serve(s); | |
| 69 | expect(s.round).toBe(2); expect(s.score).toBe(len * 10); expect(s.order.length).toBe(len + 1); | |
| 70 | expect(s.placed).toEqual([]); expect(s.shown).toBeGreaterThan(0); expect(s.lives).toBe(LIVES); | |
| 71 | }); | |
| 72 | ||
| 73 | test('losing all lives ends the game with a line naming the rounds served', () => { | |
| 74 | let s = serve(serve(start(6))); | |
| 75 | for (let i = 0; i < LIVES; i++) s = ok(place(hide(s), wrongFor(hide(s)))); | |
| 76 | expect(s.status).toBe('lost'); expect(s.lives).toBe(0); | |
| 77 | expect(s.line).toContain('2 rounds served'); | |
| 78 | }); | |
| 79 | ||
| 80 | test('serving eight rounds wins, with a win line', () => { | |
| 81 | let s = start(8); | |
| 82 | for (let i = 0; i < ROUNDS; i++) { expect(s.status).toBe('playing'); s = serve(s); } | |
| 83 | expect(s.status).toBe('won'); | |
| 84 | expect(s.line).toContain('You win'); | |
| 85 | expect(s.round).toBe(ROUNDS); | |
| 86 | expect(place(s, 'jam').why).toBe('game-over'); | |
| 87 | }); | |
| 88 | ||
| 89 | test('restart gives the same first round; the HUD fields are reported', () => { | |
| 90 | const s = start(9); | |
| 91 | expect(restart(s).order).toEqual(start(9).order); | |
| 92 | for (const k of ['round', 'lives', 'score', 'placed', 'line']) expect(k in s).toBe(true); | |
| 93 | }); |
new above
offline