feat: Stress it beat for Modules 6-10, Module 11 (Break your own engine), level solver tool
- lessons 8 and 9 updated for the once-per-move rule, per-object negated conversion and the new ordering tests - tools/solve-spellgrid.mjs: breadth-first search over a SpellGrid level with the TypeScript reference; --never-rule and --naive - README, harness.md and the mentor skill know the new beat and module Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
e907f17937
commit
4cb0f3a61f
13 files changed
+771
-19
No files matched your search
@@ -5,8 +5,9 @@ A free, hands-on course on **systemic game design**. You learn by rebuilding the
|
||||
- **Part I: Hearthstone** (cards, zones, triggers, death phases, auras, text assembly)
|
||||
- **Part II: Baba Is You** (rules as pushable text, parsing, movement, win/lose)
|
||||
- **Part III: SpellGrid**, a small original game that combines both systems
|
||||
- **Module 11: Break your own engine**, an optional closing module on testing what the tests miss: mutation testing, invariants, metamorphic relations, undo and replay, and a level solver
|
||||
|
||||
Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine.
|
||||
Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Stress it, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine. Stress it is optional: edge cases and stretch exercises that push the design past what the module's tests check.
|
||||
|
||||
## Two paths
|
||||
|
||||
@@ -41,7 +42,7 @@ node tools/start-module.mjs 01 ~/my-engine
|
||||
|
||||
`start-module` copies a module's tests for your track (from your workspace's `.course.json`) and refuses to start a module if you have not yet taken the previous one. Module 00 uses the `naive-engine/` project and is not started this way.
|
||||
|
||||
Other tools: `tools/coupling-audit.mjs <workspace>` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs <workspace> <baseRef>` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests; `--expansion` also checks every Expansion Drop solution), `tools/play-spellgrid.mjs <rust|ts> [l1|l2|l3]` (play the Module 10 capstone, SpellGrid, on the command line with the reference engine).
|
||||
Other tools: `tools/coupling-audit.mjs <workspace>` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs <workspace> <baseRef>` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests; `--expansion` also checks every Expansion Drop solution), `tools/play-spellgrid.mjs <rust|ts> [l1|l2|l3]` (play the Module 10 capstone, SpellGrid, on the command line with the reference engine), `tools/solve-spellgrid.mjs [l1|l2|l3|level.json]` (search a SpellGrid level for its shortest solution by brute force; `--never-rule "X IS Y"` asks whether it can be solved without a given rule; see Module 11).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -337,6 +337,69 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
|
||||
|
||||
</details>
|
||||
|
||||
## Stress it
|
||||
|
||||
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
|
||||
|
||||
1. One row: `BABA IS ROCK IS PUSH`. How many rules does your parser read, and which? Then one row: `BABA AND IS YOU`, and one row: `BABA IS YOU AND`.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
`BABA IS ROCK IS PUSH` gives two rules in the reference: `BABA IS ROCK` and `ROCK IS PUSH`. Each `IS` reads outwards on its own, and the word `ROCK` is the complement of the first and the subject of the second. Nothing in "start at every `IS`" stops two sentences sharing a word end to end, just as the crossword test shares an `IS` at right angles. Whether the real game reads this row the same way is not in the course's sources; if you care, try it in the game.
|
||||
|
||||
`BABA AND IS YOU` spells nothing: the tile before `IS` holds no noun, so the subject run is empty. `BABA IS YOU AND` is just `BABA IS YOU`: a trailing `AND` continues the run onto a tile with nothing that fits, and the run keeps what it had. These are the cases a grammar written as three lines doesn't mention and a parser has to decide anyway. Write a test for each decision you made, even if the decision was "whatever my loop happened to do".
|
||||
|
||||
</details>
|
||||
|
||||
2. Without running anything: if you slide every object on a board three tiles right and two down (on a level big enough to hold it), do the rules change? If you swap every object's x and y? If you mirror the board left to right?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Sliding: no. Nothing in the grammar mentions an absolute position. Swapping x and y: no, and this one is less obvious. Rows become columns and columns rows, but rows read left to right and columns top to bottom, so every sentence still reads in its forward direction. Mirroring: yes. `BABA IS YOU` becomes `YOU IS BABA`, which spells nothing.
|
||||
|
||||
Each "no" is a test you can run on boards nobody wrote. Scatter random words on a random board, compute `rules()`, apply the change, compute again, compare:
|
||||
|
||||
```text
|
||||
rules(board) == rules(slide(board, dx, dy))
|
||||
rules(board) == rules(swapXY(board))
|
||||
```
|
||||
|
||||
This is **metamorphic testing**: you don't know the right answer for a random board, but you know how the answers for two related boards must relate. A metamorphic relation is a necessary property "of the intended functionality" that "must involve multiple executions of the software" ([Metamorphic testing, Wikipedia](https://en.wikipedia.org/wiki/Metamorphic_testing)). The swap relation is a sharp one for this module, because a parser that handles rows and columns in two separate pieces of code can easily be right in one and wrong in the other, and no hand-written test compares them. Module 1's seeded generator is all you need. [Module 11](../11-break-your-engine/lesson.md) collects the relations that hold across the course.
|
||||
|
||||
</details>
|
||||
|
||||
3. Three tiles in a row hold two stacked nouns, one `IS`, and three stacked properties. How many rules? Now *k* words on each of the three tiles of a sentence with one `AND`-free subject and complement. Why did Teikari call stacking "one of the larger undertakings"?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Six, then *k* × *k* (and *k* stacked `IS` words would each spell all of them again, as separate rules with different text ids). Add `AND` and the count multiplies along the run. The parser's work grows with the number of rules it emits, and stacked text makes that number a product, not a sum.
|
||||
|
||||
For the course's parser this costs nothing to *write*: Hint 2's "stacking is free" is true because a tile was a list from the start. That is the real lesson of the quote. Teikari was reworking a rule system in which a tile held one word, and "the need to go through all the possible permutations" arrived late. The decision that a tile holds a list is cheap on day one and expensive afterwards, like most decisions about the shape of data.
|
||||
|
||||
</details>
|
||||
|
||||
4. The reference reparses on every change to the board. The real game reparses only "when any parsable object changes position or is destroyed, or an object is transformed to text" ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). Add that optimisation to your engine. How do you convince yourself it never changes a result?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
The optimisation is a flag: set it when a text object is placed, moved, removed or created; reparse only if it is set. The argument that it is safe is that `parse` reads text objects and nothing else, so a change that touches no text object can't change its output.
|
||||
|
||||
An argument is not a test. The test is to keep both: build one level that always reparses and one that uses the flag, feed them the same random sequence of changes, and compare `rules()` after every change. Two implementations of one specification checking each other is **differential testing**, and it is the cheapest way to make an optimisation safe: the slow, obviously correct version is the oracle for the fast one. (It is also why this course ships its reference in two languages.)
|
||||
|
||||
Then notice what the flag costs. "Reparse on every change" can't be wrong. "Reparse when text changed" is right only as long as every way text can change remembers to set the flag, and Module 9 adds conversion and Module 10 adds text written by a Deathrattle. The reference never took the optimisation, for that reason.
|
||||
|
||||
</details>
|
||||
|
||||
5. Baba Is You compiles its rules from the board. [PuzzleScript](https://www.puzzlescript.net/), a small language for making puzzle games, has the author write the rules down as patterns: "The engine looks for the pattern on the left, and replaces it with the one on the right." ([Rules 101](https://www.puzzlescript.net/Documentation/rules101.html)). Pushing a crate is the rule `[ > Player | Crate ] -> [ > Player | > Crate ]`. What does each design make easy, and what does each make impossible?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
In PuzzleScript the rules are fixed for the whole game and the board is only data; in Baba Is You the board *is* the rule source. PuzzleScript can express a rule in one line that this course needs a module for (Module 8 is that crate rule), and its author can invent mechanics without touching an engine. It can't let the player change a rule, because rules aren't objects in the level.
|
||||
|
||||
The two share more than they differ on. Both separate the rule table from the code that applies it. Both have to say in what order rules apply: PuzzleScript's documentation is blunt about it, "order does matter - these aren't abstract replacement rules floating around in a vacuum" ([Execution Order](https://www.puzzlescript.net/Documentation/executionorder.html)), which is Module 9 in one sentence. Reading its docs after this module is the fastest way to see which of your design choices were about Baba Is You and which were about rule engines in general.
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- The Baba Is You Wiki's [Rule](https://babaiswiki.fandom.com/wiki/Rule) and [AND](https://babaiswiki.fandom.com/wiki/AND) pages: reading directions, stacking, base rules, when the game reparses, and AND's edge cases. Read the AND table against your parser. Then [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) and [PUSH](https://babaiswiki.fandom.com/wiki/PUSH) for `TEXT IS PUSH` as a base rule.
|
||||
|
||||
@@ -217,7 +217,7 @@ When your tests pass, compare with the reference engine in `course/07-rule-resol
|
||||
Decisions in the reference that the tests don't force:
|
||||
|
||||
- **`BABA IS NOT BABA` in `activeRules()` is the deletion record.** The tests do force negated rules to stay in `activeRules()` (see the harness above: a negated rule is in force and keeps overriding). They don't check that `BABA IS NOT BABA` is listed, even though the reference lists it as its record that every Baba is to vanish. The sources say Baba vanishes; they don't say how an engine should record it.
|
||||
- **`X IS NOT Y` with `Y` a noun is just a negation.** It cancels `X IS Y` and nothing else, as the NOT page says of `ROCK IS NOT GRASS`.
|
||||
- **`X IS NOT Y` with `Y` a noun is just a negation.** In the resolution pass it cancels `X IS Y` and nothing else, as the NOT page says of `ROCK IS NOT GRASS`. Like a negated property, it also has to be settled per object once two nouns can name the same thing: Module 9's conversion does that.
|
||||
- **Negation is settled twice.** Once per rule in the resolution pass (`activeRules()` drops `BABA IS YOU`), and once per object in `hasProperty`. Only the second matters when the subjects differ, which the Expansion Drop shows.
|
||||
- **Which objects a noun names is a fact about the word.** The reference's word list has an optional field, `"selects"`: `"named"` (the default: the noun's own things), `"text"` (every text object) or `"things"` (every object that isn't text). The engine registers `TEXT` with `"text"`. `Lexicon::names` / `Lexicon.names` reads the field.
|
||||
- **One `NOT` only.** `IS NOT NOT PUSH` spells nothing (see the box below).
|
||||
@@ -311,10 +311,73 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
|
||||
|
||||
</details>
|
||||
|
||||
## Stress it
|
||||
|
||||
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
|
||||
|
||||
1. Start from an empty board and place three sentences, one at a time: `BABA IS ROCK`, then `BABA IS BABA`, then `BABA IS NOT BABA`. After each, is `BABA IS ROCK` in `activeRules()`? What does that tell you about the function from "text on the board" to "rules in force"?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Yes, no, yes. Adding text removed a rule, and adding more text brought it back.
|
||||
|
||||
In logic, a system where more premises can only give more conclusions is called *monotonic*. This one isn't: `NOT` and the identity lock are both ways for a sentence to take something away. That has a practical consequence for an engine. In a monotonic system you could update the rule set incrementally, adding what each new sentence implies. Here a new sentence can invalidate any earlier conclusion, so the safe design is the one the module builds: throw the conclusions away and derive them again from all the premises.
|
||||
|
||||
It also has a consequence for players, and it is the reason the game is a puzzle game at all. Every "aha" in Baba Is You is a non-monotonic step: you make something *stop* being true by adding a word.
|
||||
|
||||
</details>
|
||||
|
||||
2. The resolver runs negation first and identity second, once each. Why is one pass of each enough? Could a rule removed in the second step have been needed in the first? Invent a kind of rule that would make two passes not enough.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Negation only reads what the parser wrote: a negated rule is never removed by anything, so the set of negations is known before the pass starts, and the first step can't be affected by the second. Identity reads the survivors of the first step. Information flows one way, parse → negation → identity, so each step runs once.
|
||||
|
||||
Database people call this **stratification**. Datalog, the classic rule language, allows negation only in this layered form: "Stratified negation can be added to Datalog while retaining its model-theoretic and fixed-point semantics." ([Datalog, Wikipedia](https://en.wikipedia.org/wiki/Datalog)). A stratum may negate facts from lower strata, never from its own or a higher one.
|
||||
|
||||
To break it you need a rule whose *applicability* depends on the resolved rule set, so that resolving can change what needs resolving. The real game has several: conditions such as `ON` and `NEAR` depend on the board, and words that make an object count as text feed the parser from the rules. With those, "is this rule in force?" can depend on itself, and a design has to choose between iterating to a fixed point, detecting the loop, or forbidding the construction. Jonathan Geller's [*Baba is You is Undecidable*](https://arxiv.org/abs/2205.00127) (2022) shows how far the full rule set goes: "We establish the undecidability of 2019 puzzle game Baba is You through a reduction from the Post correspondence problem." The course's grammar is deliberately small enough to stratify.
|
||||
|
||||
</details>
|
||||
|
||||
3. Two facts every correct `resolve` satisfies, whatever the rules: running it on its own output changes nothing, and shuffling its input only shuffles its output. Test both on a few hundred random rule lists. Then find the mistake in this plausible resolver: "walk the list once; when you meet `X IS NOT Y`, delete any `X IS Y` you have already seen".
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
The first property is **idempotence**: `resolve(resolve(R)) = resolve(R)`. It holds because the negations and the surviving locks are all still present in the output, so a second run finds nothing new to remove. The second is **order-independence**, and it is Predict question 1 stated for every input at once.
|
||||
|
||||
The one-walk resolver fails order-independence: a negation deletes what came before it and misses what comes after. On the list `[BABA IS NOT YOU, BABA IS YOU]` it keeps `BABA IS YOU`. A hand-written test finds this only if its author thought to put the negation first; a shuffle finds it in a handful of tries. The generator needs no board: make rules from three nouns and three properties, with a random sign.
|
||||
|
||||
Idempotence is worth the extra line because of where resolvers go wrong as they grow. The day a third step is added that can remove a lock, a rule that the lock had removed should come back, and a single pass in the old order won't bring it. The property fails that day.
|
||||
|
||||
</details>
|
||||
|
||||
4. Do the Expansion Drop first. Then: `ALL IS YOU`, `BABA IS BABA`, and `ALL IS ROCK` are on the board, with one Baba and one key. Which rules does `activeRules()` list? Is Baba going to become a rock? Is the key?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
All three rules are listed. `BABA IS BABA` locks the subject `BABA`, and `ALL IS ROCK` has a different subject, so the resolution pass leaves it alone. And yet Baba must not become a rock, because `BABA IS BABA` "prevents BABA from transforming into any object". The key, named by `ALL` and by no lock, does convert.
|
||||
|
||||
So `activeRules()` can't be the whole answer to "what happens to this object". It is a list of sentences; the truth about an object needs every sentence whose subject *names* that object. This is the per-rule and per-object split the drop's discussion describes for properties, and it applies to locks and to negated conversions (`ALL IS ROCK` with `BABA IS NOT ROCK`) in the same way. `hasProperty` already asks the question per object. Module 9's conversion has to as well, and its `test_09_negated_conversion_beats_group` is there because the first version of the course's reference forgot.
|
||||
|
||||
The general point: once two names can refer to one thing, every rule about a *name* has to be re-read as a rule about the *things it names* at the moment of use. Group nouns are the cheapest feature in this module to add and the one with the longest reach.
|
||||
|
||||
</details>
|
||||
|
||||
5. `TEXT IS NOT PUSH` is written on the board. Can the player ever undo it?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Not by pushing: the three words that spell it are text, and no text is pushable while it stands. The rule protects itself. Another rule could still move text some other way, which is what the wiki's rulebook alludes to ("Text can be stacked if TEXT IS NOT PUSH can be formed with another way to move text"), but the course has no other way.
|
||||
|
||||
A level that lets the player form this sentence with nothing else in reach is unwinnable from that point, and nothing tells the player so. That is a property of the *level*, not the engine, and you can check it mechanically: [Module 11](../11-break-your-engine/lesson.md) builds a search that finds every position a level can reach, including the dead ones.
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- The Baba Is You Wiki's [NOT](https://babaiswiki.fandom.com/wiki/NOT) and [IS](https://babaiswiki.fandom.com/wiki/IS) pages: every case in this module, and the ones the course leaves out (stacked `NOT`, `NOT` before nouns). Then [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) for `TEXT IS PUSH` and `ALL`.
|
||||
- John Harris, [*Designing Baba Is You's delightfully innovative rule-writing system*](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system), Game Developer, 8 May 2019: Teikari on the final pass over the rule set, the pass you just built.
|
||||
- Wizards of the Coast, [Magic: The Gathering Comprehensive Rules](https://magic.wizards.com/en/rules), rule 613 (613.1, 613.7, 613.8): interaction of continuous effects, in layers, by timestamp and by dependency. A resolution pass for a game with thousands of cards.
|
||||
- Argentum, [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md), §2.3 "Rule 613: Base State vs. Projected State": "the engine doesn't need to 'undo' +3/+3. It simply removes the floating effect." The same idea as recompiling the rules from the board.
|
||||
- [Datalog, Wikipedia](https://en.wikipedia.org/wiki/Datalog), the sections on negation and on evaluation: the same problem as this module (derive everything that follows from a set of facts and rules, with negation), studied for decades. Read it for the words *stratified* and *fixed point*.
|
||||
- baba-is-auto, `Game::ParseRules` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (around line 611) and `RuleManager::HasProperty` in [`Sources/baba-is-auto/Rules/RuleManager.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Rules/RuleManager.cpp): one place that answers "does this object have this property?". It is an example of rules as data, **not an authority** on the game: it has no `IS NOT` for properties or nouns and hard-codes text as pushable ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md)), so it can't run this module's tests.
|
||||
@@ -62,7 +62,7 @@ Write your answers down before you open the discussion, for example in `notes/08
|
||||
|
||||
6. The course's answer: the edge of the level blocks like STOP, so the rock doesn't move and neither does Baba. The sources don't say this directly; see "How the real game differs".
|
||||
|
||||
7. The sources read for this course don't cover it. If your engine moves the Babas one at a time and the left one goes first, it pushes the right one, and then the right one takes its own step: it moved two tiles. The reference moves the YOU object furthest ahead first, so each moves once. That is a choice; make yours deliberately.
|
||||
7. The sources read for this course don't cover it. If your engine moves the Babas one at a time and the left one goes first, it pushes the right one, and then the right one takes its own step: it moved two tiles. The reference moves the YOU object furthest ahead first, and it also keeps a rule that doesn't depend on the order: an object steps at most once per move. That is a choice; make yours deliberately. (The order alone is not enough. The Expansion Drop shows a case where it fails.)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -99,6 +99,7 @@ The full statements are in `course/08-push-chains/scenarios.md`.
|
||||
- `test_08_open_shut_during_push`: Predict question 3. Key and door are destroyed and Baba is on the key's former tile, the course's inference.
|
||||
- `test_08_text_pushed`: Predict question 5. Text is pushed by the base rule, and `rules()` after the move includes `ROCK IS PUSH`.
|
||||
- `test_08_level_edge_is_stop`: Predict question 6. A word on the edge can't be pushed off, and Baba can't step off either.
|
||||
- `test_08_stacked_you_moves_together`: Predict question 7, with a third Baba stacked on the second. Every Baba moves exactly one tile, and the stack stays a stack.
|
||||
|
||||
The third test:
|
||||
|
||||
@@ -183,13 +184,13 @@ Run `cargo test` or `npm test`. The Module 8 tests fail to compile (Rust) or fai
|
||||
|
||||
## Build
|
||||
|
||||
Make the five tests pass, and keep Modules 1–7 green. Where code goes:
|
||||
Make the six tests pass, and keep Modules 1–7 green. Where code goes:
|
||||
|
||||
- **Plan, then commit.** A move has two phases. The first reads the board and builds a plan: which objects step one tile, which are destroyed. It changes nothing. The second applies the plan. If the plan fails, there is nothing to undo.
|
||||
- **A failed branch leaves no trace.** While planning, an attempt can fail halfway: question 4's key finds its unlock, and then the wall blocks it. In the course's reading, whatever that attempt added to the plan (the destruction of key and door) goes too. Decide how your planner forgets.
|
||||
- **The edge blocks.** A step that would leave the level fails, like a step into STOP.
|
||||
- **Rules from before the move.** Every question asked while planning ("is this rock PUSH?") uses the rules in force when the move began. Reparse once, after all movement.
|
||||
- **Several YOU objects.** Plan and commit them one at a time. Pick an order, and make sure a YOU object that is also PUSH doesn't move twice.
|
||||
- **Several YOU objects.** Plan and commit them one at a time. Pick an order, and make sure no object steps twice in one move: not a YOU object that is also PUSH, and not anything else a later mover could carry along.
|
||||
- **One more question.** PUSH, STOP and OPEN/SHUT each say what happens when something moves. Is each of them a branch in your planner, or something the planner runs? The Expansion Drop will bring a new movement property, and you won't be allowed to touch `src/engine/`.
|
||||
|
||||
<details><summary>Hint 1 — nudge</summary>
|
||||
@@ -213,13 +214,17 @@ Write a function that answers one question: "can this object step one tile in th
|
||||
```text
|
||||
move(dir):
|
||||
you = objects with YOU, furthest ahead in dir first
|
||||
for each id in you that still exists:
|
||||
stepped = {} // objects that have stepped in this move
|
||||
for each id in you that still exists and is not in stepped:
|
||||
plan = { moving: {}, destroyed: {} }
|
||||
if try_move(plan, id, dir): commit(plan, dir)
|
||||
if try_move(plan, id, dir):
|
||||
commit(plan, dir)
|
||||
stepped += plan.moving - plan.destroyed
|
||||
reparse()
|
||||
|
||||
try_move(plan, id, dir): // reads the board, never changes it
|
||||
if id in plan.moving or id in plan.destroyed: return true
|
||||
if id in stepped: return true // it has had its step; it is not moved again
|
||||
to = position(id) + dir
|
||||
if to is outside the level: return false
|
||||
saved = copy of plan
|
||||
@@ -250,6 +255,7 @@ Decisions in the reference that the tests don't force:
|
||||
|
||||
- **The responses are content.** The engine owns the planner, the edge, the order of the YOU objects and the commit. What `OPEN`/`SHUT`, `PUSH` and `STOP` do is three functions in `src/content/`, which the engine runs for every step in the order content lists them. `YOU` stays in the engine: it picks who moves when the player presses a key.
|
||||
- **Several YOU objects move furthest-ahead first**, oldest first on a tie, each planned and committed before the next is planned. All of them use the rules from before the first.
|
||||
- **An object steps at most once per move.** The level keeps the set of objects that have stepped in a committed plan. A `YOU` object in the set doesn't start a move of its own, and the planner answers "yes, and nothing to add" when a later mover asks one of them to step: it isn't moved again and it doesn't hold the mover back. So two stacked `YOU AND PUSH` objects end the move stacked, one tile on. The sources read don't cover any of this; it is the course's rule.
|
||||
- **One partner per OPEN/SHUT step: the oldest** SHUT (or OPEN) object on the tile. The [OPEN](https://babaiswiki.fandom.com/wiki/OPEN) page: "Only one SHUT object is destroyed per OPEN object (and vice versa), based on the first object in update order." The reference's update order is object age.
|
||||
- **A destroyed object has no properties.** `hasProperty` on it is `false`, and `posOf` is `null`.
|
||||
|
||||
@@ -273,6 +279,9 @@ pub fn try_move(&mut self, id: ObjectId) -> bool {
|
||||
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
if self.stepped.contains(&id) {
|
||||
return true; // it has stepped in this move already: not again, and not in the way
|
||||
}
|
||||
let level = self.level;
|
||||
let o = level.board().get(id);
|
||||
let (dx, dy) = self.dir.delta();
|
||||
@@ -300,6 +309,7 @@ pub fn try_move(&mut self, id: ObjectId) -> bool {
|
||||
```ts
|
||||
tryMove(id: ObjectId): boolean {
|
||||
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
|
||||
if (this.stepped.has(id)) return true; // it has stepped in this move already: not again, and not in the way
|
||||
const o = this.level.board.get(id);
|
||||
const [dx, dy] = delta(this.dir);
|
||||
const to: Pos = [o.x + dx, o.y + dy];
|
||||
@@ -335,7 +345,7 @@ That is new behaviour: PUSH looks at the tile ahead of the mover, PULL at the ti
|
||||
node tools/expansion-check.mjs ~/my-engine HEAD
|
||||
```
|
||||
|
||||
The test, `test_08x_pull_follows`, puts two PULL rocks behind Baba. Baba moves away and both rocks follow, one tile each. Then Baba turns back: the rock is solid, so nothing moves. The full scenario is in `course/08-push-chains/expansion/README.md`.
|
||||
The first test, `test_08x_pull_follows`, puts two PULL rocks behind Baba. Baba moves away and both rocks follow, one tile each. Then Baba turns back: the rock is solid, so nothing moves. The second, `test_08x_pulled_you_moves_once`, puts a key that is `YOU` and `PULL` behind Baba: the key is pulled one tile and must not then take a step of its own. The full scenarios are in `course/08-push-chains/expansion/README.md`.
|
||||
|
||||
<details><summary>Discussion: what this drop measures</summary>
|
||||
|
||||
@@ -377,6 +387,7 @@ Shapes that fail this drop:
|
||||
- **Properties as branches in the planner.** If "can this step?" says `if PUSH ... else if STOP ...`, PULL is a third branch, under `src/engine/`. Make the reactions something the planner runs, as a normal commit, then redo the drop.
|
||||
- **A planner that only lets a reaction see the tile ahead.** If your responses are handed "the objects on the target tile" and nothing else, PULL can't see the object behind the mover. Give a response the step (where from, where to, which way) and a way to ask about any tile.
|
||||
- **Moving while you walk.** If your engine moves objects as it finds them, a pull that fails halfway leaves objects moved. With a plan, a pull that can't happen is simply never in the plan.
|
||||
- **"Furthest ahead first" as the only guard against double moves.** That order stops a pusher from moving something that hasn't had its own turn yet, because whatever you push is ahead of you. A pulled object is *behind* its mover: the leader goes first, drags the key one tile, and then the key's own turn comes. `test_08x_pulled_you_moves_once` fails with the key two tiles on. The first version of the course's reference had this bug, and no test saw it, because the only guard was an ordering that happened to work for PUSH. The fix is a rule that doesn't mention any property (an object steps once per move), and it is an engine change: a normal commit, then redo the drop.
|
||||
|
||||
The reference lets a PULL object that can't follow stay behind without holding the mover back. The PULL page doesn't say, and the test doesn't check it.
|
||||
|
||||
@@ -388,9 +399,58 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
|
||||
|
||||
</details>
|
||||
|
||||
## Stress it
|
||||
|
||||
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
|
||||
|
||||
1. `try_move` copies the whole plan before every attempt so that it can restore it. For a chain of *n* rocks, how much copying is that? Replace the copy with something cheaper that restores exactly as well.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Each of the *n* nested attempts copies a plan that already holds up to *n* ids, so a straight push costs on the order of *n*² set insertions. On a level this size nobody will notice. The shape is still worth knowing, because the same choice appears in every system that has to undo.
|
||||
|
||||
The cheaper version is a **journal**. Keep the plan as an append-only list of entries ("moving: 7", "destroyed: 12"). A savepoint is just the list's current length; restoring is truncating the list back to that length. Nothing is copied on the way in, and only a failed branch pays, in proportion to what it added. Databases call the two designs shadow copies and undo logs.
|
||||
|
||||
Before you switch, write the test that would catch a wrong restore: Predict question 4's board (the key finds its unlock, then a wall blocks it), with an assertion that the door still exists. The lesson says no Module 8 test sets this board up. A change to the restore logic is the moment that gap matters.
|
||||
|
||||
</details>
|
||||
|
||||
2. "An object already in the plan answers yes at once ... That also stops any cycle." On this module's boards, can a push chain ever come back to an object it has already visited? If not, what is that line actually for? Describe a level geometry where it would be load-bearing.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
It can't. Every recursive call looks one tile further in the same direction, on a board with edges, so a chain is a straight line that ends at the edge. PULL looks one tile *back*, at an object the mover is leaving, and that object's own step looks further back still. No path returns.
|
||||
|
||||
So on these boards the check is not cycle protection. Its job is deduplication: with PULL in content, the same object can be reached twice in one plan (a rock behind the mover is pulled, and is also on the tile some other object in the plan is leaving), and it must be planned once.
|
||||
|
||||
Make the level wrap around (step off the right edge, arrive at the left) and the line becomes load-bearing: a full row of PUSH rocks pushes itself, and without the check `try_move` recurses forever. With it, the row rotates one tile, which is a sensible answer. A guard whose purpose you can't state is a guard you will delete one day; write the purpose next to it.
|
||||
|
||||
</details>
|
||||
|
||||
3. Your planner decides one move in time proportional to the chain. How hard is it to decide whether a whole level can be *solved*? Guess before you look.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Very. Sokoban, which is this module with one property and no text, is the standard example: "deciding whether any given Sokoban puzzle is solvable is a problem known to be NP-hard and PSPACE-complete" ([Sokoban, Wikipedia](https://en.wikipedia.org/wiki/Sokoban), citing Dor and Zwick 1999 and Culberson 1997). Pushes are easy to do and hard to take back, so positions that look alike differ in what is still reachable, and a solver can't avoid exploring them.
|
||||
|
||||
That is not a reason to give up on checking levels. Complexity results are about how the cost grows with the size of the puzzle, and a designer's levels are small. A brute-force search over a 9 × 7 level finishes in seconds, and it will tell you two things no amount of play-testing can: that a level has *no* solution other than the one you intended, or that it has one you didn't. [Module 11](../11-break-your-engine/lesson.md) builds that search.
|
||||
|
||||
</details>
|
||||
|
||||
4. Press right, then left. Is the board back where it started? What does your answer mean for an Undo button?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Usually not. Baba pushes a rock right; stepping left walks away from it. Moves are not their own inverses, because pushing loses information: after the push you can't tell from the board whether the rock was pushed there or always stood there. A destroyed key and door are gone entirely.
|
||||
|
||||
So undo can't be "apply the opposite move". It has to be one of two things: remember enough to put the old state back (a snapshot of the board, or the plan that was committed plus what it destroyed), or replay the level from the start without the last input. Both work here, and both are much easier than they would be in Part I, for a reason this part of the course set up: the rules are *derived* from the board. Restore the objects, reparse, and every rule is right again, with no rule history to keep. [Module 9](../09-turn-pipeline/lesson.md)'s Stress it asks what, exactly, "the objects" has to include.
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- The Baba Is You Wiki's [PUSH](https://babaiswiki.fandom.com/wiki/PUSH), [STOP](https://babaiswiki.fandom.com/wiki/STOP), [OPEN](https://babaiswiki.fandom.com/wiki/OPEN) and [SHUT](https://babaiswiki.fandom.com/wiki/SHUT) pages: every rule in this module and the ones it leaves out (SAFE, HAS, float levels). Then [PULL](https://babaiswiki.fandom.com/wiki/PULL) for the drop.
|
||||
- The [Esolangs wiki's Baba Is You page](https://esolangs.org/wiki/Baba_Is_You): the game read as a cellular automaton, with a table of words. Its PUSH, STOP and OPEN/SHUT entries are the shortest statement of this module.
|
||||
- The Baba Is You Wiki's [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations): the movement checks in order, and where movement sits in the turn you build in Module 9.
|
||||
- [Sokoban, Wikipedia](https://en.wikipedia.org/wiki/Sokoban), the Research section: what is known about how hard push puzzles are, with references. Useful background before you write a solver.
|
||||
- baba-is-auto, `Game::CanMove`, `Game::ProcessMove` and `Game::ProcessPush` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (from line 1756): validate, then commit, in C++. It is an example, **not an authority** on the game: it hard-codes text as pushable and its float handling is partial ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Documents/rule-language.md)).
|
||||
@@ -6,7 +6,7 @@ Part I had this too. In Module 3, the end of the outermost Phase ran a fixed seq
|
||||
|
||||
This is the last Baba Is You module. Module 10 joins the two halves of the course.
|
||||
|
||||
You need your Module 8 workspace with all Module 1–8 tests passing, under git.
|
||||
You need your Module 8 workspace with all Module 1–8 tests passing, under git. This module's tests also use the meta-noun `ALL` from the [Module 7 Expansion Drop](../07-rule-resolution/lesson.md#expansion-drop). If you skipped that drop, do it first; it is one line of data.
|
||||
|
||||
## Observe
|
||||
|
||||
@@ -61,6 +61,9 @@ Write your answers down before you open the discussion, for example in `notes/09
|
||||
6. Baba pushes a rock into water, and `WATER IS SINK`. What is left?
|
||||
7. `FLAG IS WIN AND DEFEAT`. Baba steps onto the flag. Won, lost, or neither?
|
||||
8. In your engine, where does the order of the turn live? If you had to move hazards before conversion, how many places would you edit?
|
||||
9. `TEXT IS ROCK` is written on the board. What is left after one turn? Which rules?
|
||||
10. `ALL IS ROCK` and `BABA IS NOT ROCK`, with a Baba and a key on the board. After a turn, what is each of them?
|
||||
11. `SKULL IS DEFEAT AND MELT`, `LAVA IS HOT`. A skull lies on a lava tile and Baba steps onto it. Does Baba survive?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
@@ -80,6 +83,12 @@ Write your answers down before you open the discussion, for example in `notes/09
|
||||
|
||||
8. If the order is spread across functions that call each other (move, which reparses and then calls convert, which calls hazards), moving a phase means untangling those calls. If it is a list, it is one edit to the list. Question 1 shows why that matters: the game's order is a long list (see "How the real game differs"), and the course builds a short version of it.
|
||||
|
||||
9. Rocks where the words were, and one rule: `TEXT IS PUSH`. `TEXT` names every text object, and the three words spelling `TEXT IS ROCK` are text. The sentence converts itself, along with every other sentence on the board. The parse after conversion finds no text at all, so every property is gone before the hazards run, and nothing is `YOU`: the level is lost. A rule that destroys the text that states it is the shortest demonstration that rules here are board state and nothing more.
|
||||
|
||||
10. The key is a rock; Baba is still Baba. `ALL` names both of them, and `BABA IS NOT ROCK` names Baba. Module 7's resolution pass leaves `ALL IS ROCK` in force, because its subject is `ALL`, not `BABA`. So conversion has to do for each object what `hasProperty` does: gather every rule whose subject names *this object*, and let a negation among them win.
|
||||
|
||||
11. Yes. The hazards run in a fixed order and each one's victims are removed before the next runs. HOT/MELT comes before DEFEAT, so the skull has melted by the time DEFEAT looks for something on Baba's tile. Swap those two and Baba dies. Inferred from the order of `block()`; no source shows this board.
|
||||
|
||||
</details>
|
||||
|
||||
## Spec
|
||||
@@ -106,7 +115,7 @@ Three new functions on `Baba`:
|
||||
|
||||
What the phases do:
|
||||
|
||||
- **Conversion.** An object named by `X IS Y`, where `Y` is a noun, becomes a `Y` where it stands, with the same id. `X IS X` blocks it. An object named by `X IS NOT X` is deleted. Every object is decided from the board as movement left it.
|
||||
- **Conversion.** An object named by `X IS Y`, where `Y` is a noun, becomes a `Y` where it stands, with the same id. `X IS X` blocks it, and so does an `… IS NOT Y` that names the object. An object named by `X IS NOT X` is deleted. Every object is decided from the board as movement left it.
|
||||
- **Hazards**, in this order, each one against the objects sharing a tile on the same float level, and each one destroying everything it finds at once, before the next runs:
|
||||
- SINK destroys every object it shares a tile with, and itself.
|
||||
- HOT destroys MELT objects, and an object that is both destroys itself.
|
||||
@@ -127,6 +136,14 @@ The full statements are in `course/09-turn-pipeline/scenarios.md`.
|
||||
- `test_09_sink_destroys_both`: Predict question 6.
|
||||
- `test_09_you_and_win`: `BABA IS YOU` and `BABA IS WIN`. A turn wins the level; placing the text doesn't.
|
||||
- `test_09_defeat_beats_win`: Predict question 7.
|
||||
- `test_09_negated_conversion_beats_group`: Predict question 10.
|
||||
- `test_09_conversion_reparses_before_hazards`: Predict question 9, with a Baba that is `MELT` standing on lava. It survives, because the parse after conversion has removed every rule.
|
||||
- `test_09_conversion_before_hazards`: a key that is `MELT` and lies on lava becomes a rock in the same turn. The rock survives.
|
||||
- `test_09_last_parse_matches_board`: a hazard destroys a word. `rules()` after the turn no longer has that word's sentence.
|
||||
- `test_09_hazard_order_melt_before_defeat`: Predict question 11.
|
||||
- `test_09_hazard_order_sink_before_melt`: a key that is `HOT` and `MELT` lies on water that is `SINK`. Both are destroyed, because SINK runs first.
|
||||
|
||||
The last five look like a lot of tests for one list. Each exists because the turn makes a separate ordering decision there, and an engine with that one decision changed passed every earlier test in the course. [Module 11](../11-break-your-engine/lesson.md) shows the experiment.
|
||||
|
||||
The first test:
|
||||
|
||||
@@ -194,7 +211,7 @@ Run `cargo test` or `npm test`. The Module 9 tests fail to compile (Rust) or fai
|
||||
|
||||
## Build
|
||||
|
||||
Make the eight tests pass, and keep Modules 1–8 green. Where code goes:
|
||||
Make the fourteen tests pass, and keep Modules 1–8 green. Where code goes:
|
||||
|
||||
- **The turn is a list.** Write the order down once, as a list of phases, and have `step` walk it. Movement doesn't call parsing, and parsing doesn't call conversion. Each phase is called by the turn.
|
||||
- **Parse between phases.** Module 8's `move` reparsed at the end. In the turn, parsing is a phase of its own. It runs after movement and again after conversion, and each phase uses the rules from the parse before it. Hazards and the win check share one set of rules: a hazard that destroys text doesn't change the rules until the turn's last parse.
|
||||
@@ -213,7 +230,7 @@ Write `step` as a loop over a constant list: `[movement, parse, conversion, pars
|
||||
<details><summary>Hint 2 — concept</summary>
|
||||
|
||||
- **Split Module 8's `move`.** Movement without the reparse becomes the first phase, and `move` becomes "movement, then parse", so the Module 8 tests keep passing.
|
||||
- **Conversion is a plan too.** For each object, list the active rules that name it. If one of them is `X IS X`, the object is locked. If one is `X IS NOT X`, it is deleted. Otherwise each `X IS Y` with `Y` a noun is a target. Record the changes, then apply them: remove, or change the kind in place.
|
||||
- **Conversion is a plan too.** For each object, list the active rules that name it. If one of them is `X IS X`, the object is locked. If one is `X IS NOT X`, it is deleted. Otherwise each `X IS Y` with `Y` a noun is a target, unless one of the rules naming this object is `… IS NOT Y`. Record the changes, then apply them: remove, or change the kind in place.
|
||||
- **A hazard is a function from the level to a list of ids.** SINK: every SINK object with company on its tile, plus the company. HOT/MELT: every MELT object that is HOT or has HOT company. DEFEAT: every YOU object that is DEFEAT or has DEFEAT company. The hazards phase runs them in order and removes each list before running the next.
|
||||
- **"Company" means same float level.** One function, `sharing(id)`, returns the other objects on the tile whose FLOAT matches this one's. The hazards and the win check all use it.
|
||||
- **Win** is: some YOU object is WIN, or has WIN company.
|
||||
@@ -234,7 +251,8 @@ conversion():
|
||||
rules = active rules whose subject names o
|
||||
if some rule in rules is X IS X: continue // locked
|
||||
deleted = some rule in rules is X IS NOT X
|
||||
into = distinct noun complements of X IS Y in rules, other than o's own kind
|
||||
into = distinct noun complements of X IS Y in rules, other than o's own kind,
|
||||
less every Y that some negated rule in rules has as its complement
|
||||
if deleted or into: changes += (o, deleted, into)
|
||||
for (o, deleted, into) in changes:
|
||||
if deleted: remove o; place each of into on o's tile
|
||||
@@ -263,6 +281,7 @@ Decisions in the reference that the tests don't force:
|
||||
- **The turn is a constant in the engine.** `TURN` lists the phases and `step` walks it. The order is the engine's because it is the game's rules for a turn, not any one word's.
|
||||
- **The hazards are content.** SINK, HOT/MELT and DEFEAT are three functions in `src/content/`. The engine runs them in the order content lists them, which is the wiki's `block()` order, and removes each one's victims before running the next. This is the same shape as Module 8's movement responses.
|
||||
- **The engine knows `YOU`, `WIN` and `FLOAT`.** `YOU` picks who moves (Module 8). `WIN` is the engine's own end-of-turn check. `FLOAT` is in the engine because it isn't a hazard. It decides which objects share a tile at all, and the win check needs that as much as the hazards do ("a same-float YOU YOU2 3D object", [WIN](https://babaiswiki.fandom.com/wiki/WIN)). If each hazard filtered by FLOAT itself, a new hazard could forget to, and the engine's win check would need the rule anyway. So it lives in one place, `sharing`, and hazards can't get it wrong.
|
||||
- **Negation is settled per object in conversion too.** `ALL IS ROCK` with `BABA IS NOT ROCK` leaves every Baba a Baba. The resolution pass can't do this, because it compares subjects as words; conversion compares what the subjects name. It is the same rule `hasProperty` has followed since Module 7, applied in the second place that needs it.
|
||||
- **Several conversions.** "if you write A IS B and A IS C simultaneously in the same turn ... then you get both a B and a C for each A" ([Advanced rulebook](https://babaiswiki.fandom.com/wiki/Advanced_rulebook)). The object becomes the first target and keeps its id; each other target is a new object on its tile.
|
||||
- **`X IS NOT X` deletes in the conversion step**, together with the conversions. The deleted object's other targets still appear, as new objects. No source gives the deletion its own place in the turn; see "How the real game differs".
|
||||
- **`won()` stays true.** In the game, winning ends the level. In the course, `step` keeps playing if you call it again, and `won()` stays true.
|
||||
@@ -422,6 +441,64 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
|
||||
|
||||
</details>
|
||||
|
||||
## Stress it
|
||||
|
||||
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
|
||||
|
||||
1. Take your turn's list and change its order, one change at a time, running the whole test suite after each: drop the parse after conversion; run hazards before conversion; run the win check before the hazards; drop the last parse; reverse the list of hazards. For each change, which test fails? Is there any change after which every test still passes?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
With this module's fourteen tests, each of those changes fails at least one test, and the name of the failing test tells you which decision you broke. On the reference:
|
||||
|
||||
| Change to the turn | Fails |
|
||||
|---|---|
|
||||
| No parse between movement and conversion | `test_09_mid_turn_conversion` |
|
||||
| No parse between conversion and hazards | `test_09_conversion_reparses_before_hazards` |
|
||||
| Hazards before conversion | `test_09_conversion_before_hazards`, `test_09_conversion_reparses_before_hazards` |
|
||||
| Win check before hazards | `test_09_defeat_beats_win` |
|
||||
| No last parse | `test_09_last_parse_matches_board` |
|
||||
| DEFEAT before HOT/MELT | `test_09_hazard_order_melt_before_defeat` |
|
||||
| Hazards reversed | both `test_09_hazard_order_…` tests |
|
||||
|
||||
With the eight tests this module first shipped with, five of those rows were empty. The engine could run hazards before conversion, skip two of its parses, or run the hazards backwards, and stay green. Nobody had made those mistakes, so nobody had noticed that nothing would catch them.
|
||||
|
||||
A changed program that the tests don't notice is called a surviving *mutant*, and looking for them on purpose is **mutation testing**: it "involves making small changes to the program being tested", and a test "detects, and therefore rejects, a mutant upon test failure" ([Mutation testing, Wikipedia](https://en.wikipedia.org/wiki/Mutation_testing)). A pipeline is the ideal subject, because its mutants are easy to list: every pair of neighbours swapped, every phase removed. If your order is a list in one place, each mutant is a one-line edit. That is a second payoff of writing the order down as data.
|
||||
|
||||
Finding the scenario that kills a mutant is a design exercise in its own right. For "HOT/MELT before DEFEAT" you need one object that is a victim of the first hazard and a weapon of the second. Try constructing the one for "SINK before HOT/MELT" before you read its test.
|
||||
|
||||
</details>
|
||||
|
||||
2. Is the order of SINK and HOT/MELT *observable* at all? Try to find a board, with objects that have one of those properties each, where swapping the two hazards changes what survives.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
With one property per object, there isn't one. For HOT to melt something, the two must share a tile and a float level. If a SINK object shares that tile and level, it destroys both, whichever hazard runs first; if it doesn't, it affects neither. The victim sets overlap but the survivors are the same.
|
||||
|
||||
The order shows only through an object that *destroys itself*: something HOT and MELT, alone with water. HOT/MELT first removes it and leaves the water with no company; SINK first takes both. That is `test_09_hazard_order_sink_before_melt`, and it needed an object with two properties to exist.
|
||||
|
||||
An order that can't be observed isn't a rule of the game yet, only a habit of the implementation. It becomes a rule the moment something makes it visible, and extensions tend to do that. In Module 10 a hazard's victims can be minions with Deathrattles, and then the order in which two hazards kill is the order in which two Deathrattles resolve, on any board. Decide orders deliberately even when today's content can't tell the difference; tomorrow's will.
|
||||
|
||||
</details>
|
||||
|
||||
3. Add an Undo to your harness: `undo()` puts the level back as it was before the last `step`. Build it by saving something before each step and restoring it. What is the smallest thing you can save? Then test it: play twenty random steps, undo them all, play the same twenty again, and compare every `kindsAt`, `rules()` and `posOf` with the first run.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Not the rules: they are compiled from the board, so restoring the board and reparsing gets them back. That is the payoff Module 8's last question promised, and it is why undo is cheap in Baba Is You and would be expensive in an engine that edited its rule list in place.
|
||||
|
||||
"The board" is more than it looks, though. Try the cheapest snapshot, a list of (kind, x, y) read off with `kindsAt`, and rebuild the level from it. The replay will sooner or later differ from the first run, and the differences show what else is state:
|
||||
|
||||
- **Ids.** A rebuilt level numbers its objects afresh. Anything outside the engine that holds an id (your test, a UI) now points at the wrong object. Module 1's handles are only stable if undo preserves them.
|
||||
- **Age.** The reference breaks ties by object age: the oldest SHUT partner is the one destroyed, and the older of two YOU objects on a line moves first. `kindsAt` returns kinds sorted by name, so a snapshot made from it forgets which object on a tile was older, and two objects of different kinds can swap seniority.
|
||||
- **`won`.** It stays true once set, and it is not on the board.
|
||||
|
||||
So the smallest correct snapshot is every object's id, kind and position, in age order, plus the id counter and `won`. The exercise is finding that list; an undo that is right nineteen times in twenty comes from guessing it.
|
||||
|
||||
The other design is to save nothing but the inputs and replay all but the last. It can't forget a piece of state, because it never copies any. It costs a replay per undo, and it requires the engine to be deterministic, which yours is. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter covers both designs; Martin Fowler's [*Event Sourcing*](https://martinfowler.com/eaaDev/EventSourcing.html) is the second one taken as an architecture: "we can also use the event log to reconstruct past states".
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- The Baba Is You Wiki's [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations): the whole turn. Read it with this module's turn beside it and mark each line the course leaves out.
|
||||
|
||||
@@ -377,6 +377,73 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
|
||||
|
||||
</details>
|
||||
|
||||
## Stress it
|
||||
|
||||
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
|
||||
|
||||
1. In one turn, a Powder Keg melts in lava and a Rune Scribe steps onto a skull that is `DEFEAT` (both minions are `YOU`). Do their Deathrattles resolve in one Death Phase or two? In what order? Now both melt in the same lava. Same questions.
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Two hazards: two Sequences, one per hazard, in the hazards' order. HOT/MELT runs before DEFEAT, so the keg dies, its Deathrattle resolves (and may kill more), the grid is reparsed, and only then does DEFEAT run and the scribe die. One hazard: one Sequence, one Death Creation Step, one Death Phase, and the two Deathrattles resolve in order of play.
|
||||
|
||||
Module 9's Stress it asked whether the order of two hazards could be observed, and found that in the grid engine alone it took an object with two properties. Here any two minions do it. The seam turned an internal order into a rule players can see, without a line of code that mentions the order. When you join two systems, list each one's "it doesn't matter which goes first" decisions and ask again; the join is where they start to matter.
|
||||
|
||||
It also shows what seam 2 chose. "All objects destroyed in the same step are destroyed simultaneously" became "share one Death Creation Step", which is Part I's meaning of simultaneous. Objects destroyed in *different* steps of the same turn got Part I's meaning of sequential. Neither game said what a turn-and-Sequence hybrid should do. Write a test for whichever answer you chose.
|
||||
|
||||
</details>
|
||||
|
||||
2. `DEATHRATTLE IS NOT DOUBLE` is on the grid, and two Test Chorus ("Your minions trigger their Deathrattles an additional time.") are in play. A keg dies. How many activations in the reference? Is that what the sentence says?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
One. The negated rule compiles to a cap of one activation, and the cap is applied last, whatever was multiplying.
|
||||
|
||||
Read literally, though, the board says only that Deathrattles are not *doubled*. Nothing on it says "additional activations don't count", and a player could fairly expect three. The reference's reading is "`NOT` beats everything of this kind"; the literal reading is "`NOT` removes the `DOUBLE` contributions and leaves the rest". Under the second, the negation would compile to something that cancels multipliers whose word is `DOUBLE`, and the cap would not exist.
|
||||
|
||||
Both are coherent, and the difference is the old one from Module 7 in new clothes: is `X IS NOT Y` about the *sentence* `X IS Y`, or about the *fact* Y? In Part II the two coincide, because the only way to have a property is a sentence that grants it. In SpellGrid a card can grant the fact with no sentence, and the design has to pick. The lesson's last discussion says SpellGrid "chose Baba's answer". This question is the case where you can see that it was a choice.
|
||||
|
||||
</details>
|
||||
|
||||
3. A Harvest Golem stands on the grid and dies. Its Deathrattle summons a Damaged Golem, which "enters play but not the grid". Design the fix. Which engine decides the tile? What if the tile is occupied, or the word `GOLEM` doesn't exist?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
Neither engine can decide alone. Part I knows a minion was summoned, and since Module 3 it even knows *where on the board*, as a neighbour. Part II knows tiles. So it is a fourth seam, and it should look like the third: Part I records `Summoned`, and the code in the middle reads the event when the Sequence ends and places an object.
|
||||
|
||||
Then the questions the seam has to answer, none of which either game answers:
|
||||
|
||||
- **Which tile?** The dead minion's tile is the natural reading of "where it stood", and Rune Scribe's `spawnText` already uses it. But the dead minion's object is removed in the same pass, so order the two as seam 3 does for text: place first, remove second.
|
||||
- **Occupied?** Grid objects stack, so nothing stops it. Hearthstone's board has a size limit; a grid tile doesn't.
|
||||
- **What noun?** The token needs a `"noun"` in its card data to be anything on the grid. A card with no noun is a minion no rule can name: it can't be `YOU`, `PUSH` or `MELT`. That may even be a feature.
|
||||
- **Order of play versus age.** The token's object is the newest on the grid and its minion the newest in play. Two different counters, as in Module 1, now in two engines.
|
||||
|
||||
The point of the exercise is the list, not the code. A seam is cheap to write once you know what crosses it and when, and those are design questions.
|
||||
|
||||
</details>
|
||||
|
||||
4. The course ships three levels, each with one intended solution. Are they sound: is each solvable, and only in the intended way? How would you know?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
You search. A level is a small deterministic system with a handful of inputs per position, so a breadth-first search over inputs finds every position it can reach. The course's tool does this with the reference engine:
|
||||
|
||||
```sh
|
||||
node tools/solve-spellgrid.mjs l1
|
||||
node tools/solve-spellgrid.mjs l1 --max 40 --never-rule "DEATHRATTLE IS DOUBLE"
|
||||
node tools/solve-spellgrid.mjs l3 --naive
|
||||
```
|
||||
|
||||
What it reports for the shipped levels:
|
||||
|
||||
- **Level 1** has no solution shorter than 10 inputs, the length of the scripted one. With every position where `DEATHRATTLE IS DOUBLE` is in force thrown away, the search runs out of positions (622 of them, the deepest 17 inputs in) without winning. The level can't be solved without the rule it was built to teach.
|
||||
- **Level 2** has no solution shorter than 4 inputs, and among some 31,000 positions within 4 inputs exactly one wins: the intended graft, played and pushed into the lava.
|
||||
- **Level 3** is solved in 6 inputs, and under `--naive` the search exhausts all 11 reachable positions with no win. "The knight dies" is not only what the one scripted test shows; no other line of play saves the naive engine.
|
||||
|
||||
An unintended solution is called a *cook*, and a level with one doesn't teach what it was built to teach. [Module 11](../11-break-your-engine/lesson.md) explains how the solver works and what it can't tell you. Then design a fourth level, and try to cook it before the tool does.
|
||||
|
||||
</details>
|
||||
|
||||
## Further reading
|
||||
|
||||
- The Hearthstone Wiki's [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook), on Aura Updates and the Death Creation Step: the source of Level 3's answer, and of Module 4's.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Module 11: Break your own engine
|
||||
|
||||
The course is finished at Module 10. This module is about what to do with an engine once its tests pass, which is where most of an engine's life is spent.
|
||||
|
||||
Every module so far gave you tests and asked you to make them green. That teaches design, and it hides a question: how do you know the *tests* are any good? This course is a useful case study, because it got the answer wrong. Its first complete version had reference engines in two languages, every test green, an automated check that both languages ran the same tests, and three bugs in the engines that no test could see. All three were found by the methods in this module, and none by reading the code more carefully.
|
||||
|
||||
There is no Spec or Build here, no reference solution and no Expansion Drop. `start-module` doesn't know this module. Each section is a technique, what it found in this course, and something to try on your own engine. The sections are independent; take them in any order.
|
||||
|
||||
## Observe
|
||||
|
||||
Three bugs, all in the reference engines, all with every test passing.
|
||||
|
||||
**A Deathrattle summon in the wrong place.** The Death Creation Step remembered each dying minion's board index. If a minion further left died in the same step, the index was stale and the token appeared one place too far right ([Module 3](../03-death-step/lesson.md), Predict question 5). The lesson that introduced the bug sits two modules after the lesson that explains why indexes go stale.
|
||||
|
||||
**A negation that lost to a group.** `ALL IS ROCK` with `BABA IS NOT ROCK` turned Baba into a rock. Properties were settled per object; conversions were settled per rule ([Module 9](../09-turn-pipeline/lesson.md), Predict question 10). The lesson text for Module 7 described this exact mistake as a way for *learners* to fail the Expansion Drop.
|
||||
|
||||
**An object that moved twice.** A key that was `YOU` and `PULL`, standing behind Baba, was dragged one tile by Baba's move and then took its own step ([Module 8](../08-push-chains/lesson.md), Expansion Drop). The lesson told learners to "make sure a YOU object that is also PUSH doesn't move twice"; the reference did that with an ordering trick that only worked for PUSH.
|
||||
|
||||
And one that wasn't a bug but could have become one at any time: six different changes to the *order* in which the engines do things (two phases of the turn swapped, a parse dropped, the hazards reversed, the aura snapshot replaced by a live scan) left every test green.
|
||||
|
||||
Notice what the three bugs have in common. None is in a hard algorithm. Each is the meeting of two features that were each tested alone: simultaneous death and summon position; group nouns and conversion; pull and several `YOU` objects. This course is about systems whose parts combine freely, and the price of that is that the number of combinations grows much faster than anyone can write tests by hand.
|
||||
|
||||
## Predict
|
||||
|
||||
Write your answers down before you open the discussion.
|
||||
|
||||
1. Your engine passes about seventy tests. Pick any `if` in it and flip the condition. What do you think the chance is that a test fails?
|
||||
2. You want to test the parser on a board nobody designed, with random words on random tiles. You can't know what the right rules are. What can you check?
|
||||
3. Level 3 of SpellGrid has a scripted solution in the tests. What does that test prove about the level, and what doesn't it prove?
|
||||
4. Can your engine loop forever? Can a level?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
1. Lower than you'd like, and you can measure it. That is mutation testing, below.
|
||||
2. You can check that two *related* boards give related answers (the same board shifted, or with x and y swapped), and you can check facts that hold for every board. Those are metamorphic relations and invariants.
|
||||
3. It proves that the level is solvable and that one particular input sequence wins. It doesn't prove that the solution needs the idea the level was built around, or that there is no shorter way. A search does.
|
||||
4. The engine: yes, with the right two cards (Modules 2 and 3). A level: there is no general way to tell for the real games, which is a theorem, and the last section says what follows from it in practice.
|
||||
|
||||
</details>
|
||||
|
||||
## Mutation testing: test the tests
|
||||
|
||||
Change the program slightly, on purpose, and run the tests. If they still pass, they don't check the thing you changed. The changed program is a *mutant*; a mutant that passes is said to survive. Mutation testing "involves making small changes to the program being tested", and a test "detects, and therefore rejects, a mutant upon test failure" ([Mutation testing, Wikipedia](https://en.wikipedia.org/wiki/Mutation_testing)).
|
||||
|
||||
General-purpose tools generate thousands of mutants by flipping operators. You don't need one to start, because this course's engines have their most important decisions written down as *lists*, and a list has obvious mutants: swap two neighbours, drop an entry. These were run against the TypeScript reference with the tests the course first shipped:
|
||||
|
||||
| Mutant | Tests that failed |
|
||||
|---|---|
|
||||
| End of Phase: Aura Update before the Death Creation Step | `test_04_simultaneous_death_not_doubled`, `test_04_aura_timing_order`, `test_10_level_3_solution` |
|
||||
| Turn: no parse between movement and conversion | `test_09_mid_turn_conversion` |
|
||||
| Turn: win check before hazards | `test_09_defeat_beats_win` |
|
||||
| **Turn: no parse between conversion and hazards** | **none** |
|
||||
| **Turn: hazards before conversion** | **none** |
|
||||
| **Turn: no last parse** | **none** |
|
||||
| **Hazards in reverse order** | **none** |
|
||||
| **Auras read live from the board, no snapshot** | **none in Modules 1–9** |
|
||||
|
||||
Five survivors. The engines were right in each case; the tests would not have noticed if they hadn't been. Each survivor now has a test with its name on it (`test_09_conversion_reparses_before_hazards`, `test_09_conversion_before_hazards`, `test_09_last_parse_matches_board`, the two `test_09_hazard_order_…` tests, `test_05_own_aura_misses_own_battlecry`), and the whole table has no empty row.
|
||||
|
||||
Killing a mutant is a design exercise. You have to find the smallest situation in which the two orders give different results, and sometimes you learn that there isn't one. With one property per object, the order of SINK and HOT/MELT can't be observed at all ([Module 9](../09-turn-pipeline/lesson.md), Stress it question 2). The live-aura mutant survived because three separate mechanisms guard the same behaviour ([Module 4](../04-interceptors/lesson.md), Stress it questions 2 and 3). A surviving mutant means one of three things: a missing test, a decision that isn't observable yet, or redundant machinery. All three are worth knowing.
|
||||
|
||||
**Try it.** List every ordered list in your engine: the turn, the hazards, the movement responses, the end-of-Phase steps, the order in which `react` walks events and listeners, the order `YOU` objects move. For each, swap two neighbours and run everything. Keep a table like the one above. For each survivor, write the test or write down why none can exist.
|
||||
|
||||
## Invariants and random inputs
|
||||
|
||||
A hand-written test checks one path. An **invariant** is a fact that must hold after every operation on every path, and checking it needs no knowledge of what the right answer is. [Module 1](../01-handles/lesson.md)'s Stress it gave three for the world of cards. Some more that should hold in an engine built like the reference:
|
||||
|
||||
- Every minion on a board has more than 0 Health and is not pending destroy, *whenever a Sequence is not running*. (During one, mortally wounded minions are in play. An invariant has a scope, and finding the scope is half the work.)
|
||||
- The log never has a `Triggered` whose source has never been summoned.
|
||||
- `activeRules()` is a subset of `rules()`.
|
||||
- `rules()` equals what a fresh level built from the same objects would compute. This one says "the rule table is a function of the board", which is the thesis of Part II, as one line of test.
|
||||
- After `step`, no object is off the level, and no destroyed id has a position.
|
||||
|
||||
To use them, generate operations at random from a seed you choose, so that a failure can be replayed, and check every invariant after each one. A library is optional. [fast-check](https://fast-check.dev/) for TypeScript and [proptest](https://proptest-rs.github.io/proptest/intro.html) for Rust add one thing a hand-rolled loop lacks and you will want eventually: *shrinking*, the automatic search for a smaller failing input. The idea comes from QuickCheck, a library "designed to assist in software testing by generating test cases for test suites – an approach known as property testing" ([QuickCheck, Wikipedia](https://en.wikipedia.org/wiki/QuickCheck)).
|
||||
|
||||
The summon-position bug is the kind this finds. A generator that summons three minions, damages two at random and casts Whirlwind will produce the failing arrangement within a few dozen tries, provided some property mentions position. This one would do: "a Deathrattle token stands between the same two surviving neighbours as the minion that summoned it".
|
||||
|
||||
**Try it.** Write the fourth invariant above as a test: twenty random `place` and `step` calls on a random level, and after each, compare `rules()` with those of a level rebuilt from scratch. If you added a "reparse only when text changed" flag in [Module 6](../06-parser/lesson.md), this is the test that keeps it safe.
|
||||
|
||||
## Metamorphic relations
|
||||
|
||||
When you can't say what the output should be, say how two outputs must relate. A metamorphic relation is a necessary property "of the intended functionality" that "must involve multiple executions of the software" ([Metamorphic testing, Wikipedia](https://en.wikipedia.org/wiki/Metamorphic_testing)). The course's engines have several, and each one encodes a claim a lesson made in prose:
|
||||
|
||||
| Relation | The claim it tests |
|
||||
|---|---|
|
||||
| `rules(board) == rules(board shifted by (dx, dy))` | The grammar mentions no absolute position (Module 6). |
|
||||
| `rules(board) == rules(board with x and y swapped)` | Rows and columns are parsed by the same rule (Module 6). |
|
||||
| `resolve(shuffle(rules))` is a shuffle of `resolve(rules)` | Where a sentence sits can't matter (Module 7). |
|
||||
| `resolve(resolve(rules)) == resolve(rules)` | The resolution pass finishes the job in one go (Module 7). |
|
||||
| A game replayed from its recorded inputs and choices gives the same log | The chooser is the only way randomness gets in (Module 5). |
|
||||
|
||||
Some relations you might expect are false, and working out why is as useful as a passing test. "Adding a textless minion to the enemy board changes nothing but its own events" fails as soon as a Test Echo is in play, because a trigger's condition can be about any minion. Mirroring a board left to right changes the rules, because sentences have a direction. "Press right, then left" is not the identity ([Module 8](../08-push-chains/lesson.md), Stress it question 4). Dealing 1 damage twice in one Sequence is not the same as in two Sequences, because death is checked between Sequences and not inside one (Module 3). Each false relation marks a place where the system has a direction, a memory or a checkpoint.
|
||||
|
||||
## Two engines, one specification
|
||||
|
||||
This repository has a Rust reference and a TypeScript reference for every module. They pass identical tests. That says they agree on the seventy-odd situations somebody wrote down. It says nothing about any other situation, and two engines written from one specification are the best oracle there is for the rest: generate a random level and a random input sequence, run both, compare what they print. Any difference is a bug in at least one of them or a hole in the specification, and you didn't need to know the right answer to find it.
|
||||
|
||||
This is **differential testing**. It is also how [Module 6](../06-parser/lesson.md) suggests you protect an optimisation: keep the slow version and compare. If you did both tracks of the course, you own two engines; if you did one, the reference in your language is the second. The command-line front ends from Module 10 (`tools/play-spellgrid.mjs`) read inputs from standard input and print the board, the rules, the minions and the hand after each one, so two engines can be compared as text.
|
||||
|
||||
The course has not done this systematically, and the two references are known to differ in at least one place the tests don't reach (how each decides that an Assembled card already exists). Finding the rest is an open exercise, and a good one.
|
||||
|
||||
## State, undo and replay
|
||||
|
||||
[Module 9](../09-turn-pipeline/lesson.md)'s Stress it asks you to build Undo and find out what a snapshot has to contain. The general lesson is in two parts.
|
||||
|
||||
**Derived data is not state.** The rule table, the aura snapshot at the start of a Sequence, a minion's position on the grid as seen by the card engine: all of these are computed from something else. If you can recompute it, don't save it, don't restore it, and above all don't let anything edit it in place. Both parts of the course were built around this: "The previous rules are removed entirely" (Module 6); auras are re-read at an Aura Update (Module 4). Martin Fowler's description of state held as a log of events makes the same cut from the other side: "Event Sourcing ensures that all changes to application state are stored as a sequence of events", and from them "we can also use the event log to reconstruct past states" ([*Event Sourcing*](https://martinfowler.com/eaaDev/EventSourcing.html)).
|
||||
|
||||
**The real state is larger than it looks.** Ids, the id counter, the order-of-play counter, the age order of objects on a tile, `won`, the contents of each deck in order, the chooser's future answers. None of these appear when you draw the board, and every one of them can change what happens next. The honest way to find the list is the round-trip test: snapshot, restore into a fresh engine, play both forward with the same random inputs, and compare.
|
||||
|
||||
An engine that is deterministic gets a second kind of undo for nothing: record the inputs, replay all but the last. It needs no snapshot and can't forget a field. The solver in the next section is built entirely on it. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter is the short introduction to both.
|
||||
|
||||
## Solving your own levels
|
||||
|
||||
A SpellGrid level has a few inputs per position (four directions, plus a card or two), no hidden information once the chooser's answers are enumerated, and no randomness. So the positions reachable from the start form a graph that can be explored exhaustively. The course's solver is about 150 lines, in `tools/solver/solve.ts`:
|
||||
|
||||
```sh
|
||||
node tools/solve-spellgrid.mjs l3 # shortest solution
|
||||
node tools/solve-spellgrid.mjs l3 --naive # the same level on the engine with the wrong aura timing
|
||||
node tools/solve-spellgrid.mjs l1 --max 40 --never-rule "DEATHRATTLE IS DOUBLE"
|
||||
node tools/solve-spellgrid.mjs path/to/your-level.json --all
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
- **Breadth-first over inputs**, so the first solution found is a shortest one.
|
||||
- **Replay, not copy.** Each position is reached by starting a fresh level and applying its inputs. The solver never clones engine state, so it needs nothing from the engine except determinism and the harness you already wrote. It is slow per position and it cannot be wrong about what a position is.
|
||||
- **A key for "the same position".** Two input sequences that lead to the same tiles, minions, Health, hand and hero Health are one position, and only the first is explored. This is the delicate part, for the reason the last section gave: the key has to include everything that can change the future. The solver adds the Assemble choices made so far, because two Assembled cards can share a name and differ in text. It does not include object age, so in a level where age breaks a tie it could merge two positions that differ. The three shipped levels have no such tie. Yours might.
|
||||
- **A filter.** `--never-rule` discards positions in which a given rule is in force. That turns "is the level solvable?" into "is it solvable *without the idea*?", which is the question a designer cares about.
|
||||
|
||||
What it found for the shipped levels is in [Module 10](../10-spellgrid/lesson.md)'s Stress it: each is solvable in the scripted number of inputs, Level 1 has no solution that avoids `DEATHRATTLE IS DOUBLE`, and Level 3 has no solution at all on the naive engine. Those are stronger statements than any test in Module 10 makes, and they hold for every line of play, not for one script.
|
||||
|
||||
An unintended solution is called a *cook*. Puzzle designers have always hunted cooks by hand; a solver hunts them while you sleep. The method generalises: state what the level is *for* as a predicate on positions, forbid it, and search.
|
||||
|
||||
**Try it.** Write a fourth SpellGrid level around one idea from the course (say, a `NOT` that has to be broken before a Deathrattle can double). Run the solver with `--all` and read every solution it prints. Then run it with the idea forbidden.
|
||||
|
||||
## What search can't tell you
|
||||
|
||||
The solver finishes on small levels. It is worth knowing how far that goes.
|
||||
|
||||
- Sokoban, which is Module 8 with one property and no text: "deciding whether any given Sokoban puzzle is solvable is a problem known to be NP-hard and PSPACE-complete" ([Sokoban, Wikipedia](https://en.wikipedia.org/wiki/Sokoban)). The cost of exhaustive search grows explosively with the size of the level.
|
||||
- Baba Is You, with its full rule set: "We establish the undecidability of 2019 puzzle game Baba is You through a reduction from the Post correspondence problem." (Jonathan Geller, [*Baba is You is Undecidable*](https://arxiv.org/abs/2205.00127), 2022). The result is about a version of the game without a fixed level size; no program can decide solvability for all of those.
|
||||
- Magic: The Gathering, the card game Modules 3, 4 and 7 borrowed rules from: "optimal play in real-world Magic is at least as hard as the Halting Problem" (Churchill, Biderman and Herrick, [*Magic: The Gathering is Turing Complete*](https://arxiv.org/abs/1904.09828), 2019).
|
||||
|
||||
These are what you should expect from the systems this course teaches. Rules that are data, that combine freely and that can modify one another are, give or take some bookkeeping, a programming language. The player's "what happens if I do this?" becomes the programmer's "what does this program do?", and that question has no general shortcut.
|
||||
|
||||
Three practical consequences:
|
||||
|
||||
- **Bound everything.** A cap on Death Phase rounds, on trigger nesting, on solver depth. A cap that fails loudly is a design decision; a hang is not. Magic's rule 104.4b turns the unbounded case into an outcome: a game in "a 'loop' of mandatory actions ... with no way to stop ... is a draw" ([Comprehensive Rules](https://magic.wizards.com/en/rules)).
|
||||
- **Search small, argue large.** Exhaustive search for the levels you ship; arguments like Module 3's decreasing quantity for properties of the whole card set.
|
||||
- **Expect surprises and keep them cheap.** You will not foresee every interaction. The engine's job is to make each one a matter of data and one generic routine, so that fixing it doesn't mean rewriting. That is where Module 0 started.
|
||||
|
||||
## Name it
|
||||
|
||||
The pattern is the **oracle problem**, and the techniques in this module are ways round it. A test needs something that knows the right answer. Hand-written expected values are one such oracle, the most precise and the most expensive. The others trade precision for coverage:
|
||||
|
||||
| Oracle | Knows | Module 11 section |
|
||||
|---|---|---|
|
||||
| An expected value written by a person | the exact answer, for one input | (Modules 1–10) |
|
||||
| An invariant | a fact about every answer | Invariants |
|
||||
| A metamorphic relation | how two answers relate | Metamorphic relations |
|
||||
| A second implementation | the same answer, by other means | Two engines |
|
||||
| The tests themselves, against a changed program | whether anyone is checking | Mutation testing |
|
||||
| Exhaustive search | every reachable position | Solving your own levels |
|
||||
|
||||
Systemic games need the cheaper oracles more than most software, for the reason given under Observe: their behaviour is the product of their parts, and hand-written tests grow as the sum.
|
||||
|
||||
## Further reading
|
||||
|
||||
Testing:
|
||||
|
||||
- [Mutation testing](https://en.wikipedia.org/wiki/Mutation_testing) and [Metamorphic testing](https://en.wikipedia.org/wiki/Metamorphic_testing), Wikipedia. Short, with the standard references. Read the second for the phrase "test oracle problem".
|
||||
- [fast-check](https://fast-check.dev/) (TypeScript) and the [proptest book](https://proptest-rs.github.io/proptest/intro.html) (Rust). Property-based testing libraries for the two tracks. Proptest's book has a chapter on state machine testing, which is the "random operations, check invariants" loop with shrinking.
|
||||
- Robert Nystrom, [*Command*](https://gameprogrammingpatterns.com/command.html) (*Game Programming Patterns*), for undo and replay. Martin Fowler, [*Event Sourcing*](https://martinfowler.com/eaaDev/EventSourcing.html), for state as a log of events.
|
||||
|
||||
The wider field of systemic design, which this course approached from the rules-engine side only:
|
||||
|
||||
- Mark Brown, [*The Rise of the Systemic Game*](https://www.youtube.com/watch?v=SnpAAX9CkIc) (Game Maker's Toolkit, video). The designer's definition of the word in the course's title, with examples from games whose systems are physical (fire, AI, stealth) where this course's are textual. Watch it and ask which of the course's patterns each example would need.
|
||||
- Hidemaro Fujibayashi, Satoru Takizawa and Takuhiro Dohta, [*Change and Constant: Breaking Conventions with 'The Legend of Zelda: Breath of the Wild'*](https://www.gdcvault.com/play/1024562/Change-and-Constant-Breaking-Conventions) (GDC 2017). The talk on a game whose objects interact through a small set of shared properties instead of pairwise scripts. Compare that with Module 9's hazards, which ask "what shares this tile and has this property?" and never "is this a skull?".
|
||||
- [PuzzleScript](https://www.puzzlescript.net/) and its documentation, especially [Rules 101](https://www.puzzlescript.net/Documentation/rules101.html) and [Execution Order](https://www.puzzlescript.net/Documentation/executionorder.html). A complete language for games like Part II's, where the designer writes the rules as rewrite patterns. Rebuilding a Module 8 level in it takes an evening and shows which of your engine's decisions were forced.
|
||||
- HearthSim's [python-hearthstone](https://github.com/HearthSim/python-hearthstone) and [python-hslog](https://github.com/HearthSim/python-hslog). The real game's vocabulary of tags and its log format, as reverse-engineered by the community. After building Part I you can read a `Power.log` and recognise most of what is in it.
|
||||
+3
-3
@@ -71,7 +71,7 @@ In TypeScript each event is a plain object with a `type` field, for example `{ t
|
||||
- M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, each distinct rule once, includes `"TEXT IS PUSH"`).
|
||||
- M7: `hasProperty(id, prop: string): boolean`; `activeRules(): string[]` (after NOT/identity post-pass; same format as `rules()`). From M7, `rules()` also lists negated rules (`"BABA IS NOT YOU"`).
|
||||
- M8: `move(dir: "up"|"down"|"left"|"right"): void` (movement only, then reparse); `posOf(id): [number, number] | null` (null once destroyed); `kindsAt(x, y): string[]` (sorted).
|
||||
- M9: `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain).
|
||||
- M9 (requires the M7 Expansion Drop: the meta-noun `ALL` is in the word list): `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain).
|
||||
|
||||
Grid coordinates: `x` grows right, `y` grows down; vertical sentences read top-to-bottom.
|
||||
|
||||
@@ -113,7 +113,7 @@ pub struct LevelObject {
|
||||
|
||||
From M7, `rules()` is what the text spells before resolution, and `activeRules()` is the rule set in force after it: `X IS NOT Y` cancels every `X IS Y` and stays in the set itself; a surviving `X IS X` then cancels every conversion `X IS Y` (`Y` a noun other than `X`); `"TEXT IS PUSH"` is listed unless `TEXT IS NOT PUSH` cancels it. `X IS NOT X` stays in `activeRules()` as the record that every X is to vanish (the reference's choice; no test checks it; the objects are removed by the M9 turn). `hasProperty(id, prop)` is true if an active rule gives `prop` to a noun that names the object and no active negated rule takes it from one: a thing is named by its own noun, a text object by `TEXT` (and, after the M7 Expansion Drop, every thing by `ALL`). `hasProperty` on an id the level never issued panics (Rust) or throws (TypeScript).
|
||||
|
||||
From M8, `move(dir)` moves every object that is `YOU` one tile, with the objects it pushes, and then reparses the rules once. It is movement only: conversions, hazards and winning are the M9 turn. Each `YOU` object's move is planned in full before anything changes: if any object in a push chain can't move (`STOP`, or the edge of the level, which blocks like `STOP`), nothing in that chain moves, the `YOU` object included. An `OPEN` object moving onto a `SHUT` one (or the reverse), pushed or not, destroys both during the move. Several `YOU` objects move one at a time, the one furthest ahead in `dir` first (the reference's choice). `posOf(id)` is `[x, y]`, or `null` once the object has been destroyed (Rust: `Option<(i32, i32)>`); it panics or throws on an id the level never issued. `kindsAt(x, y)` lists the kinds on a tile as they are placed (`"baba"`, `"text:ROCK"`), sorted; `[]` for an empty tile. From M8, `hasProperty` on a destroyed object is `false`.
|
||||
From M8, `move(dir)` moves every object that is `YOU` one tile, with the objects it pushes, and then reparses the rules once. It is movement only: conversions, hazards and winning are the M9 turn. An object steps at most once per move: a `YOU` object that an earlier `YOU` object's move already carried along (pushed, or pulled) doesn't start a move of its own, and isn't moved again. Each `YOU` object's move is planned in full before anything changes: if any object in a push chain can't move (`STOP`, or the edge of the level, which blocks like `STOP`), nothing in that chain moves, the `YOU` object included. An `OPEN` object moving onto a `SHUT` one (or the reverse), pushed or not, destroys both during the move. Several `YOU` objects move one at a time, the one furthest ahead in `dir` first (the reference's choice). `posOf(id)` is `[x, y]`, or `null` once the object has been destroyed (Rust: `Option<(i32, i32)>`); it panics or throws on an id the level never issued. `kindsAt(x, y)` lists the kinds on a tile as they are placed (`"baba"`, `"text:ROCK"`), sorted; `[]` for an empty tile. From M8, `hasProperty` on a destroyed object is `false`.
|
||||
|
||||
In TypeScript a direction is the string `"up" | "down" | "left" | "right"` (exported as the type `Dir`). In Rust it is an enum, defined in the engine and re-exported from `src/harness.rs`:
|
||||
|
||||
@@ -127,7 +127,7 @@ pub enum Dir {
|
||||
}
|
||||
```
|
||||
|
||||
From M9, `step(dir)` plays one whole turn, in the course's simplified order: movement (M8's, without its reparse), parse, conversion, parse, hazards, win, and a last parse so that `rules()` matches the board. Conversion: every object named by an active `X IS Y` (`Y` a noun naming its own things) becomes a `Y` in place, keeping its id; with several `Y`s it becomes the first and each other `Y` is a new object on its tile; `X IS X` blocks it; an object named by `X IS NOT X` is deleted (its other `Y`s still appear, as new objects). All conversions are decided from the board as movement left it. Hazards run in content's order (the reference: `SINK`, `HOT`/`MELT`, `DEFEAT`); each sees only the objects that share a tile on the same float level (both `FLOAT` or both not), and destroys everything it finds at once before the next runs. Hazards and the win check use the rules from the parse after conversion. `won()` becomes `true` when the win check finds a `YOU` object that is `WIN`, or shares its tile with a same-float `WIN` object, and stays `true`. `lost()` is `true` whenever no object is `YOU`. `move(dir)` keeps its M8 meaning.
|
||||
From M9, `step(dir)` plays one whole turn, in the course's simplified order: movement (M8's, without its reparse), parse, conversion, parse, hazards, win, and a last parse so that `rules()` matches the board. Conversion: every object named by an active `X IS Y` (`Y` a noun naming its own things) becomes a `Y` in place, keeping its id, unless an active `… IS NOT Y` also names the object (negation is settled per object, as in `hasProperty`); with several `Y`s it becomes the first and each other `Y` is a new object on its tile; `X IS X` blocks it; an object named by `X IS NOT X` is deleted (its other `Y`s still appear, as new objects). All conversions are decided from the board as movement left it. Hazards run in content's order (the reference: `SINK`, `HOT`/`MELT`, `DEFEAT`); each sees only the objects that share a tile on the same float level (both `FLOAT` or both not), and destroys everything it finds at once before the next runs. Hazards and the win check use the rules from the parse after conversion. `won()` becomes `true` when the win check finds a `YOU` object that is `WIN`, or shares its tile with a same-float `WIN` object, and stays `true`. `lost()` is `true` whenever no object is `YOU`. `move(dir)` keeps its M8 meaning.
|
||||
|
||||
## Part III (`SpellGrid`)
|
||||
|
||||
|
||||
+3
-1
@@ -28,7 +28,7 @@ You teach; the learner builds. The curriculum lives in the course repository (`<
|
||||
|
||||
## The beats
|
||||
|
||||
Read the module's `lesson.md` and `scenarios.md` before starting it. Teach the lesson's H2 beats in order: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading. Track the current one in `beat`.
|
||||
Read the module's `lesson.md` and `scenarios.md` before starting it. Teach the lesson's H2 beats in order: Observe, Predict, Spec, Build, Name it, Expansion Drop, Stress it, Further reading. Track the current one in `beat`.
|
||||
|
||||
**Observe.** Have the learner read the section (or summarise it to them in a few lines with citations if they prefer). Ask what surprised them. Do not move on until they can say the situation in their own words.
|
||||
|
||||
@@ -51,6 +51,8 @@ Read the module's `lesson.md` and `scenarios.md` before starting it. Teach the l
|
||||
3. Run `expansion-check.mjs <workspace> <expansionBase>`. If it fails, the engine changed: do not offer to fix it. Ask what the engine lacked and what data would have let the content express the card. The learner reverts the content change, makes the engine generic in its own commit, records that commit as the new `expansionBase`, and repeats the drop.
|
||||
4. Run the coupling audit again. Then open the lesson's "what this drop measures" discussion.
|
||||
|
||||
**Stress it.** Optional; offer it, do not assign it. Treat each question like a Predict question: the learner commits to an answer before you open its discussion. Where a question asks them to change their engine or write a test, they do it (Rule 1), and experiments that change the engine are made on a throwaway branch or reverted, never left in place before the next module. Module 11 is made only of this kind of work: it has no tests to start and no drop, so teach its sections in whatever order the learner picks.
|
||||
|
||||
**Further reading.** Offer the list; do not assign it. Then set `module` to the next number, `beat` to `Observe`, `hintsUsed` to 0.
|
||||
|
||||
## Hint tiers
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@
|
||||
"check": "node tools/sync-check.mjs",
|
||||
"test:site": "node --test site/scripts/*.test.mjs",
|
||||
"check:links": "node tools/check-links.mjs",
|
||||
"play:spellgrid": "node tools/play-spellgrid.mjs"
|
||||
"play:spellgrid": "node tools/play-spellgrid.mjs",
|
||||
"solve:spellgrid": "node tools/solve-spellgrid.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Search a SpellGrid level (Module 10) for solutions, by brute force, with the course's
|
||||
// TypeScript reference engine. Used in Module 11 to check that a level has the solution
|
||||
// its designer meant and no other.
|
||||
//
|
||||
// node tools/solve-spellgrid.mjs [level] [--dir <dir>] [--max N] [--naive] [--never-rule "X IS Y"] [--all]
|
||||
// npm run solve:spellgrid -- l1 --never-rule "DEATHRATTLE IS DOUBLE"
|
||||
//
|
||||
// Stages the Module 10 TypeScript reference with its cards and levels, as play-spellgrid
|
||||
// does, copies tools/solver/solve.ts beside it and runs it there. `level` is a level of
|
||||
// course/10-spellgrid/levels by name or prefix (`l1`), or a path to a level file; default `l1`.
|
||||
// The other options are the solver's: see tools/solver/solve.ts.
|
||||
import { cpSync, existsSync, readdirSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fail, isMain } from "./lib.mjs";
|
||||
import { resolveLevel, stagePlay } from "./play-spellgrid.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const WITH_VALUE = ["--max", "--never-rule"];
|
||||
const SWITCHES = ["--naive", "--all"];
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const opts = { level: "l1", dir: null, solver: [] };
|
||||
const rest = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--dir") opts.dir = argv[++i];
|
||||
else if (WITH_VALUE.includes(argv[i])) opts.solver.push(argv[i], argv[++i]);
|
||||
else if (SWITCHES.includes(argv[i])) opts.solver.push(argv[i]);
|
||||
else rest.push(argv[i]);
|
||||
}
|
||||
[opts.level = "l1"] = rest;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const dir = resolve(stagePlay("ts", opts.dir));
|
||||
const level = resolveLevel(join(dir, "levels"), opts.level);
|
||||
if (!level) {
|
||||
const names = readdirSync(join(dir, "levels")).filter((f) => f.endsWith(".json")).map((f) => basename(f, ".json"));
|
||||
fail(`no level "${opts.level}". Levels: ${names.join(", ")}`);
|
||||
}
|
||||
cpSync(join(here, "solver", "solve.ts"), join(dir, "solve.ts"));
|
||||
if (!existsSync(join(dir, "node_modules"))) {
|
||||
console.error(`Installing the TypeScript reference's dependencies in ${dir} ...`);
|
||||
const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, stdio: ["ignore", "inherit", "inherit"] });
|
||||
if (r.status !== 0) fail("npm install failed");
|
||||
}
|
||||
const r = spawnSync("npx", ["tsx", "solve.ts", level.path, ...opts.solver], { cwd: dir, stdio: "inherit" });
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) main();
|
||||
@@ -0,0 +1,160 @@
|
||||
// A brute-force solver for SpellGrid levels, run inside a staged Module 10 TypeScript
|
||||
// reference by tools/solve-spellgrid.mjs. Breadth-first over the player's inputs.
|
||||
//
|
||||
// It never copies engine state. Every position is reached by replaying its inputs on a
|
||||
// fresh level, which needs nothing from the engine but determinism. A position is
|
||||
// remembered by a key built from what the harness shows (every tile's kinds, each
|
||||
// minion's card and Health, the hand, the heroes), plus the choices made for Assemble,
|
||||
// because two Assembled cards can share a name. Two input sequences with the same key
|
||||
// are treated as one position, so the search finds one shortest solution per winning
|
||||
// position, not every sequence of inputs that wins.
|
||||
//
|
||||
// npx tsx solve.ts <level.json> [--max N] [--naive] [--never-rule "X IS Y"] [--all]
|
||||
//
|
||||
// --max N stop after N inputs (default 12)
|
||||
// --naive the level-3 test hook: Aura Update before the Death Creation Step
|
||||
// --never-rule R discard every position in which rule R is in force: "is the level
|
||||
// solvable without ever forming R?"
|
||||
// --all keep searching after the first depth that has a solution
|
||||
import { readFileSync } from "node:fs";
|
||||
import { newSpellGrid, type LevelDef, type SpellGrid } from "./src/harness";
|
||||
|
||||
type Dir = "up" | "down" | "left" | "right";
|
||||
type Input =
|
||||
| { type: "step"; dir: Dir }
|
||||
| { type: "cast"; card: string; at?: [number, number]; picks: number[] }
|
||||
| { type: "play"; card: string; at: [number, number] };
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string): boolean => args.includes(name);
|
||||
const option = (name: string): string | undefined => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
||||
const levelPath = args.find((a) => a.endsWith(".json"));
|
||||
if (levelPath === undefined) {
|
||||
process.stderr.write('usage: npx tsx solve.ts <level.json> [--max N] [--naive] [--never-rule "X IS Y"] [--all]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const level = JSON.parse(readFileSync(levelPath, "utf8")) as LevelDef;
|
||||
const maxDepth = Number(option("--max") ?? 12);
|
||||
const neverRule = option("--never-rule");
|
||||
|
||||
/** Apply one input. Returns how many options each Assemble choice offered, in order. */
|
||||
function apply(g: SpellGrid, input: Input): number[] {
|
||||
const offered: number[] = [];
|
||||
if (input.type === "step") g.step(input.dir);
|
||||
else if (input.type === "play") g.play(input.card, ...input.at);
|
||||
else {
|
||||
let next = 0;
|
||||
g.setChooser((_kind, options) => {
|
||||
offered.push(options.length);
|
||||
return options[input.picks[next++] ?? 0];
|
||||
});
|
||||
const target = input.at === undefined ? null : g.minionAt(...input.at);
|
||||
if (input.at !== undefined && target === null) throw new Error("no minion on the target tile");
|
||||
g.cast(input.card, target ?? undefined);
|
||||
}
|
||||
return offered;
|
||||
}
|
||||
|
||||
function replay(inputs: Input[]): SpellGrid {
|
||||
const g = newSpellGrid(level, []);
|
||||
if (flag("--naive")) g.withNaiveAuraTiming();
|
||||
for (const input of inputs) apply(g, input);
|
||||
return g;
|
||||
}
|
||||
|
||||
function key(g: SpellGrid, assembled: string): string {
|
||||
const tiles: string[] = [];
|
||||
for (let y = 0; y < level.height; y++) {
|
||||
for (let x = 0; x < level.width; x++) {
|
||||
const kinds = g.kindsAt(x, y);
|
||||
if (kinds.length === 0) continue;
|
||||
const minion = g.minionAt(x, y);
|
||||
tiles.push(`${x},${y}:${kinds.join("+")}${minion === null ? "" : `#${g.nameOf(minion)}/${g.health(minion)}`}`);
|
||||
}
|
||||
}
|
||||
return [tiles.join("|"), g.hand().join(","), assembled, g.health(g.hero(0)), g.health(g.hero(1))].join("~");
|
||||
}
|
||||
|
||||
const show = (input: Input): string =>
|
||||
input.type === "step"
|
||||
? input.dir
|
||||
: input.type === "play"
|
||||
? `play ${input.card} at (${input.at})`
|
||||
: `cast ${input.card}${input.at ? ` at (${input.at})` : ""}${input.picks.length > 0 ? ` choosing ${input.picks.map((p) => p + 1).join(", ")}` : ""}`;
|
||||
|
||||
/** Every input worth trying from the position `inputs` reaches. */
|
||||
function moves(g: SpellGrid, inputs: Input[]): Input[] {
|
||||
const out: Input[] = (["up", "down", "left", "right"] as const).map((dir) => ({ type: "step", dir }));
|
||||
const minions: [number, number][] = [];
|
||||
const empty: [number, number][] = [];
|
||||
for (let y = 0; y < level.height; y++) {
|
||||
for (let x = 0; x < level.width; x++) {
|
||||
if (g.minionAt(x, y) !== null) minions.push([x, y]);
|
||||
if (g.kindsAt(x, y).length === 0) empty.push([x, y]);
|
||||
}
|
||||
}
|
||||
for (const card of new Set(g.hand())) {
|
||||
if (g.checkCast(card) === null) {
|
||||
// Assemble asks the chooser; try every answer to every question it asks.
|
||||
const pending: number[][] = [[]];
|
||||
while (pending.length > 0) {
|
||||
const picks = pending.pop()!;
|
||||
const offered = apply(replay(inputs), { type: "cast", card, picks });
|
||||
if (offered.length > picks.length) {
|
||||
for (let i = 0; i < offered[picks.length]; i++) pending.push([...picks, i]);
|
||||
} else out.push({ type: "cast", card, picks });
|
||||
}
|
||||
}
|
||||
for (const at of minions) {
|
||||
if (g.checkCast(card, g.minionAt(...at)!) === null) out.push({ type: "cast", card, at, picks: [] });
|
||||
}
|
||||
for (const at of empty) if (g.checkPlay(card, ...at) === null) out.push({ type: "play", card, at });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let frontier: { inputs: Input[]; assembled: string }[] = [{ inputs: [], assembled: "" }];
|
||||
const seen = new Set<string>([key(replay([]), "")]);
|
||||
let solutions = 0;
|
||||
let exhausted = false;
|
||||
for (let depth = 1; depth <= maxDepth; depth++) {
|
||||
const next: typeof frontier = [];
|
||||
for (const node of frontier) {
|
||||
for (const input of moves(replay(node.inputs), node.inputs)) {
|
||||
const inputs = [...node.inputs, input];
|
||||
let g: SpellGrid;
|
||||
try {
|
||||
g = replay(inputs);
|
||||
} catch {
|
||||
continue; // not a legal input here
|
||||
}
|
||||
if (neverRule !== undefined && g.activeRules().includes(neverRule)) continue;
|
||||
if (g.won()) {
|
||||
solutions++;
|
||||
console.log(`solution in ${depth}: ${inputs.map(show).join("; ")}`);
|
||||
continue;
|
||||
}
|
||||
if (g.lost()) continue;
|
||||
const assembled = node.assembled + (input.type === "cast" && input.picks.length > 0 ? `[${input.picks}]` : "");
|
||||
const k = key(g, assembled);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
next.push({ inputs, assembled });
|
||||
}
|
||||
}
|
||||
console.log(`${depth} input${depth === 1 ? "" : "s"}: ${next.length} new positions, ${seen.size} in all`);
|
||||
frontier = next;
|
||||
if (frontier.length === 0) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
if (solutions > 0 && !flag("--all")) break;
|
||||
}
|
||||
if (solutions === 0) {
|
||||
console.log(
|
||||
exhausted
|
||||
? "no solution: every reachable position was searched"
|
||||
: `no solution within ${maxDepth} inputs (the search was cut off, not exhausted)`,
|
||||
);
|
||||
}
|
||||
process.exit(solutions > 0 ? 0 : 1);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { toolsDir } from "./helpers.mjs";
|
||||
import { parseArgs } from "../solve-spellgrid.mjs";
|
||||
|
||||
test("solve_spellgrid_parses_args", () => {
|
||||
assert.deepEqual(parseArgs([]), { level: "l1", dir: null, solver: [] });
|
||||
assert.deepEqual(parseArgs(["l3", "--naive", "--max", "9", "--dir", "x", "--never-rule", "A IS B", "--all"]), {
|
||||
level: "l3",
|
||||
dir: "x",
|
||||
solver: ["--naive", "--max", "9", "--never-rule", "A IS B", "--all"],
|
||||
});
|
||||
});
|
||||
|
||||
test("solve_spellgrid_solver_uses_only_the_harness", () => {
|
||||
// The solver is copied into a staged reference: it may import the harness and nothing else of the engine.
|
||||
const path = join(toolsDir, "solver", "solve.ts");
|
||||
assert.ok(existsSync(path));
|
||||
const imports = [...readFileSync(path, "utf8").matchAll(/from "([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual(imports.sort(), ["./src/harness", "node:fs"]);
|
||||
});
|
||||
Reference in new issue
Block a user