clone

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

feat(content): add Garden Basics and Word Lab proof packs

Dami committed 86124f9
commit
86124f98c4a52b0b44245daa431db6b1a64d5913
parent
5a35bdd5bf55f3a002fb3c1dd52539801628dbae
author
Dami <dami.etoile@gmail.com>
5 files changed, +391 -0
Adocs/level-pack-curriculum.md115++++++++++++++++++++++++++++++
Amods/garden-basics.cjs63++++++++++++++++
Amods/word-lab.cjs79+++++++++++++++++++++
Atests/garden-basics.test.js61++++++++++++++++
Atests/word-lab.test.js73+++++++++++++++++++
A docs/level-pack-curriculum.md +115 -0
@@ -0,0 +1,115 @@
1+# Basic level-pack curriculum2+3+This is a proposed teaching pack for the current MVP vocabulary. It extends4+the three shipped puzzles without assuming `NOT`, spatial conditions, `OPEN`,5+`HOT`, `TEXT`, or meta-level rules. Each level should isolate one question,6+then give the player a reason to reuse the answer.7+8+The sequence follows the design-backbone recommendation to teach one word or9+property at low pressure, reuse it in a second context, and keep difficult10+levels optional. It also follows Teikari's account of organizing areas around11+new words and moving unusually hard stages into an “Extra” category12+([creator interview](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)).13+14+## Proposed basic pack15+16+| Order | Level | Player should learn | Authoring constraint |17+| --- | --- | --- | --- |18+| 0 | **Read the board** | Words are physical tiles; `MIAU IS YOU` gives control; `FLAG IS WIN` is a goal; walking is one turn at a time. | Open room, no push required, no rule-breaking. Put the goal in direct sight. |19+| 1 | **Break the rule** *(shipped)* | Breaking a sentence immediately removes its property. `ROCK IS STOP` can be dismantled by pushing `STOP`. | The intended solution must use the rule change; do not leave a walk-around route. |20+| 2 | **Make room to push** *(shipped)* | `PUSH` changes a legal push chain; `STOP` affects where the player and rock can travel. | Teach one box-like object and one narrow corridor. Keep the wall rule inaccessible until wall behavior is understood. |21+| 3 | **Two things can win** *(shipped)* | `AND` creates multiple subjects for one property; either `ROCK` or `FLAG` can satisfy `WIN`. | Place the two candidate winners visibly and make only one reachable before the player reads `AND`. |22+| 4 | **Change what you are** | A noun-to-noun rule such as `MIAU IS ROCK` transforms an object; a transformed object can gain a second rule such as `ROCK IS PUSH`. | Use one transformation only. The before and after nouns need distinct glyphs and the transformed route must be necessary. |23+| 5 | **A thing can disappear** | `SINK` consumes every occupant in a contact cell, including the controlled object. Undo restores the complete stack. | Make the first sink interaction safe to observe, then require a controlled sink on the second attempt. Avoid pairing `SINK` with `WIN` in this lesson. |24+| 6 | **A dangerous thing** | `DEFEAT` removes a controlled object on contact; losing control is recoverable with undo or restart. | Show the hazard and a safe detour first. A defeat must teach a fact needed for the solution, not be a surprise penalty. |25+| 7 | **Rules are cargo** | Words are pushable like objects and may share a cell with objects or other words. Moving one rule tile can open a route while preserving another rule. | Use two short sentences with one shared word or a deliberate word stack. The intended state should be readable in the ledger and on the board. |26+| 8 | **Mixed properties** | One subject can have `YOU AND PUSH` or `PUSH AND STOP`; the player must reason about the combined capabilities and their precedence. | Introduce the combination in a small room, then make the challenge optional. Do not introduce a new noun here. |27+28+Levels 0–3 are required onboarding. Levels 4–6 are the core property tour.29+Levels 7–8 are `challenge` levels until the engine's stacked-word and30+multi-property presentation is proven in playtests. A garden can replace the31+names and glyphs while preserving the learning objective.32+33+## Author checklist for each stage34+35+Each level record should carry:36+37+```js38+{39+ id: 'read-the-board',40+ title: 'Read the board',41+ hint: 'Walk to the flag.',42+ category: 'required', // required | optional | challenge43+ introduces: ['YOU', 'WIN'],44+ reuses: [],45+ objective: 'Teach direct control and contact with a winning noun',46+ objects: []47+}48+```49+50+The author should be able to answer these questions before shipping:51+52+1. What new word, interaction, or state fact is this level teaching?53+2. What earlier fact does it deliberately reuse?54+3. Which visible board element tells the player where to experiment?55+4. What is the intended first useful action, and can a player undo it?56+5. Is there a plausible shortcut that bypasses the lesson?57+6. Does the hint name an observation or action rather than reveal the whole58+ move sequence?59+60+For the current engine, level validation should also report whether the initial61+state contains a `YOU` entity, whether the goal is reachable by at least one62+replay, and whether an intended rule is initially active. A level may be63+deliberately unsolved by the static checker only when its metadata says it is64+an editor or sandbox level.65+66+## Pack identity and credits67+68+The CommonJS manifest should present the pack as a small authored work. Add69+these optional fields to the existing manifest contract, with `name` and `id`70+remaining the stable machine and display identifiers:71+72+```js73+manifest: {74+ id: 'garden-basics',75+ name: 'Garden Basics',76+ version: '1.0.0',77+ author: 'A. Designer',78+ description: 'Eight introductory sentence-pushing puzzles.',79+ license: 'CC BY 4.0',80+ source: 'https://example.com/garden-basics',81+ credits: [82+ { role: 'design', name: 'A. Designer' },83+ { role: 'art', name: 'B. Artist' }84+ ]85+}86+```87+88+Identity rules:89+90+* `id`, semantic `version`, and `name` are required. The loader uses `id` plus91+ version for saves and replay provenance; changing either should create a new92+ published pack identity.93+* `author` and `credits` are display metadata, not executable code. Preserve94+ the declared order, and show them in the pack drawer and post-win credits.95+* `license` and `source` are optional but should be shown when supplied. A96+ missing license means “all rights reserved” in the UI rather than an97+ inferred open license.98+* A level can add `author`, `source`, or `credits` only when it differs from99+ the pack defaults. The host should merge display metadata without changing100+ simulation state.101+* Replay records should include pack `id`, version, level `id`, and engine102+ version so a shared solution can be attributed and diagnosed later.103+104+These fields support attribution and reproducibility without allowing a mod to105+change the host's simulation or presentation policy.106+107+## Deferred lessons108+109+Do not add `NOT`, `ON`, `NEAR`, `LONELY`, `OPEN`/`SHUT`, `HOT`/`MELT`, `ALL`,110+`EMPTY`, `TEXT`, or level-map transformations to this basic pack. Each should111+get a separate garden with an introduction, a reuse level, a challenge level,112+and an explicit conflict example. The design-backbone research records that113+the reference game's conditional and meta words created major parser and114+complexity costs, so these belong after the basic turn contract is stable.115+
A mods/garden-basics.cjs +63 -0
@@ -0,0 +1,63 @@
1+// A small follow-up pack for the three built-in MIAU lessons.2+const { word, object, level } = require('@miau/engine');3+4+const rule = (subject, property, x, y) => [5+ word(subject, x, y), word('IS', x + 1, y), word(property, x + 2, y),6+];7+8+module.exports = {9+ manifest: {10+ id: 'garden-basics',11+ name: 'Garden Basics',12+ version: '1.0.0',13+ engine: 1,14+ author: 'Miau examples',15+ description: 'Three compact follow-up puzzles about pushing, hazards, and safe routes.',16+ },17+ vocabulary: ['MIAU', 'ROCK', 'FLAG', 'WATER', 'THORN'],18+ palette: {19+ MIAU: '🐈',20+ ROCK: '🪨',21+ FLAG: '🚩',22+ WATER: '💧',23+ THORN: '🌵',24+ },25+ levels: [26+ level(9, 7, [27+ ...rule('MIAU', 'YOU', 0, 0),28+ ...rule('ROCK', 'PUSH', 0, 1),29+ ...rule('FLAG', 'WIN', 0, 2),30+ object('MIAU', 1, 3, 'garden-push-miau'),31+ object('ROCK', 4, 3, 'garden-push-rock'),32+ object('FLAG', 7, 3, 'garden-push-flag'),33+ ], {34+ id: 'garden-push',35+ title: 'Clear the Garden Path',36+ hint: 'Get below ROCK, push it up, then walk to FLAG.',37+ }),38+ level(9, 7, [39+ ...rule('MIAU', 'YOU', 0, 0),40+ ...rule('WATER', 'SINK', 0, 1),41+ ...rule('FLAG', 'WIN', 0, 2),42+ object('MIAU', 1, 3, 'garden-pond-miau'),43+ object('WATER', 4, 3, 'garden-pond-water'),44+ object('FLAG', 7, 3, 'garden-pond-flag'),45+ ], {46+ id: 'garden-pond',47+ title: 'Around the Pond',48+ hint: 'Water sinks MIAU. Take the high path around it to FLAG.',49+ }),50+ level(9, 7, [51+ ...rule('MIAU', 'YOU', 0, 0),52+ ...rule('THORN', 'DEFEAT', 0, 1),53+ ...rule('FLAG', 'WIN', 0, 2),54+ object('MIAU', 1, 3, 'garden-thorn-miau'),55+ object('THORN', 4, 3, 'garden-thorn-hazard'),56+ object('FLAG', 7, 3, 'garden-thorn-flag'),57+ ], {58+ id: 'garden-thorns',59+ title: 'Mind the Thorns',60+ hint: 'Thorns defeat MIAU. Use the high path to reach FLAG.',61+ }),62+ ],63+};
A mods/word-lab.cjs +79 -0
@@ -0,0 +1,79 @@
1+// A second curated pack for the MVP engine. The levels isolate AND control,2+// noun transformation, and editing one property out of a multi-property rule.3+const { word, object, level } = require('@miau/engine');4+5+module.exports = {6+ manifest: {7+ id: 'word-lab',8+ name: 'Word Lab',9+ version: '1.0.0',10+ engine: 1,11+ author: 'Miau engine team',12+ description: 'Three compact experiments in shared control, transformation, and rule editing.',13+ },14+ vocabulary: ['MIAU', 'ROCK', 'FLAG', 'WALL', 'GEM'],15+ palette: {16+ MIAU: '🐱',17+ ROCK: '🪨',18+ FLAG: '🚩',19+ WALL: '🧱',20+ GEM: '💎',21+ },22+ levels: [23+ level(10, 5, [24+ // Two nouns share control. MIAU is stopped while ROCK can reach FLAG.25+ word('MIAU', 0, 0, 'shared-miau'), word('AND', 1, 0, 'shared-and'),26+ word('ROCK', 2, 0, 'shared-rock'), word('IS', 3, 0, 'shared-is'),27+ word('YOU', 4, 0, 'shared-you'),28+ word('FLAG', 6, 0, 'shared-flag'), word('IS', 7, 0, 'shared-flag-is'),29+ word('WIN', 8, 0, 'shared-win'),30+ word('WALL', 6, 1, 'shared-wall'), word('IS', 7, 1, 'shared-wall-is'),31+ word('STOP', 8, 1, 'shared-stop'),32+ object('MIAU', 1, 2, 'shared-player'), object('WALL', 2, 2, 'shared-barrier'),33+ object('ROCK', 5, 2, 'shared-rock-object'), object('FLAG', 8, 2, 'shared-flag-object'),34+ ], {35+ id: 'shared-control',36+ title: 'Shared control',37+ hint: 'The wall traps MIAU. Read the AND sentence and move the other YOU to FLAG.',38+ category: 'required',39+ introduces: ['AND'],40+ }),41+42+ level(9, 3, [43+ // ROCK becomes GEM at reset, then GEM IS YOU gives the transformed noun control.44+ word('ROCK', 0, 0, 'change-rock'), word('IS', 1, 0, 'change-rock-is'),45+ word('GEM', 2, 0, 'change-gem'),46+ word('GEM', 4, 0, 'change-gem-subject'), word('IS', 5, 0, 'change-gem-is'),47+ word('YOU', 6, 0, 'change-you'),48+ word('FLAG', 0, 1, 'change-flag'), word('IS', 1, 1, 'change-flag-is'),49+ word('WIN', 2, 1, 'change-win'),50+ object('ROCK', 1, 2, 'change-rock-object'), object('FLAG', 7, 2, 'change-flag-object'),51+ ], {52+ id: 'change-what-you-are',53+ title: 'Change what you are',54+ hint: 'ROCK changes into GEM. Follow the new noun to the flag.',55+ category: 'required',56+ introduces: ['GEM'],57+ }),58+59+ level(10, 5, [60+ // ROCK starts with PUSH and STOP. Push STOP out of the sentence while61+ // keeping ROCK IS PUSH, then move the rock to FLAG.62+ word('MIAU', 0, 0, 'cargo-miau'), word('IS', 1, 0, 'cargo-miau-is'),63+ word('YOU', 2, 0, 'cargo-you'),64+ word('FLAG', 6, 0, 'cargo-flag'), word('IS', 7, 0, 'cargo-flag-is'),65+ word('WIN', 8, 0, 'cargo-win'),66+ word('ROCK', 1, 1, 'cargo-rock'), word('IS', 2, 1, 'cargo-rock-is'),67+ word('PUSH', 3, 1, 'cargo-push'), word('AND', 4, 1, 'cargo-and'),68+ word('STOP', 5, 1, 'cargo-stop'),69+ object('MIAU', 1, 3, 'cargo-player'), object('ROCK', 6, 3, 'cargo-rock-object'),70+ object('FLAG', 8, 3, 'cargo-flag-object'),71+ ], {72+ id: 'rules-are-cargo',73+ title: 'Rules are cargo',74+ hint: 'Push STOP upward out of ROCK IS PUSH AND STOP, then push the rock aside and step onto FLAG.',75+ category: 'challenge',76+ introduces: ['PUSH', 'STOP'],77+ }),78+ ],79+};
A tests/garden-basics.test.js +61 -0
@@ -0,0 +1,61 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { readFile } from 'node:fs/promises';4+import { createGame } from '../src/engine.js';5+import { loadMod } from '../src/mods.js';6+7+const source = await readFile(new URL('../mods/garden-basics.cjs', import.meta.url), 'utf8');8+const garden = loadMod(source, { filename: 'garden-basics.cjs' });9+10+const move = (game, direction, count = 1) => {11+ const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction];12+ for (let index = 0; index < count; index++) game.step(...delta);13+};14+15+test('garden basics has stable metadata and one active campaign pack', () => {16+ assert.equal(garden.manifest.id, 'garden-basics');17+ assert.deepEqual(garden.levels.map(level => level.id), ['garden-push', 'garden-pond', 'garden-thorns']);18+ assert.equal(new Set(garden.levels.map(level => level.id)).size, garden.levels.length);19+ assert.deepEqual(garden.vocabulary, ['MIAU', 'ROCK', 'FLAG', 'WATER', 'THORN']);20+});21+22+test('garden-push requires the PUSH rule to clear ROCK', () => {23+ const game = createGame(garden.levels[0]);24+ assert.equal(game.has('ROCK', 'PUSH'), true);25+ move(game, 'right');26+ move(game, 'down');27+ move(game, 'right', 2);28+ move(game, 'up');29+ const rock = game.getState().objects.find(object => object.id === 'garden-push-rock');30+ assert.equal(rock.y, 2);31+ move(game, 'right', 3);32+ assert.equal(game.getState().won, true);33+});34+35+test('garden-pond exposes SINK and still has a safe winning route', () => {36+ const hazard = createGame(garden.levels[1]);37+ assert.equal(hazard.has('WATER', 'SINK'), true);38+ move(hazard, 'right', 3);39+ assert.equal(hazard.getState().objects.some(object => object.id === 'garden-pond-miau'), false);40+41+ const game = createGame(garden.levels[1]);42+ move(game, 'right', 2);43+ move(game, 'up', 2);44+ move(game, 'right', 4);45+ move(game, 'down', 2);46+ assert.equal(game.getState().won, true);47+});48+49+test('garden-thorns exposes DEFEAT and still has a safe winning route', () => {50+ const hazard = createGame(garden.levels[2]);51+ assert.equal(hazard.has('THORN', 'DEFEAT'), true);52+ move(hazard, 'right', 3);53+ assert.equal(hazard.getState().objects.some(object => object.id === 'garden-thorn-miau'), false);54+55+ const game = createGame(garden.levels[2]);56+ move(game, 'right', 2);57+ move(game, 'up', 2);58+ move(game, 'right', 4);59+ move(game, 'down', 2);60+ assert.equal(game.getState().won, true);61+});
A tests/word-lab.test.js +73 -0
@@ -0,0 +1,73 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { readFile } from 'node:fs/promises';4+import { createGame } from '../src/engine.js';5+import { loadMod } from '../src/mods.js';6+7+const source = await readFile(new URL('../mods/word-lab.cjs', import.meta.url), 'utf8');8+const pack = loadMod(source, { filename: 'word-lab.cjs' });9+const move = (game, direction, count = 1) => {10+ const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction];11+ for (let index = 0; index < count; index++) game.step(...delta);12+};13+14+test('Word Lab has a stable identity and three authored levels', () => {15+ assert.equal(pack.manifest.id, 'word-lab');16+ assert.equal(pack.manifest.version, '1.0.0');17+ assert.deepEqual(pack.levels.map(level => level.id), [18+ 'shared-control',19+ 'change-what-you-are',20+ 'rules-are-cargo',21+ ]);22+ assert.equal(pack.palette.GEM, '💎');23+});24+25+test('shared control uses AND to let ROCK solve the level while MIAU is stopped', () => {26+ const game = createGame(pack.levels[0]);27+ assert.equal(game.has('MIAU', 'YOU'), true);28+ assert.equal(game.has('ROCK', 'YOU'), true);29+ assert.equal(game.has('WALL', 'STOP'), true);30+31+ const first = game.step(1, 0);32+ assert.equal(first.accepted, true);33+ assert.deepEqual(34+ (({ id, kind, noun, x, y }) => ({ id, kind, noun, x, y }))(game.getState().objects.find(object => object.id === 'shared-player')),35+ { id: 'shared-player', kind: 'object', noun: 'MIAU', x: 1, y: 2 },36+ );37+ assert.equal(game.getState().objects.find(object => object.id === 'shared-rock-object').x, 6);38+ move(game, 'right', 2);39+ assert.equal(game.getState().won, true);40+});41+42+test('noun transformation changes ROCK into the controllable GEM', () => {43+ const game = createGame(pack.levels[1]);44+ const transformed = game.getState().objects.find(object => object.id === 'change-rock-object');45+ assert.equal(transformed.noun, 'GEM');46+ assert.equal(game.has('ROCK', 'GEM'), true);47+ assert.equal(game.has('GEM', 'YOU'), true);48+ assert.equal(game.getState().objects.some(object => game.properties(object).includes('YOU') && object.noun === 'ROCK'), false);49+50+ move(game, 'right', 6);51+ assert.equal(game.getState().won, true);52+});53+54+test('editing a stacked predicate removes STOP while preserving PUSH', () => {55+ const game = createGame(pack.levels[2]);56+ assert.equal(game.has('ROCK', 'PUSH'), true);57+ assert.equal(game.has('ROCK', 'STOP'), true);58+59+ // Before the edit, ROCK is STOP and cannot be pushed right.60+ move(game, 'right', 4);61+ assert.equal(game.step(1, 0).accepted, false);62+ assert.equal(game.getState().objects.find(object => object.id === 'cargo-rock-object').x, 6);63+64+ // Reach the STOP word from below and push it out of the sentence.65+ move(game, 'up', 2);66+ assert.equal(game.has('ROCK', 'STOP'), false);67+ assert.equal(game.has('ROCK', 'PUSH'), true);68+69+ // Return to the left of ROCK and push it onto FLAG.70+ move(game, 'down', 2);71+ move(game, 'right', 3);72+ assert.equal(game.getState().won, true);73+});