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
Check · Review · Land · Live done — Landed and live in production.
torque/pixel-plays:torque/316-hay-loft-rules-a-tested-hay-loft-stack-j → main 1 commit fork
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 | }); |
new above
offline