clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

feat: add three rule-learning puzzle levels

Dami committed 1a5995d
commit
1a5995de9cdc056da115090df3532b76c54af603
parent
6a82b24b3ee43995378f9d6e390da12e0f1fd9ad
author
Dami <dami.etoile@gmail.com>
2 files changed, +144 -0
Asrc/levels.js75++++++++++++++++++++++++++++++
Atests/levels.test.js69++++++++++++++++++++++++++++
A src/levels.js +75 -0
@@ -0,0 +1,75 @@
1+// Small, hand-authored tutorial boards. Words are ordinary pushable objects;2+// the engine derives the rules from their current positions on every turn.3+export const levels = [4+ {5+ title: 'Break the rule',6+ hint: 'Push STOP out of ROCK IS STOP, then walk through the rock.',7+ width: 12,8+ height: 9,9+ objects: [10+ { kind: 'object', noun: 'MIAU', x: 1, y: 3 },11+ { kind: 'object', noun: 'FLAG', x: 10, y: 5 },12+ ...Array.from({ length: 12 }, (_, x) => ({ kind: 'object', noun: 'ROCK', x, y: 4 })),13+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },14+ { kind: 'word', text: 'IS', x: 1, y: 0 },15+ { kind: 'word', text: 'YOU', x: 2, y: 0 },16+ { kind: 'word', text: 'FLAG', x: 5, y: 0 },17+ { kind: 'word', text: 'IS', x: 6, y: 0 },18+ { kind: 'word', text: 'WIN', x: 7, y: 0 },19+ { kind: 'word', text: 'ROCK', x: 1, y: 2 },20+ { kind: 'word', text: 'IS', x: 2, y: 2 },21+ { kind: 'word', text: 'STOP', x: 3, y: 2 },22+ ],23+ },24+ {25+ title: 'Make room to push',26+ hint: 'Break STOP, push ROCK through the narrow corridor, then take the upper exit to FLAG.',27+ width: 12,28+ height: 9,29+ objects: [30+ { kind: 'object', noun: 'MIAU', x: 1, y: 5 },31+ { kind: 'object', noun: 'ROCK', x: 4, y: 5 },32+ { kind: 'object', noun: 'FLAG', x: 10, y: 5 },33+ ...Array.from({ length: 12 }, (_, x) => x === 1 ? null : ({ kind: 'object', noun: 'WALL', x, y: 4 })).filter(Boolean),34+ ...Array.from({ length: 12 }, (_, x) => ({ kind: 'object', noun: 'WALL', x, y: 6 })),35+ { kind: 'word', text: 'MIAU', x: 3, y: 0 },36+ { kind: 'word', text: 'IS', x: 4, y: 0 },37+ { kind: 'word', text: 'YOU', x: 5, y: 0 },38+ { kind: 'word', text: 'FLAG', x: 6, y: 0 },39+ { kind: 'word', text: 'IS', x: 7, y: 0 },40+ { kind: 'word', text: 'WIN', x: 8, y: 0 },41+ { kind: 'word', text: 'ROCK', x: 6, y: 1 },42+ { kind: 'word', text: 'IS', x: 7, y: 1 },43+ { kind: 'word', text: 'PUSH', x: 8, y: 1 },44+ { kind: 'word', text: 'ROCK', x: 1, y: 1 },45+ { kind: 'word', text: 'IS', x: 1, y: 2 },46+ { kind: 'word', text: 'STOP', x: 1, y: 3 },47+ // Keep the wall rule in the sealed lower area so it cannot be disabled48+ // as a shortcut around the corridor lesson.49+ { kind: 'word', text: 'WALL', x: 5, y: 8 },50+ { kind: 'word', text: 'IS', x: 6, y: 8 },51+ { kind: 'word', text: 'STOP', x: 7, y: 8 },52+ ],53+ },54+ {55+ title: 'Two things can win',56+ hint: 'Read ROCK AND FLAG IS WIN, then reach the rock.',57+ width: 12,58+ height: 9,59+ objects: [60+ { kind: 'object', noun: 'MIAU', x: 1, y: 4 },61+ { kind: 'object', noun: 'ROCK', x: 7, y: 4 },62+ { kind: 'object', noun: 'FLAG', x: 10, y: 4 },63+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },64+ { kind: 'word', text: 'IS', x: 1, y: 0 },65+ { kind: 'word', text: 'YOU', x: 2, y: 0 },66+ { kind: 'word', text: 'ROCK', x: 0, y: 2 },67+ { kind: 'word', text: 'AND', x: 1, y: 2 },68+ { kind: 'word', text: 'FLAG', x: 2, y: 2 },69+ { kind: 'word', text: 'IS', x: 3, y: 2 },70+ { kind: 'word', text: 'WIN', x: 4, y: 2 },71+ ],72+ },73+];74+75+export default levels;
A tests/levels.test.js +69 -0
@@ -0,0 +1,69 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createGame } from '../src/engine.js';4+import { levels } from '../src/levels.js';5+6+const move = (game, direction, count = 1) => {7+ const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction];8+ for (let index = 0; index < count; index++) game.step(...delta);9+};10+11+test('built-in levels have compact rectangular boards and typed objects', () => {12+ assert.equal(levels.length, 3);13+ for (const level of levels) {14+ assert.equal(level.width, 12);15+ assert.equal(level.height, 9);16+ assert.ok(level.objects.length > 0);17+ for (const object of level.objects) {18+ assert.ok(['word', 'object'].includes(object.kind));19+ assert.ok(Number.isInteger(object.x) && Number.isInteger(object.y));20+ assert.ok(object.x >= 0 && object.x < level.width);21+ assert.ok(object.y >= 0 && object.y < level.height);22+ }23+ }24+});25+26+test('level 1 teaches that breaking STOP changes movement', () => {27+ const game = createGame(levels[0]);28+ const rules = game.getState().rules.map(rule => rule.text);29+ assert.ok(rules.includes('MIAU IS YOU'));30+ assert.ok(rules.includes('FLAG IS WIN'));31+ assert.ok(rules.includes('ROCK IS STOP'));32+33+ move(game, 'right', 2);34+ move(game, 'up', 2); // Push STOP out of the sentence.35+ assert.ok(!game.has('ROCK', 'STOP'));36+ move(game, 'down', 4);37+ move(game, 'right', 7); // Walk through ROCK and onto FLAG.38+ assert.equal(game.getState().won, true);39+});40+41+test('level 2 preserves PUSH while STOP is removed', () => {42+ const game = createGame(levels[1]);43+ assert.equal(game.has('ROCK', 'PUSH'), true);44+ assert.equal(game.has('ROCK', 'STOP'), true);45+ assert.equal(game.has('WALL', 'STOP'), true);46+47+ move(game, 'up', 2); // Enter the gap and push the vertical STOP sentence.48+ move(game, 'left');49+ move(game, 'up');50+ move(game, 'right'); // Nudge STOP sideways to finish breaking the rule.51+ assert.equal(game.has('ROCK', 'STOP'), false);52+ assert.equal(game.has('ROCK', 'PUSH'), true);53+ assert.equal(game.has('WALL', 'STOP'), true);54+ move(game, 'down', 3); // Return to the corridor entrance.55+ move(game, 'right', 8); // Reach ROCK and push it to x=10.56+ const rock = game.getState().objects.find(object => object.kind === 'object' && object.noun === 'ROCK');57+ assert.equal(rock.x, 10);58+ assert.equal(game.getState().won, false);59+ move(game, 'right'); // Push ROCK through FLAG to x=11; MIAU enters FLAG.60+ assert.equal(game.getState().won, true);61+});62+63+test('level 3 teaches an AND subject list', () => {64+ const game = createGame(levels[2]);65+ assert.equal(game.has('ROCK', 'WIN'), true);66+ assert.equal(game.has('FLAG', 'WIN'), true);67+ move(game, 'right', 6);68+ assert.equal(game.getState().won, true);69+});