40 KiB
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.
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 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 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 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 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.
KEY ON ROCK IS WIN. One key lies on a rock, another key lies alone. Which keys are WIN? Is the rock?BABA IS YOUandBABA ON KEY IS NOT YOU. One Baba stands on a key, another on an empty tile. Which of them moves? What doesactiveRules()list?- Add
BABA ON KEY IS YOUto the board of question 2. Does anything change? What doesactiveRules()list now? BABA IS YOU,LAVA IS HOTandBABA 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?KEY ON ROCK AND ROCK IS WIN, with one key on a single rock. WIN?BABA ONthen a tile holding two stacked words,KEYandROCK, thenIS WIN. How many rules is that, and what are they?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?- The board at the end of Observe. Does Baba survive? Would it in an engine that decided every condition when it parsed the rules?
- Baron is read from a snapshot;
NOTis read when the rules are compiled. In your engine, where would you readON, and when?
Discussion
-
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 spellROCK IS WIN, which is what your Module 10 parser reads from it. -
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 YOUapplies.activeRules()lists both rules (andTEXT IS PUSH). Module 7's resolution pass would have cancelledBABA IS YOUhere, 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. -
Nothing changes on the board.
BABA ON KEY IS NOT YOUcancelsBABA ON KEY IS YOUexactly asBABA IS NOT YOUcancelsBABA IS YOU: same subject, same complement, and now also the same condition. SoactiveRules()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. -
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
ONreads the board. -
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.
-
Two rules:
BABA ON KEY IS WINandBABA 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 notBABA ON KEY AND ROCK IS WIN, which would need both. -
In the course, yes, both: each is on the other.
ALLnames the rock itself too, butONasks about another object. The third rock isn't WIN:ALLdoesn't name text. The course readsON ALLas "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). -
It survives, if conditions are checked when they are used. In an engine that decided
BABA ON KEYduring 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. -
There is no single place. Baron's count was read by one routine at the moment a trigger ran.
NOTwas 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.
Spec
From the course repository, copy the module's tests into your workspace:
node tools/start-module.mjs 11 ~/my-engine
The harness
No new functions. The contract of four old ones grows:
rules()andactiveRules()write a condition between the subject andIS, 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 askhasProperty, 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, andONstacked onAND, 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:
#[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"]);
}
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.
ONgoes in your word list with the rolecondition. 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 touchsrc/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 (
ANDthere 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.
ONis 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.
hasPropertyand 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 YcancelsX ON Z IS Yin the resolution pass, as before. It doesn't cancelX IS Y: that one is settled for each object, among the rules that apply to it.hasPropertyalready 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()writesKEY ON ROCK AND FLAG IS WIN.
Hint 1 — nudge
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?
Hint 2 — concept
- Parsing. The run of nouns before
ISends at a tile that holds noAND(or aNOT). 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 anANDon 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. InhasProperty, filter the active rules by complement and byappliesTo, 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.
Hint 3 — pseudo-code
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
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.
Levelrefuses to start if a word with roleconditionhas 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 WINneeds a second rock. The ON page says "overlapping another object"; the course reads that as excluding the object itself, and applies the same reading toALL. - 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 KEYon a tile with a key and a rock holds: the rock stands forALLand the key forKEY, 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 YOUcancelsBABA ON ROCK AND KEY IS YOU. They are true of exactly the same objects.rules()still prints each as written. ONstacked onANDreads both ways.FLAG [ON AND] KEY IS PUSHisFLAG ON KEY IS PUSH, and alsoFLAG IS PUSHandKEY 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 DOUBLEgives no aura. A condition is about one object on the grid, and a trigger word names no object. Module 10'sruleAuras/rule_aurasskips rules with conditions. - Who moves is decided once per movement phase. Module 8's movement collects the
YOUobjects when it starts. A conditionalYOUthat becomes true halfway through the moves doesn't add a mover. The responses (PUSH,STOP,OPEN/SHUT) askhasPropertyas 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
NOTbefore a condition. "Conditions can be negated by prefixing with NOT" (Conditions), andNOTbefore a condition's noun names everything else (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 WINandBABA ON NOT KEY IS WINspell nothing, asNOT BABA IS YOUspells 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 asLONELYandPOWERED, "parsed as condition noun verb thing" (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 secondONstops at the first one.- Matching nouns to objects. For
NOTamong 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 noNOTthere and searches every assignment.ALLafter a condition. In the game, "ALL in the noun2 part requires one of every object in ALL", andBABA ON ALL AND ALL AND FLAG IS WINneeds Baba on two of everything in ALL, "not counting itself", plus a flag (Conditions). The course'sON ALLmeans any one other object that isn't text, the wayALLnames objects as a subject. That simpler meaning is the course's choice, andtest_11_on_group_nounpins it.LEVEL ON ROCK, and the level icon's position (ON). The course has noLEVEL.- When a condition is checked. The game checks conditions "usually right before it uses it" (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
NOTagainst 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 withROCK IS PUSHelsewhere 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), which the game sets by placement on the grid (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). 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?":
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))
}
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
hasPropertyon 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).
ononly 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 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.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 11 done". - Copy the expansion test for your track from
course/11-conditions/expansion/tests/rust/test_11x.rsorcourse/11-conditions/expansion/tests/ts/test_11x.test.tsinto your workspace'stests/. Run it and watch it fail. - Add
NEARto your word list, and make it do what it says. - When the tests pass, run the check from the course repository:
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.
Discussion: what this drop measures
If a condition word's meaning is a check that content registers, NEAR is one word in the list and one check, beside on:
/// 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)
}
/**
* 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), works with no extra code.
Shapes that fail this drop:
ONin the engine. If the parser looks for the wordON, orhasPropertysaysif word == "ON", thenNEARis a second branch undersrc/engine/, and the parser doesn't even readKEY NEAR ROCKas 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 WINinto facts about particular keys,NEARneeds 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
appliesTocompares 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 NEARreverses the condition, and the page shows thatPILLAR NOT NEAR BABA AND KEKEdiffers fromPILLAR NOT NEAR BABA AND NOT NEAR KEKE(NEAR). The course has noNOTbefore 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 withNEARin 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.
Stress it
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
- 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?
Discussion
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.
- Write down, for your engine, the order in which
hasPropertyis asked about each object during one turn. Then imagine a condition word whose check itself askshasProperty:WITH, say, so thatBABA WITH HOT IS MELTmeans "on a tile with something HOT". What could go wrong?
Discussion
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.
- 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?
Discussion
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.
Further reading
- The Baba Is You Wiki's ON, NEAR and Conditions pages: every condition word in the game,
NOTbefore conditions, several conditions in one sentence, and how the game matches nouns to objects. Then reread Order of Operations and mark every line that says when a condition is checked. - Guarded Command Language, Wikipedia: 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, 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.