465 lines
40 KiB
Markdown
465 lines
40 KiB
Markdown
# Module 11: Conditions
|
||
|
||
Module 7 decided which rules are in force. Module 9 decided when, in a turn, the rules are read. This module adds one word, `ON`, and it unsettles both. `BABA ON KEY IS WIN` is a rule about some Babas: the ones standing on a key, at the moment someone asks. The rule is in force the whole time. Whether it *applies* to a given Baba changes every time that Baba moves, or the key does, or the key is destroyed.
|
||
|
||
This is the third time the course meets a rule that modifies another rule, and the third answer to "when is the modifier read?". In Module 4, Baron Rivendare was read from a snapshot taken at scheduled moments. In Module 7, `BABA IS NOT YOU` was read once, when the rules were compiled. A condition is read every time someone asks. Lesson 10's closing question said both engines answer "when is the world looked at?" with a schedule. This module is the exception, and it shows what the exception costs.
|
||
|
||
You need your Module 10 workspace with all Module 1–10 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).
|
||
|
||
## Observe
|
||
|
||
All quotes come from sources recorded in the course's research notes. The Baba Is You Wiki is an unofficial community wiki; the course treats it as the best available description of the game, not as Hempuli's own word.
|
||
|
||
What `ON` does, from the wiki's [ON](https://babaiswiki.fandom.com/wiki/ON) page:
|
||
|
||
> ON is an infix condition that makes a rule apply when object is overlapping another object.
|
||
|
||
Its examples: "FLAG ON BABA IS WIN makes flag WIN only when it's on a baba." And with two nouns: "FLAG ON BABA AND BABA IS WIN makes flag WIN only when it's on two babas." The same page says "ON ignores FLOAT levels".
|
||
|
||
How a condition fits into a sentence, from the wiki's [Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions) page:
|
||
|
||
> They are parsed as noun1 condition noun2 verb thing, where noun1 verb thing only if the condition is true.
|
||
|
||
The same page on `AND`. Before the condition, "AND in the noun1 part works like normal": `BABA AND KEKE ON SKULL IS DEFEAT` is two rules, one for each subject. After it, "AND in the noun2 part requires at least as many objects as are mentioned", so `BABA ON KEKE AND KEKE IS WIN` needs two Kekes. And a limit: "It is not possible to write something like noun1 condition1 noun2 condition2 noun3 verb thing."
|
||
|
||
Negation works on conditional rules too. The [NOT](https://babaiswiki.fandom.com/wiki/NOT) page: "ROCK ON BABA IS NOT PUSH makes ROCK that is on BABA not PUSHable."
|
||
|
||
Now the part that matters most for an engine. The [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) page, which Module 9 built its turn from, opens with this:
|
||
|
||
> Conditions process when the game checks for what objects have a rule, which is usually right before it uses it.
|
||
|
||
It comes back to conditions twice. In the movement phase, "it completely finishes moving for a particular move stage before checking which units are in the next", "so conditions (e.g. IT ON BABA IS YOU AND MOVE) reparse in between". And for `HAS`: "conditions on these rules are rechecked at the time of destruction, even if multiple destructions occur in the same turn."
|
||
|
||
Notice what this says and what it doesn't. A condition is not part of the rule list that the game builds when it parses. It is evaluated when a rule is used, "usually right before". Module 9's turn parses at fixed points and every phase reads the rules from the parse before it. A condition doesn't wait for a parse.
|
||
|
||
Put a board together. `BABA IS YOU`, `BABA AND LAVA IS FLOAT`, `LAVA IS HOT`, `KEY IS SINK` and `BABA ON KEY IS MELT`. A tile holds lava, a key and a rock. Baba steps onto it. When the rules are parsed after movement, Baba is on the key, so `BABA ON KEY IS MELT` applies to it. Then the hazards run, in the `block()` order from Module 9: SINK first. The key is SINK and shares its tile with the rock on the same float level, so both go. "Deletion occurs after each step that removes objects". Then HOT/MELT asks whether Baba is MELT. If the condition is checked right before it is used, Baba is on no key now, and **Baba survives**. No page shows this board. The course infers the outcome from the two quotes above, and this module's test says so.
|
||
|
||
## Predict
|
||
|
||
Write your answers down before you open the discussion, for example in `notes/11.md` in your workspace.
|
||
|
||
1. `KEY ON ROCK IS WIN`. One key lies on a rock, another key lies alone. Which keys are WIN? Is the rock?
|
||
2. `BABA IS YOU` and `BABA ON KEY IS NOT YOU`. One Baba stands on a key, another on an empty tile. Which of them moves? What does `activeRules()` list?
|
||
3. Add `BABA ON KEY IS YOU` to the board of question 2. Does anything change? What does `activeRules()` list now?
|
||
4. `BABA IS YOU`, `LAVA IS HOT` and `BABA ON KEY IS MELT`. One Baba steps onto a tile with a key and lava, another onto a tile with lava only. Before the step, is either Baba MELT? After it, who is left?
|
||
5. `KEY ON ROCK AND ROCK IS WIN`, with one key on a single rock. WIN?
|
||
6. `BABA ON` then a tile holding two stacked words, `KEY` and `ROCK`, then `IS WIN`. How many rules is that, and what are they?
|
||
7. `ROCK ON ALL IS WIN`. Two rocks share a tile, and nothing else is on it. Are they WIN? A third rock shares its tile with a piece of text. Is it?
|
||
8. The board at the end of Observe. Does Baba survive? Would it in an engine that decided every condition when it parsed the rules?
|
||
9. Baron is read from a snapshot; `NOT` is read when the rules are compiled. In your engine, where would you read `ON`, and when?
|
||
|
||
<details><summary>Discussion</summary>
|
||
|
||
1. The key on the rock. The other key isn't, and neither is the rock: the rock is what the condition is about, not a subject. The sentence spells one rule, `KEY ON ROCK IS WIN`. It must not spell `ROCK IS WIN`, which is what your Module 10 parser reads from it.
|
||
|
||
2. Only the Baba on the empty tile moves. For the Baba on the key, both rules apply, and the negation wins, as in Module 7. For the other, only `BABA IS YOU` applies. `activeRules()` lists both rules (and `TEXT IS PUSH`). Module 7's resolution pass would have cancelled `BABA IS YOU` here, because the subject and the complement match. With conditions it can't: whether the negation wins depends on where each Baba stands, which the rule list doesn't know.
|
||
|
||
3. Nothing changes on the board. `BABA ON KEY IS NOT YOU` cancels `BABA ON KEY IS YOU` exactly as `BABA IS NOT YOU` cancels `BABA IS YOU`: same subject, same complement, and now also the same condition. So `activeRules()` is the same as before. The resolution pass still does its job, for rules whose conditions are identical; the rest is settled for each object.
|
||
|
||
4. Neither is MELT before the step. After it, the Baba that stepped onto the key is gone and the other stands on its lava. The rules never changed. Only the board did, and `ON` reads the board.
|
||
|
||
5. No. One rock can't stand for both nouns: "AND in the noun2 part requires at least as many objects as are mentioned". The course needs a different object for each listed noun.
|
||
|
||
6. Two rules: `BABA ON KEY IS WIN` and `BABA ON ROCK IS WIN`. A stack is a choice of words, as it has been since Module 6, so each noun in the stack makes its own sentence. It is not `BABA ON KEY AND ROCK IS WIN`, which would need both.
|
||
|
||
7. In the course, yes, both: each is on the other. `ALL` names the rock itself too, but `ON` asks about *another* object. The third rock isn't WIN: `ALL` doesn't name text. The course reads `ON ALL` as "any other object that isn't text". That is the course's choice: the real game asks for more (see "How the real game differs" under Build).
|
||
|
||
8. It survives, if conditions are checked when they are used. In an engine that decided `BABA ON KEY` during the parse after movement, Baba would stay MELT until the next parse, and HOT/MELT would destroy it. The difference is visible only because something changed the board between the parse and the use, and in Module 9's turn the hazards are such a thing.
|
||
|
||
9. There is no single place. Baron's count was read by one routine at the moment a trigger ran. `NOT` was applied by one pass over the rules. A condition has to be checked by everything that asks a question about an object: `hasProperty`, and so movement, every hazard and the win check, and conversion. Keep the answer in one function that all of them call, and the question "where" goes back to having one answer.
|
||
|
||
</details>
|
||
|
||
## Spec
|
||
|
||
From the course repository, copy the module's tests into your workspace:
|
||
|
||
```sh
|
||
node tools/start-module.mjs 11 ~/my-engine
|
||
```
|
||
|
||
### The harness
|
||
|
||
No new functions. The contract of four old ones grows:
|
||
|
||
- `rules()` and `activeRules()` write a condition between the subject and `IS`, its nouns in the order they are written: `"KEY ON ROCK IS WIN"`, `"KEY ON ROCK AND FLAG IS WIN"`, `"BABA ON KEY IS NOT YOU"`.
|
||
- `hasProperty(id, prop)` uses only the rules that apply to the object: its noun names the object, and every condition of the rule holds for the object on the board as it is now.
|
||
- `step(dir)`: conversion uses the same rules, decided from the board as movement left it. The hazards and the win check ask `hasProperty`, so each of them checks conditions when it runs.
|
||
|
||
What `ON Y1 AND … AND Yk` means: the other objects on the object's tile can supply a different object for each `Yi`, one that `Yi` names. Float levels don't matter.
|
||
|
||
New word: `ON`, with a new role, `condition`. In the reference's word list: `{ "name": "ON", "role": "condition" }`.
|
||
|
||
### The tests
|
||
|
||
The full statements are in `course/11-conditions/scenarios.md`.
|
||
|
||
- `test_11_condition_gates_property`: Predict question 1.
|
||
- `test_11_condition_follows_the_object`: Predict question 4. `activeRules()` is the same before and after the turn.
|
||
- `test_11_and_requires_both`: `KEY ON ROCK AND FLAG IS WIN`, with keys on a rock, on a flag, and on both.
|
||
- `test_11_and_counts_objects`: Predict question 5, and a key on two rocks.
|
||
- `test_11_conditional_not_only_where_it_holds`: Predict questions 2 and 3, then a turn.
|
||
- `test_11_conditional_conversion`: `KEY ON ROCK IS FLAG`. A key already on a rock converts, and so does a key that a pushed rock arrived on during the same turn. A key alone doesn't.
|
||
- `test_11_condition_and_stacked_text`: Predict question 6, and `ON` stacked on `AND`, which reads both ways.
|
||
- `test_11_on_group_noun`: Predict question 7.
|
||
- `test_11_condition_rechecked_between_hazards`: Predict question 8. The outcome is the course's inference.
|
||
|
||
The second test:
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_11_condition_follows_the_object() {
|
||
let mut b = new_baba(empty_level(6, 5));
|
||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||
write(&mut b, &[("LAVA", 0, 1), ("IS", 1, 1), ("HOT", 2, 1)]);
|
||
write(
|
||
&mut b,
|
||
&[
|
||
("BABA", 0, 2),
|
||
("ON", 1, 2),
|
||
("KEY", 2, 2),
|
||
("IS", 3, 2),
|
||
("MELT", 4, 2),
|
||
],
|
||
);
|
||
let to_key = b.place("baba", 0, 4);
|
||
b.place("key", 1, 4);
|
||
b.place("lava", 1, 4);
|
||
let to_lava = b.place("baba", 3, 4);
|
||
b.place("lava", 4, 4);
|
||
let before = b.active_rules();
|
||
|
||
assert!(!b.has_property(to_key, "MELT"));
|
||
assert!(!b.has_property(to_lava, "MELT"));
|
||
|
||
b.step(Dir::Right);
|
||
|
||
assert_eq!(b.active_rules(), before);
|
||
assert_eq!(b.pos_of(to_key), None);
|
||
assert_eq!(b.kinds_at(1, 4), vec!["key", "lava"]);
|
||
assert_eq!(b.pos_of(to_lava), Some((4, 4)));
|
||
assert_eq!(b.kinds_at(4, 4), vec!["baba", "lava"]);
|
||
}
|
||
```
|
||
|
||
```ts
|
||
test("test_11_condition_follows_the_object", () => {
|
||
const b = newBaba(emptyLevel(6, 5));
|
||
write(b, [
|
||
["BABA", 0, 0],
|
||
["IS", 1, 0],
|
||
["YOU", 2, 0],
|
||
]);
|
||
write(b, [
|
||
["LAVA", 0, 1],
|
||
["IS", 1, 1],
|
||
["HOT", 2, 1],
|
||
]);
|
||
write(b, [
|
||
["BABA", 0, 2],
|
||
["ON", 1, 2],
|
||
["KEY", 2, 2],
|
||
["IS", 3, 2],
|
||
["MELT", 4, 2],
|
||
]);
|
||
const toKey = b.place("baba", 0, 4);
|
||
b.place("key", 1, 4);
|
||
b.place("lava", 1, 4);
|
||
const toLava = b.place("baba", 3, 4);
|
||
b.place("lava", 4, 4);
|
||
const before = b.activeRules();
|
||
|
||
expect(b.hasProperty(toKey, "MELT")).toBe(false);
|
||
expect(b.hasProperty(toLava, "MELT")).toBe(false);
|
||
|
||
b.step("right");
|
||
|
||
expect(b.activeRules()).toEqual(before);
|
||
expect(b.posOf(toKey)).toBeNull();
|
||
expect(b.kindsAt(1, 4)).toEqual(["key", "lava"]);
|
||
expect(b.posOf(toLava)).toEqual([4, 4]);
|
||
expect(b.kindsAt(4, 4)).toEqual(["baba", "lava"]);
|
||
});
|
||
```
|
||
|
||
Run `cargo test` or `npm test`. The Module 11 tests fail: your lexer doesn't know `ON`, so the parser skips it and reads `KEY IS MELT` or `ROCK IS WIN` where a condition stands. Modules 1–10 still pass. That is RED.
|
||
|
||
## Build
|
||
|
||
Make the nine tests pass, and keep Modules 1–10 green. Where code goes:
|
||
|
||
- **A new role, not a new word in the engine.** `ON` goes in your word list with the role `condition`. The engine learns that role, and where it may stand. It doesn't learn the word: the Expansion Drop brings a second condition, and you won't be allowed to touch `src/engine/`.
|
||
- **The parser finds the condition, and only reads it.** Walking back from `IS`, the parser collects a run of nouns, as it has since Module 6. If a condition word stands just before that run, the run was the condition's nouns, and the subjects are the run before the condition word. Otherwise the run is the subjects, as before. A rule now carries its conditions: the word, and its nouns.
|
||
- **Stacks still mean every reading.** Subjects distribute, so a stack of subjects was always just more rules. Condition nouns don't distribute (`AND` there means *all of them*), so a stack among them is a choice: one rule per noun in the stack.
|
||
- **What a condition means is content.** A condition check is a function from (the level, one object, the condition's nouns) to yes or no, registered by its word, as Module 8's movement responses and Module 9's hazards are. `ON` is one such function.
|
||
- **Check at use, in one place.** Write one function that answers "does this rule apply to this object now?": the subject names the object, and every condition holds. `hasProperty` and conversion both call it. Don't decide conditions when you parse, and don't cache the answer.
|
||
- **Resolution pairs like with like.** `X ON Z IS NOT Y` cancels `X ON Z IS Y` in the resolution pass, as before. It doesn't cancel `X IS Y`: that one is settled for each object, among the rules that apply to it. `hasProperty` already settles negation per object, so the change there is only which rules it looks at. The identity lock pairs the same way.
|
||
- **Print the condition.** `rules()` writes `KEY ON ROCK AND FLAG IS WIN`.
|
||
|
||
<details><summary>Hint 1 — nudge</summary>
|
||
|
||
You will be tempted to make conditions disappear early. One way: when you parse `BABA ON KEY IS WIN`, look at the board and emit `BABA IS WIN` "for the Babas on keys". Another: compute the answer for every object at each parse and store it. Both pass the first tests. Then play Predict question 8 by hand: what does each of them think about Baba after SINK has removed the key?
|
||
|
||
</details>
|
||
|
||
<details><summary>Hint 2 — concept</summary>
|
||
|
||
- **Parsing.** The run of nouns before `IS` ends at a tile that holds no `AND` (or a `NOT`). Look at that tile. If it holds a condition word, the run you collected is the condition's nouns, in reverse order; read the run of subjects from the tile before the condition word. If it doesn't, the run is the subjects and there is no condition. A stack among the condition's nouns: walk every path, one noun per tile, and make one condition per path. A stack of a condition word and an `AND` on one tile can be read both ways; the reference does.
|
||
- **The rule.** Add `conditions: [{ word, nouns }]`. Rules spelled without a condition have none. Two rules "have the same conditions" if they have the same words, each with the same nouns.
|
||
- **One question, one place.** `appliesTo(rule, id)`: the rule's subject names the object, and for every condition, content's check for its word says yes for this object and these nouns. In `hasProperty`, filter the active rules by complement and by `appliesTo`, then do what Module 7 did: one positive and no negated. In conversion, collect the rules that apply to the object, instead of the rules whose subject names it, and do what Module 9 did.
|
||
- **`ON`.** The other objects on the object's tile. Each noun needs one of them that it names, a different one for each noun. With one noun that is just "any". With several, try each assignment: a short backtracking search.
|
||
- **Resolution.** In both steps, add "and the same conditions" to the pairing.
|
||
|
||
</details>
|
||
|
||
<details><summary>Hint 3 — pseudo-code</summary>
|
||
|
||
```text
|
||
before_is(tiles, at, step): // walking back from IS
|
||
readings = []
|
||
run = subjects(tiles, at, step) // as in Module 7, also returning the tile it stopped on
|
||
if no condition word at run.stop:
|
||
readings += (run.nouns, no condition)
|
||
for each path in noun_runs(tiles, at, step): // one noun per tile, joined by AND
|
||
for each condition word c at path.stop:
|
||
subs = subjects(tiles, path.stop + step, step).nouns
|
||
readings += (subs, condition(c, reverse(path.nouns)))
|
||
return readings
|
||
|
||
applies_to(rule, o):
|
||
names(rule.subject, o) and
|
||
every c in rule.conditions: content.checks[c.word](level, o, c.nouns)
|
||
|
||
has_property(o, prop):
|
||
about = [r in active where r.complement == prop and applies_to(r, o)]
|
||
return any(r positive in about) and not any(r negated in about)
|
||
|
||
on(level, o, nouns):
|
||
others = objects on o's tile, other than o
|
||
return each noun can be given its own object in others that it names
|
||
|
||
negates(n, r): n.negated and not r.negated and same subject, complement and conditions
|
||
```
|
||
|
||
</details>
|
||
|
||
When your tests pass, compare with the reference engine in `course/11-conditions/reference/rust/src/` or `course/11-conditions/reference/ts/src/`: `engine/puzzle/parse` (`before_is` / `beforeIs` and `noun_runs` / `nounRuns`), the new `engine/puzzle/condition`, `engine/puzzle/level` (`applies_to` / `appliesTo`), `engine/puzzle/resolve`, and the new `content/conditions`.
|
||
|
||
Decisions in the reference that the tests don't force:
|
||
|
||
- **A condition word without a check is an error.** `Level` refuses to start if a word with role `condition` has no check in content (TypeScript throws, Rust panics). Otherwise a forgotten check would quietly make every rule with that word apply never, or always.
|
||
- **Only other objects count.** `ROCK ON ROCK IS WIN` needs a second rock. The ON page says "overlapping another object"; the course reads that as excluding the object itself, and applies the same reading to `ALL`.
|
||
- **A different object for each noun, found by search.** With the course's meaning of `ON ALL` (any other object that isn't text; the game's differs, see the box below), `ON ALL AND KEY` on a tile with a key and a rock holds: the rock stands for `ALL` and the key for `KEY`, even if the key is checked first. The game claims objects in order and the wiki says that can fail (see the box below). The course tries every assignment, so the answer never depends on object order.
|
||
- **Same conditions means the same nouns in any order.** `BABA ON KEY AND ROCK IS NOT YOU` cancels `BABA ON ROCK AND KEY IS YOU`. They are true of exactly the same objects. `rules()` still prints each as written.
|
||
- **`ON` stacked on `AND` reads both ways.** `FLAG [ON AND] KEY IS PUSH` is `FLAG ON KEY IS PUSH`, and also `FLAG IS PUSH` and `KEY IS PUSH`. That is Module 6's rule for stacks: every reading the grammar allows. No source covers this board.
|
||
- **A condition crosses no seam.** In SpellGrid, `DEATHRATTLE ON KEY IS DOUBLE` gives no aura. A condition is about one object on the grid, and a trigger word names no object. Module 10's `ruleAuras` / `rule_auras` skips rules with conditions.
|
||
- **Who moves is decided once per movement phase.** Module 8's movement collects the `YOU` objects when it starts. A conditional `YOU` that becomes true halfway through the moves doesn't add a mover. The responses (`PUSH`, `STOP`, `OPEN`/`SHUT`) ask `hasProperty` as each step is planned, so they see the board as the earlier moves left it. The game also fixes its movers per move stage: "it completely finishes moving for a particular move stage before checking which units are in the next".
|
||
|
||
> **How the real game differs**
|
||
> - **`NOT` before a condition.** "Conditions can be negated by prefixing with NOT" ([Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions)), and `NOT` before a condition's noun names everything else ([NOT](https://babaiswiki.fandom.com/wiki/NOT): "FLAG ON NOT BABA IS WIN makes flag WIN when it is ON something other than baba"). The course reads neither: `BABA NOT ON KEY IS WIN` and `BABA ON NOT KEY IS WIN` spell nothing, as `NOT BABA IS YOU` spells nothing in Module 7.
|
||
> - **Several conditions.** The game joins conditions with `AND` (`BABA ON KEKE AND NEAR ME IS WIN`) and has prefix conditions such as `LONELY` and `POWERED`, "parsed as condition noun verb thing" ([Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions)). The course has one infix condition per sentence. A rule carries a list of conditions, so more would fit, but the parser reads one.
|
||
> - **Chained conditions.** `A ON B ON C IS WIN`: "This will parse as noun2 condition2 noun3 verb thing" (same page). The course does the same, by accident of its grammar: the run of subjects before the second `ON` stops at the first one.
|
||
> - **Matching nouns to objects.** For `NOT` among a condition's nouns, "the game first claims objects for all non-NOT cases, then claims objects for the NOT cases in order", and the page says this can wrongly fail (same page). The course has no `NOT` there and searches every assignment.
|
||
> - **`ALL` after a condition.** In the game, "ALL in the noun2 part requires one of every object in ALL", and `BABA ON ALL AND ALL AND FLAG IS WIN` needs Baba on two of everything in ALL, "not counting itself", plus a flag ([Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions)). The course's `ON ALL` means any one other object that isn't text, the way `ALL` names objects as a subject. That simpler meaning is the course's choice, and `test_11_on_group_noun` pins it.
|
||
> - **`LEVEL ON ROCK`**, and the level icon's position ([ON](https://babaiswiki.fandom.com/wiki/ON)). The course has no `LEVEL`.
|
||
> - **When a condition is checked.** The game checks conditions "usually right before it uses it" ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations)). The course always checks at use. The page doesn't list the exceptions.
|
||
> - **The hazard board is inferred.** Baba surviving after SINK removes the key follows from "Conditions process ... right before it uses it" and "Deletion occurs after each step that removes objects". No source shows this board.
|
||
> - **Conditional `NOT` against an unconditional rule.** The NOT page says a negation overrides "other instances of the rule it disables", and its example "ROCK ON BABA IS NOT PUSH makes ROCK that is on BABA not PUSHable" names only those rocks. No page read spells out what happens with `ROCK IS PUSH` elsewhere on the board. Settling the negation per object, among the rules that apply, is the course's reading.
|
||
> - **Object order.** "Objects are parsed in order of object priority" ([ON](https://babaiswiki.fandom.com/wiki/ON)), which the game sets by placement on the grid ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations)). The course's objects are in age order, and no answer in this module depends on it.
|
||
|
||
## Name it
|
||
|
||
The pattern is a **guard**: a condition attached to a rule, checked each time the rule is about to be used, against the state as it is then. The rule itself doesn't change. Whether it applies does. Dijkstra's guarded commands are the classic form: "A guarded command consists of a boolean condition or guard, and a statement "guarded" by it." ([Guarded Command Language, Wikipedia](https://en.wikipedia.org/wiki/Guarded_Command_Language)). Another way to say it: a rule with a condition is a **predicate** over objects, not a fact about a noun. `BABA IS WIN` was a fact about every Baba; `BABA ON KEY IS WIN` is a test you run on one Baba.
|
||
|
||
The reference's whole change to the question "does this object have this property?":
|
||
|
||
```rust
|
||
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
|
||
if self.board.find(id).is_none() {
|
||
return false;
|
||
}
|
||
let about_it: Vec<&Rule> = self
|
||
.active
|
||
.iter()
|
||
.filter(|r| r.complement == property && self.applies_to(r, id))
|
||
.collect();
|
||
about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated)
|
||
}
|
||
|
||
/// Does `rule` apply to object `id` now? Its subject names the object, and each
|
||
/// of its conditions holds for the object on the board as it is (Module 11).
|
||
fn applies_to(&self, rule: &Rule, id: ObjectId) -> bool {
|
||
self.names(&rule.subject, id)
|
||
&& rule
|
||
.conditions
|
||
.iter()
|
||
.all(|c| (self.conditions[&c.word])(self, id, &c.nouns))
|
||
}
|
||
```
|
||
|
||
```ts
|
||
hasProperty(id: ObjectId, property: string): boolean {
|
||
if (this.board.find(id) === undefined) return false;
|
||
const aboutIt = this.active.filter((r) => r.complement === property && this.appliesTo(r, id));
|
||
return aboutIt.some((r) => !r.negated) && !aboutIt.some((r) => r.negated);
|
||
}
|
||
|
||
/**
|
||
* Does `rule` apply to object `id` now? Its subject names the object, and each of
|
||
* its conditions holds for the object on the board as it is (Module 11).
|
||
*/
|
||
private appliesTo(rule: Rule, id: ObjectId): boolean {
|
||
return (
|
||
this.names(rule.subject, id) && rule.conditions.every((c) => this.conditions[c.word](this, id, c.nouns))
|
||
);
|
||
}
|
||
```
|
||
|
||
Module 7's "one question, one place" is what made this small. Every system that asks about an object already went through `hasProperty`, so putting the guard there reaches movement, the hazards, the win check and `lost()` at once. Conversion asks the same `appliesTo`.
|
||
|
||
Now the three modifiers side by side:
|
||
|
||
| | Baron Rivendare (Module 4) | `BABA IS NOT YOU` (Module 7) | `BABA ON KEY IS WIN` (this module) |
|
||
|---|---|---|---|
|
||
| Read | from a snapshot, taken at scheduled moments | once, when the rules are compiled | every time someone asks, for one object |
|
||
| Changes | how many times a trigger runs | whether a rule is in force | whether a rule applies to this object, now |
|
||
| Between two reads | stale until the next Aura Update | stale until the next parse | never stale |
|
||
|
||
The last row is what a guard buys, and the costs come with it:
|
||
|
||
- **No table to look things up in.** With unconditional rules, an engine can compile "which nouns have which properties" once per parse and answer every question from that table. The rules in force still compile once per parse here, but the answer for a conditional rule is not in them. Every `hasProperty` on a guarded rule runs the guard. A hazard that asks about every object runs every guard about each of them. On the course's small boards the cost is small. On a board with thousands of objects, it would be the place to look first.
|
||
- **The order inside a turn becomes visible.** Before this module, the only state a hazard could see change during the hazards phase was which objects were left. The rules stood still until the turn's last parse. Now any rule can change its meaning mid-phase, because its guard reads the board. The order of the hazards decides what a later hazard sees. Two orders that could not be told apart before can be now (Stress it question 1).
|
||
- **Guards must not change anything.** A condition check that moved an object, or destroyed one, would make the answer to "is Baba MELT?" depend on who asked first. Guarded commands make the same demand: "guards cannot have side effects", which is why "an implementation may evaluate the guards in any sequence" ([Guarded Command Language, Wikipedia](https://en.wikipedia.org/wiki/Guarded_Command_Language)). `on` only reads.
|
||
|
||
The real game pays the same costs, and the Order of Operations page shows where it decided to. Movers are fixed per move stage, and "conditions (e.g. IT ON BABA IS YOU AND MOVE) reparse in between". A `HAS` rule's conditions "are rechecked at the time of destruction, even if multiple destructions occur in the same turn". Each of those is the game writing down exactly when a guard is read, because the answer would otherwise depend on the order nobody wrote down.
|
||
|
||
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. The engine knows the role `condition`; `ON` is in content. The audit flags nouns only, but the rule is the same for condition words: the engine doesn't name them.
|
||
|
||
## Expansion Drop
|
||
|
||
A new word arrives: `NEAR`, a second condition. From the wiki's [NEAR](https://babaiswiki.fandom.com/wiki/NEAR) page: "NEAR is an infix condition that makes a rule apply when an object is on a target or in one of the eight spaces around it". `PILLAR NEAR FLAG IS PINK` makes a pillar pink "if a Flag is ON the Pillar, directly adjacent, or diagonally adjacent".
|
||
|
||
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either. Your word list changes, and so may the rest of your content.
|
||
|
||
1. Commit your workspace and note the commit: `git add -A && git commit -m "module 11 done"`.
|
||
2. Copy the expansion test for your track from `course/11-conditions/expansion/tests/rust/test_11x.rs` or `course/11-conditions/expansion/tests/ts/test_11x.test.ts` into your workspace's `tests/`. Run it and watch it fail.
|
||
3. Add `NEAR` to your word list, and make it do what it says.
|
||
4. When the tests pass, run the check from the course repository:
|
||
|
||
```sh
|
||
node tools/expansion-check.mjs ~/my-engine HEAD
|
||
```
|
||
|
||
The test, `test_11x_near`, writes `KEY NEAR ROCK IS WIN` with one rock and four keys: one on the rock's tile, one beside it, one diagonal to it and one two tiles away. The first three are WIN. The full scenario is in `course/11-conditions/expansion/README.md`.
|
||
|
||
<details><summary>Discussion: what this drop measures</summary>
|
||
|
||
If a condition word's meaning is a check that content registers, `NEAR` is one word in the list and one check, beside `on`:
|
||
|
||
```rust
|
||
/// NEAR: for each noun, another object on the object's tile or on one of the eight
|
||
/// around it that the noun names, a different one for each noun ("on a target or in
|
||
/// one of the eight spaces around it"). Float levels don't matter (the course's
|
||
/// choice: the wiki's NEAR page doesn't mention them).
|
||
fn near(level: &Level, id: ObjectId, nouns: &[String]) -> bool {
|
||
let o = level.board().get(id);
|
||
let others: Vec<ObjectId> = level
|
||
.board()
|
||
.objects()
|
||
.filter(|other| other.id != id && (other.x - o.x).abs() <= 1 && (other.y - o.y).abs() <= 1)
|
||
.map(|other| other.id)
|
||
.collect();
|
||
each_has_one(level, nouns, &others)
|
||
}
|
||
```
|
||
|
||
```ts
|
||
/**
|
||
* NEAR: for each noun, another object on the object's tile or on one of the eight
|
||
* around it that the noun names, a different one for each noun ("on a target or in
|
||
* one of the eight spaces around it"). Float levels don't matter (the course's choice:
|
||
* the wiki's NEAR page doesn't mention them).
|
||
*/
|
||
function near(level: Level, id: ObjectId, nouns: readonly string[]): boolean {
|
||
const o = level.board.get(id);
|
||
const others = level.board
|
||
.objects()
|
||
.filter((other) => other.id !== id && Math.abs(other.x - o.x) <= 1 && Math.abs(other.y - o.y) <= 1)
|
||
.map((other) => other.id);
|
||
return eachHasOne(level, nouns, others);
|
||
}
|
||
```
|
||
|
||
That is the reference answer, in `course/11-conditions/expansion/solution/`: `words.json` with `{ "name": "NEAR", "role": "condition" }` added, and the condition checks with `near` registered under `NEAR`. `near` shares `on`'s helper for "a different object for each noun", so `ROCK NEAR GRASS AND GRASS`, which "requires at least 2 grass" ([NEAR](https://babaiswiki.fandom.com/wiki/NEAR)), works with no extra code.
|
||
|
||
Shapes that fail this drop:
|
||
|
||
- **`ON` in the engine.** If the parser looks for the word `ON`, or `hasProperty` says `if word == "ON"`, then `NEAR` is a second branch under `src/engine/`, and the parser doesn't even read `KEY NEAR ROCK` as a condition. Make conditions a role and a registry, as a normal commit, then redo the drop.
|
||
- **Conditions decided in the parser.** If the parser looked at the board to expand `KEY ON ROCK IS WIN` into facts about particular keys, `NEAR` needs a second expansion in the parser, which is engine code. It also gets Predict question 8 wrong.
|
||
- **Conditions as hard-wired tile checks.** If the engine's `appliesTo` compares positions itself ("same tile"), a condition about neighbours can't be written in content. The engine should know only that a condition is a check that takes the level, the object and the nouns.
|
||
|
||
> **How the real game differs**
|
||
> - `NOT NEAR` reverses the condition, and the page shows that `PILLAR NOT NEAR BABA AND KEKE` differs from `PILLAR NOT NEAR BABA AND NOT NEAR KEKE` ([NEAR](https://babaiswiki.fandom.com/wiki/NEAR)). The course has no `NOT` before a condition.
|
||
> - The NEAR page says nothing about float levels. The course ignores them, as for `ON`.
|
||
> - The game has a related condition, `NEXTTO`, which the NEAR page compares with `NEAR` in a picture. The course doesn't add it.
|
||
|
||
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
|
||
|
||
</details>
|
||
|
||
## Stress it
|
||
|
||
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
|
||
|
||
1. Module 13's table lists mutant-style changes to the engine: drop one of the turn's parses, move the win check before the hazards, run the hazards in reverse, run DEFEAT before HOT/MELT. Make each change in your engine, one at a time, and run all the tests. Which of them does a *Module 11* test catch? Then add two mutants of your own: decide every condition when the rules are parsed (keep the answers in a table until the next parse), and let a negation cancel a rule in the resolution pass whatever its conditions. Which test catches each?
|
||
|
||
<details><summary>Discussion</summary>
|
||
|
||
On the TypeScript reference, with every test from Modules 1–11:
|
||
|
||
| Change | Tests that fail |
|
||
|---|---|
|
||
| No parse between movement and conversion | `test_09_mid_turn_conversion`, `test_10_level_5_solution` |
|
||
| 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`, `test_09_undo_restores_everything` |
|
||
| Win check before hazards | `test_09_defeat_beats_win`, `test_10_level_2_solution` |
|
||
| No last parse | `test_09_last_parse_matches_board` |
|
||
| **Hazards in reverse order** | both `test_09_hazard_order_…` tests, `test_10_level_6_solution`, **`test_11_condition_rechecked_between_hazards`** |
|
||
| DEFEAT before HOT/MELT | `test_09_hazard_order_melt_before_defeat`, `test_10_level_6_solution` |
|
||
| Conditions decided at parse time | `test_11_condition_rechecked_between_hazards` |
|
||
| Negation cancels whatever the conditions | `test_11_conditional_not_only_where_it_holds` |
|
||
|
||
One old mutant gains a new killer: hazards in reverse order. Reversed, HOT/MELT runs before SINK, and Baba is still on the key when HOT/MELT asks. Module 9's Stress it question 2 said the order of SINK and HOT/MELT can't be observed with one hazard property per object, and that "tomorrow's" content would make it visible. This is tomorrow. In the new test, every object has at most one hazard property, and the order shows anyway, because a condition lets one hazard change what another hazard's victim *is*.
|
||
|
||
The new mutants each have one killer. If you deleted `test_11_condition_rechecked_between_hazards`, an engine that decided conditions at parse time would pass every test in the course. The parse after movement already sees Baba on the key, so the follow-the-object test can't tell the two apart. Only a board that changes between a parse and a use can, and in this turn only a hazard changes the board there.
|
||
|
||
And one old mutant is *not* caught by the new conversion test, though it looks as if it should be: dropping the parse between movement and conversion. `test_11_conditional_conversion` pushes a rock onto a key during movement and expects the key to convert. It does, with or without that parse: the rule `KEY ON ROCK IS FLAG` was in force before the move, and its condition is checked when conversion uses it. A parse used to be the only moment the engine looked at the board. With guards, it isn't.
|
||
|
||
</details>
|
||
|
||
2. Write down, for your engine, the order in which `hasProperty` is asked about each object during one turn. Then imagine a condition word whose check itself asks `hasProperty`: `WITH`, say, so that `BABA WITH HOT IS MELT` means "on a tile with something HOT". What could go wrong?
|
||
|
||
<details><summary>Discussion</summary>
|
||
|
||
The order is long, and you have probably never written it down: movement asks about `YOU` for every object, then about `PUSH`, `STOP`, `OPEN` and `SHUT` for each object on each tile a mover steps to; conversion asks every rule about every object; each hazard asks about its victim property for every object, and about its killer property and `FLOAT` for each one's company; the win check asks about `YOU`, `WIN` and `FLOAT`. Before this module, the answer to each of those questions was fixed between parses, so the order didn't matter. Now it can, and the order is an accident of how each phase happens to loop.
|
||
|
||
A condition whose check calls `hasProperty` makes it worse. Content could write a word `WITH`, as in `BABA WITH HOT IS MELT`, meaning "on the same tile as something HOT". Its check asks `hasProperty(other, "HOT")`, and if `LAVA WITH MELT IS HOT` is also on the board, that asks `hasProperty(baba, "MELT")`, which runs the first check again. Nothing stops the loop. This is the case Module 7's Stress it question 2 warned about: a rule whose applicability depends on the resolved rule set. The game has words of this kind (the Conditions page lists `FEELING`, which "uses a property in noun2"), so it must break such loops somehow; no page read for the course says how. An engine has three choices: forbid checks from asking about properties, detect the cycle and pick an answer, or evaluate in strata so that a check only reads properties decided before it. The reference takes the first, by convention: `on` reads only kinds and positions. Nothing in the engine enforces it.
|
||
|
||
</details>
|
||
|
||
3. A conditional rule in SpellGrid: `DEATHRATTLE ON KEY IS DOUBLE`. The reference gives it no aura. Argue for a different design, in which a Deathrattle triggers twice if *the minion that has it* stands on a key. What would the seam need to know that it doesn't now, and when would it have to ask?
|
||
|
||
<details><summary>Discussion</summary>
|
||
|
||
It is a reasonable reading of the sentence: `DEATHRATTLE` names a trigger, and a trigger belongs to a minion, which is a grid object with a tile. To support it, seam 1 could no longer compile the rules into a fixed list of auras after each reparse. It would have to hand Part I something that answers per minion: "for this minion's Deathrattle, how many times?", with the minion's id. Part I's `activations` would then ask the grid during a Sequence.
|
||
|
||
The question of *when* comes straight back. Part I reads auras from a snapshot taken at the Aura Update, on purpose (Module 4). A dying minion is still on the grid until seam 3 removes it after the Sequence. So "is the dying Warden on a key?" has an answer when the Deathrattle starts, but the answer comes from a different engine with a different schedule. You would have to decide whether a conditional rule is read like an aura (at the snapshot) or like a guard (when the Deathrattle starts), and Module 10's Level 3 is the proof that a snapshot and a live reading can disagree. The reference refuses the rule rather than pick. That is the cheap answer, and it is labelled as the course's choice. A level designer who wanted the other one would be choosing a new seam.
|
||
|
||
</details>
|
||
|
||
## Further reading
|
||
|
||
- The Baba Is You Wiki's [ON](https://babaiswiki.fandom.com/wiki/ON), [NEAR](https://babaiswiki.fandom.com/wiki/NEAR) and [Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions) pages: every condition word in the game, `NOT` before conditions, several conditions in one sentence, and how the game matches nouns to objects. Then reread [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) and mark every line that says when a condition is checked.
|
||
- [Guarded Command Language, Wikipedia](https://en.wikipedia.org/wiki/Guarded_Command_Language): Dijkstra's guards, the original form of a statement that runs only if its condition holds, and why guards must have no side effects.
|
||
- [Datalog, Wikipedia](https://en.wikipedia.org/wiki/Datalog), on stratified negation, again. Module 7's resolver could stratify because no rule's applicability depended on the board. A condition that asks about properties (Stress it question 2) is where stratification starts to matter.
|