Files
systemic-game-design-course/course/11-conditions/lesson.md
T

40 KiB
Raw Blame History

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.

  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?
Discussion
  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.

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() 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:

#[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. 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.
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 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.
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. 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), and NOT before 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 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). 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). 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). The course has no LEVEL.
  • 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 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), 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 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). 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 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:
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:

  • 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). 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.

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?
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.

  1. 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?
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.

  1. 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, NOT before 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.