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

Migrated from github.com/amirmushichge/pixel-plays

Hay Loft rules: a tested `hay-loft/stack.js` where you hoist bales from the cart onto loft beams without overloading any beam, within par, from plain data, in a two-file pull with a test file that runs in under a second #321

Merged opened this

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

Merged

torque/pixel-plays:torque/316-hay-loft-rules-a-tested-hay-loft-stack-j → main 1 commit fork

  • Merged. torque/316-hay-loft-rules-a-tested-hay-loft-stack-j was merged into main as 269b54d.

Review

Sign in to review this change.
2 files changed +178 −0
hay-loft/stack.js added +86
@@ -0,0 +1,86 @@
1 'use strict';
2 // The Hay Loft rules: hoist bales from the cart onto loft beams so every bale ends up in the loft and no beam
3 // carries more than its load. Plain data in, plain data out, no DOM, no timers, no randomness. A refused call
4 // returns the very same state and says why. The game has no loss; restart is always allowed.
5 //
6 // Units: every weight and load is a whole number of stone (st).
7 // Rule: a beam b is safe while Σ weight(bale on b) ≤ load(b) (static load limit, integer arithmetic, exact).
8 // Solved when the cart is empty and every beam is safe. Moves = hoists + lowers.
9 (function (root) {
10 // Each level: beams (loads, st), cart (bale weights in the order they come off the cart, st), par (moves),
11 // and a stated solution: the beam index each bale is hoisted onto, in cart order. par = hoists in the solution.
12 const LEVELS = [
13 { name: 'Two beams', beams: [10, 10], cart: [4, 6, 5, 5], solution: [0, 0, 1, 1] },
14 { name: 'Three beams', beams: [8, 5, 3], cart: [3, 5, 3, 5], solution: [2, 1, 0, 0] },
15 { name: 'Heavy first', beams: [9, 7, 4], cart: [4, 3, 7, 2, 4], solution: [2, 0, 1, 0, 0] },
16 { name: 'Even rafters', beams: [6, 6, 6], cart: [5, 1, 4, 2, 3, 3], solution: [0, 0, 1, 1, 2, 2] },
17 { name: 'Long cart', beams: [12, 8, 5, 3], cart: [6, 5, 5, 3, 3, 2, 2, 1, 1], solution: [0, 2, 0, 1, 3, 1, 1, 0, 1] },
18 { name: 'Four and four', beams: [7, 7, 7, 7], cart: [5, 5, 5, 5, 2, 2, 2, 2], solution: [0, 1, 2, 3, 0, 1, 2, 3] },
19 ].map(l => ({ ...l, par: l.solution.length }));
20
21 const sum = a => a.reduce((t, w) => t + w, 0);
22 // load(beam) -> total weight of the bales on a beam, st
23 const load = beam => sum(beam.bales);
24
25 function describe(s) {
26 if (s.status === 'won') {
27 const d = s.moves - s.par;
28 const vs = d === 0 ? 'at par' : d < 0 ? `${-d} under par` : `${d} over par`;
29 return `You win: every bale is in the loft in ${s.moves} ${s.moves === 1 ? 'move' : 'moves'} against par ${s.par}, ${vs}`;
30 }
31 return `${s.cart.length} ${s.cart.length === 1 ? 'bale' : 'bales'} on the cart, ${s.moves} of par ${s.par} moves` +
32 (s.cart.length ? `, next bale ${s.cart[0]} st` : '');
33 }
34
35 // finish(s): sets status and line after a change
36 function finish(s) {
37 s.status = s.cart.length === 0 && s.beams.every(b => load(b) <= b.load) ? 'won' : 'playing';
38 s.line = describe(s);
39 return { ok: true, state: s, why: '' };
40 }
41
42 // start(level) -> a fresh state for level index 0..LEVELS.length-1; anything else starts level 0
43 function start(level) {
44 const i = Number.isInteger(level) && level >= 0 && level < LEVELS.length ? level : 0;
45 const l = LEVELS[i];
46 const s = {
47 level: i, name: l.name, par: l.par, moves: 0, status: 'playing',
48 beams: l.beams.map(c => ({ load: c, bales: [] })), cart: [...l.cart], line: '',
49 };
50 s.line = describe(s);
51 return s;
52 }
53
54 const reject = (state, why) => ({ ok: false, state, why });
55 const clone = s => ({ ...s, beams: s.beams.map(b => ({ ...b, bales: [...b.bales] })), cart: [...s.cart] });
56 const unknown = (s, beam) => !Number.isInteger(beam) || beam < 0 || beam >= s.beams.length;
57
58 // hoist(state, beam) -> { ok, state, why }: lifts the next bale on the cart onto a beam
59 function hoist(state, beam) {
60 if (state.status !== 'playing') return reject(state, 'game-over');
61 if (unknown(state, beam)) return reject(state, 'unknown-beam');
62 if (!state.cart.length) return reject(state, 'empty-cart');
63 if (load(state.beams[beam]) + state.cart[0] > state.beams[beam].load) return reject(state, 'overload');
64 const s = clone(state);
65 s.beams[beam].bales.push(s.cart.shift());
66 s.moves += 1;
67 return finish(s);
68 }
69
70 // lower(state, beam) -> { ok, state, why }: takes the top bale off a beam and puts it on the front of the cart
71 function lower(state, beam) {
72 if (state.status !== 'playing') return reject(state, 'game-over');
73 if (unknown(state, beam)) return reject(state, 'unknown-beam');
74 if (!state.beams[beam].bales.length) return reject(state, 'empty-beam');
75 const s = clone(state);
76 s.cart.unshift(s.beams[beam].bales.pop());
77 s.moves += 1;
78 return finish(s);
79 }
80
81 // restart(state) -> the same level from the beginning; always allowed
82 const restart = state => start(state.level);
83
84 const api = { LEVELS, start, hoist, lower, restart, load, describe };
85 if (typeof module !== 'undefined') module.exports = api; else root.HayLoft = api;
86 })(typeof window !== 'undefined' ? window : globalThis);
hay-loft/stack.test.mjs added +92
@@ -0,0 +1,92 @@
1 import { test, expect } from 'bun:test';
2 import { LEVELS, start, hoist, lower, restart, load } from './stack.js';
3
4 const ok = r => { expect(r.ok).toBe(true); return r.state; };
5 const solve = i => LEVELS[i].solution.reduce((s, b) => ok(hoist(s, b)), start(i));
6 const sum = a => a.reduce((t, w) => t + w, 0);
7
8 test('at least six levels, each with beams, cart and par', () => {
9 expect(LEVELS.length).toBeGreaterThanOrEqual(6);
10 LEVELS.forEach((l, i) => {
11 const s = start(i);
12 expect(s.beams.map(b => b.load)).toEqual(l.beams);
13 expect(s.cart).toEqual(l.cart);
14 expect(s.par).toBe(l.solution.length);
15 expect([s.moves, s.status]).toEqual([0, 'playing']);
16 });
17 });
18
19 test('each level: cart total fits the beam loads, stated solution wins within par', () => {
20 LEVELS.forEach((l, i) => {
21 expect(sum(l.cart)).toBeLessThanOrEqual(sum(l.beams));
22 const s = solve(i);
23 expect(s.status).toBe('won');
24 expect(s.moves).toBeLessThanOrEqual(s.par);
25 s.beams.forEach(b => expect(load(b)).toBeLessThanOrEqual(b.load));
26 expect(s.cart).toEqual([]);
27 });
28 });
29
30 test('refusals return the same state with a reason', () => {
31 const s = start(0);
32 for (const b of [-1, 2, 0.5, NaN, '0', undefined]) {
33 for (const f of [hoist, lower]) { const r = f(s, b); expect([r.ok, r.why, r.state]).toEqual([false, 'unknown-beam', s]); }
34 }
35 const r = lower(s, 0);
36 expect([r.ok, r.why, r.state]).toEqual([false, 'empty-beam', s]);
37 const t = { ...start(2), cart: [] };
38 expect(hoist(t, 0).why).toBe('empty-cart');
39 expect(hoist(t, 0).state).toBe(t);
40 const full = ok(hoist(start(2), 2)); // 4 st on a 4 st beam
41 const o = hoist(full, 2); // next bale 3 st
42 expect([o.ok, o.why, o.state]).toEqual([false, 'overload', full]);
43 const won = solve(0);
44 for (const f of [hoist, lower]) { const g = f(won, 0); expect([g.ok, g.why, g.state]).toEqual([false, 'game-over', won]); }
45 });
46
47 test('hoist moves the next bale; a refused hoist leaves the input untouched', () => {
48 const s = start(0), before = JSON.stringify(s);
49 const t = ok(hoist(s, 1));
50 expect([t.beams[1].bales, t.cart, t.moves]).toEqual([[4], [6, 5, 5], 1]);
51 expect(JSON.stringify(s)).toBe(before);
52 });
53
54 test('lower puts the bale back on the front of the cart where hoist took it; both cost a move', () => {
55 const s = start(0);
56 const up = ok(hoist(ok(hoist(s, 0)), 1));
57 const down = ok(lower(up, 1));
58 expect(down.cart).toEqual([6, 5, 5]);
59 expect(down.beams[1].bales).toEqual([]);
60 expect(down.moves).toBe(3);
61 const again = ok(lower(ok(hoist(s, 0)), 0));
62 expect([again.cart, again.beams, again.moves]).toEqual([s.cart, s.beams, 2]);
63 // lowers take the top bale
64 const two = ok(hoist(ok(hoist(s, 0)), 0));
65 expect(ok(lower(two, 0)).cart[0]).toBe(6);
66 });
67
68 test('win line: at par, over par, and under par', () => {
69 const at = solve(0);
70 expect(at.line).toBe('You win: every bale is in the loft in 4 moves against par 4, at par');
71 let over = ok(lower(ok(hoist(start(0), 0)), 0));
72 over = LEVELS[0].solution.reduce((s, b) => ok(hoist(s, b)), over);
73 expect(over.status).toBe('won');
74 expect(over.line).toBe('You win: every bale is in the loft in 6 moves against par 4, 2 over par');
75 const under = { ...start(0), par: 5, moves: 3, cart: [5], beams: [{ load: 10, bales: [4, 6] }, { load: 10, bales: [5] }] };
76 expect(ok(hoist(under, 1)).line).toBe('You win: every bale is in the loft in 4 moves against par 5, 1 under par');
77 expect(start(0).line).toContain('next bale 4 st');
78 });
79
80 test('no loss: any number of moves keeps playing; restart is always allowed', () => {
81 let s = start(1);
82 for (let i = 0; i < 50; i++) s = ok(lower(ok(hoist(s, 2)), 2));
83 expect([s.status, s.moves]).toEqual(['playing', 100]);
84 expect(restart(s)).toEqual(start(1));
85 const w = solve(3);
86 expect(restart(w)).toEqual(start(3));
87 expect(restart(w).status).toBe('playing');
88 });
89
90 test('start: bad level falls back to level 0', () => {
91 for (const l of [undefined, -1, 99, 1.5, 'x']) expect(start(l)).toEqual(start(0));
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs