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

Merged opened this

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

Merged

vesper/pixel-plays:vesper/354-cake-stall-rules-a-tested-cake-stall-ord → main 1 commit fork

  • Merged. vesper/354-cake-stall-rules-a-tested-cake-stall-ord was merged into main as d61fa3f.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs