docs: accuracy pass
CI / Tool tests and sync-check (push) Successful in 13s
CI / Verify references (all modules, Rust and TS) (push) Successful in 1m29s
CI / Coupling audit on references (push) Successful in 13s
CI / Site build (push) Successful in 22s
CI / Naive engine tests (Module 0) (push) Successful in 32s
CI / Tool tests and sync-check (push) Successful in 13s
CI / Verify references (all modules, Rust and TS) (push) Successful in 1m29s
CI / Coupling audit on references (push) Successful in 13s
CI / Site build (push) Successful in 22s
CI / Naive engine tests (Module 0) (push) Successful in 32s
Re-verify Teikari article quotes and GDC speaker, soften unsourced claims (lessons 01, 06, 07, 10), add course-simplification bullets to lesson 07's differs box, qualify OPEN obstruction wording in research, and add tools/check-links.mjs (report-only) with npm run check:links. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1c2fb2eb07
commit
0700cac492
@@ -10,11 +10,11 @@ Acolyte of Pain is a 3-mana 1/4 ([Acolyte of Pain, hearthstone.wiki.gg](https://
|
||||
|
||||
> Whenever this minion takes damage, draw a card.
|
||||
|
||||
You have two Acolytes of Pain on your board. Call them A and B. You played A first. B sits to the left of A. You cast Whirlwind: "Deal 1 damage to ALL minions." ([Whirlwind](https://hearthstone.wiki.gg/wiki/Whirlwind)).
|
||||
You have two Acolytes of Pain on your board. Call them A and B. You played A first. For this exercise, suppose B stands to the left of A. That is a setup the course chooses, not a claim about how the game places minions; the next paragraph shows one sourced way the newest minion can end up left of an older one. You cast Whirlwind: "Deal 1 damage to ALL minions." ([Whirlwind](https://hearthstone.wiki.gg/wiki/Whirlwind)).
|
||||
|
||||
Both Acolytes take damage and both draw a card. A's trigger goes first, even though B is further left. When Hearthstone orders triggers, it does not ask where a minion stands. It asks when the minion entered play. The Advanced rulebook's Rule 2a: "A Queue is created and filled with all triggers that can respond, in order of play." And its definition: "Order of play means the order the Entities each Event/trigger is associated with entered play, from oldest to newest." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Area damage follows the same order: "all of the Damage/Healing Events are created in play order" (same source).
|
||||
Both Acolytes take damage and both draw a card. By the rule quoted next, A's trigger goes first, even though B is further left. When Hearthstone orders triggers, it does not ask where a minion stands. It asks when the minion entered play. The Advanced rulebook's Rule 2a: "A Queue is created and filled with all triggers that can respond, in order of play." And its definition: "Order of play means the order the Entities each Event/trigger is associated with entered play, from oldest to newest." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Area damage follows the same order: "all of the Damage/Healing Events are created in play order" (same source).
|
||||
|
||||
Board position and order of play really do come apart in the game. Harvest Golem says "Deathrattle: Summon a 2/1 Damaged Golem." ([Harvest Golem](https://hearthstone.wiki.gg/wiki/Harvest_Golem)), and Deathrattle summons appear "in the board position the Death Event remembered from when it was created" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The Damaged Golem is the newest minion on the board, standing wherever the old Golem stood, possibly at the far left.
|
||||
Board position and order of play really do come apart in the game. Harvest Golem says "Deathrattle: Summon a 2/1 Damaged Golem." ([Harvest Golem](https://hearthstone.wiki.gg/wiki/Harvest_Golem)), and Deathrattle summons appear "in the board position the Death Event remembered from when it was created" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The Damaged Golem is the newest minion on the board, standing wherever the old Golem stood. So if the old Golem stood at the far left, the newest minion stands there too. That example is the course's reading of the quoted rule; the rulebook doesn't spell it out.
|
||||
|
||||
So the engine has to remember two different orders for the same minions: where they stand, and when they arrived.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Module 6: Grid, lexer and parser
|
||||
|
||||
This is the first module of Part II. In Part I the rules lived inside the cards: Harvest Golem's Deathrattle was a clause on Harvest Golem, and your engine's job was to find the right clause at the right moment and run it. Part II turns that around. In Baba Is You the objects on the board have no behaviour at all. A rock is just a rock until some text on the board says `ROCK IS PUSH`. Every rule comes from words lying on the grid, and the words can be pushed around, so the rules change while you play. The engine's job is now to *compile* the rules from the board, and to compile them again every time the board changes.
|
||||
This is the first module of Part II. In Part I the rules lived inside the cards: Harvest Golem's Deathrattle was a clause on Harvest Golem, and your engine's job was to find the right clause at the right moment and run it. Part II turns that around. In Baba Is You the objects on the board have no behaviour of their own. A rock is just a rock until some text on the board says `ROCK IS PUSH`: "When three blocks in a row make a sentence, like "ROCK IS PUSH," it creates a rule that immediately takes effect in-game", and "If you push one of the words in a ruleset out of line, that effect instantly ceases" ([Game Developer](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)). Apart from a few base rules that hold with no text, `TEXT IS PUSH` among them ([Rule](https://babaiswiki.fandom.com/wiki/Rule)), every rule comes from words lying on the grid, and the words can be pushed around, so the rules change while you play. The engine's job is now to *compile* the rules from the board, and to compile them again every time the board changes.
|
||||
|
||||
This module builds the compiler's front end: a grid that holds objects and text, a lexer that turns text into words with grammatical roles, and a parser that turns words into rules. Nothing moves yet (that is Module 8), and no rule does anything yet (Modules 7 and 9). By the end, `rules()` reads any board you build.
|
||||
|
||||
@@ -24,7 +24,7 @@ Sentences can cross. The [Esolangs wiki](https://esolangs.org/wiki/Baba_Is_You)
|
||||
|
||||
> BABA AND KEKE IS PUSH AND STOP = BABA IS PUSH, BABA IS STOP, KEKE IS PUSH, KEKE IS STOP
|
||||
|
||||
Text can share a tile. The [Rule](https://babaiswiki.fandom.com/wiki/Rule) page: "If more than one parsable object is stacked, the game will attempt to produce all possible rules." The game's designer, Arvi "Hempuli" Teikari, told Game Developer that this was one of the hard parts: "the stacking adds the need to go through all the possible permutations a rule could have" ([Designing Baba Is You's delightfully innovative rule-writing system](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)).
|
||||
Text can share a tile. The [Rule](https://babaiswiki.fandom.com/wiki/Rule) page: "If more than one parsable object is stacked, the game will attempt to produce all possible rules." The game's designer, Arvi "Hempuli" Teikari, told Game Developer that reworking the rule system for stacked words "turned out to be one of the larger undertakings over the game's development, because the stacking adds the need to go through all the possible permutations a rule could have" ([Designing Baba Is You's delightfully innovative rule-writing system](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)).
|
||||
|
||||
Some rules have no text at all. Again from [Rule](https://babaiswiki.fandom.com/wiki/Rule):
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ The IS page puts it more briefly: "BABA IS NOT BABA causes BABA to vanish in a p
|
||||
|
||||
Base rules are not exempt. The [Rule](https://babaiswiki.fandom.com/wiki/Rule) page says the base rules, `TEXT IS PUSH` among them, "can still be detected by FEELING and MIMIC and negated by NOT and BROKEN", and the [Advanced rulebook](https://babaiswiki.fandom.com/wiki/Advanced_rulebook) relies on it: "Text can be stacked if TEXT IS NOT PUSH can be formed with another way to move text".
|
||||
|
||||
Finally, where the game does this. Teikari, in the Game Developer article ([Designing Baba Is You's delightfully innovative rule-writing system](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)), describes the last step of building the rules: "Finally, once everything is (hopefully) set, the game goes through the final set of rules and adjusts them if needed." And his example: "(For example, if the rule "Baba Is Not You" exists, "Baba Is You" is marked as being not valid.)" <!-- Task 17: these two quotes are fetch-tool excerpts; re-verify against the page. -->
|
||||
Finally, where the game does this. Teikari, in the Game Developer article ([Designing Baba Is You's delightfully innovative rule-writing system](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)), describes the last step of building the rules: "Finally, once everything is (hopefully) set, the game goes through the final set of rules and adjusts them if needed." And Teikari's example: "(For example, if the rule "Baba Is Not You" exists, "Baba Is You" is marked as being not valid.)"
|
||||
|
||||
Notice what he says and what he doesn't. The parser doesn't drop `BABA IS YOU` when it meets a `NOT`. The rule is read, kept, and *marked* not valid, in a pass over the final set.
|
||||
Notice what the article says and what it doesn't. The adjustment happens in a pass over "the final set of rules", after the rules are built, and `BABA IS YOU` is "marked as being not valid" there. The article doesn't describe the data structures beyond that. The course takes the quote to mean that the parser reads `BABA IS YOU` like any other rule, and a later pass over the whole set decides that it doesn't apply. That is the course's reading, and it is the design you'll build.
|
||||
|
||||
## Predict
|
||||
|
||||
@@ -78,7 +78,7 @@ Two new functions on `Baba`, and one change:
|
||||
| `hasProperty(id, prop): boolean` | `has_property(&self, id: Id, prop: &str) -> bool` |
|
||||
|
||||
- `rules()` still lists everything the text spells, before resolution. It now includes negated rules, written `"BABA IS NOT YOU"`.
|
||||
- `activeRules()` lists the rules in force after resolution, in the same format: sorted, each distinct rule once, `"TEXT IS PUSH"` included unless `TEXT IS NOT PUSH` cancels it.
|
||||
- `activeRules()` lists the rules in force after resolution, in the same format: sorted, each distinct rule once, `"TEXT IS PUSH"` included unless `TEXT IS NOT PUSH` cancels it. Negated rules stay in it: a negated rule is in force, because it keeps overriding (the tests check this, for example `["BABA IS NOT YOU", "TEXT IS PUSH"]`).
|
||||
- `hasProperty(id, prop)` answers for one object. A thing is named by its noun (a `baba` object by `BABA`); a text object is named by `TEXT`.
|
||||
|
||||
### The tests
|
||||
@@ -216,7 +216,7 @@ When your tests pass, compare with the reference engine in `course/07-rule-resol
|
||||
|
||||
Decisions in the reference that the tests don't force:
|
||||
|
||||
- **`activeRules()` keeps negated rules.** A negated rule is in force: it keeps overriding. That also makes `BABA IS NOT BABA` appear in `activeRules()`, and that entry is the reference's record that every Baba is to vanish. The sources say Baba vanishes; they don't say how an engine should record it, so the test doesn't check this.
|
||||
- **`BABA IS NOT BABA` in `activeRules()` is the deletion record.** The tests do force negated rules to stay in `activeRules()` (see the harness above: a negated rule is in force and keeps overriding). They don't check that `BABA IS NOT BABA` is listed, even though the reference lists it as its record that every Baba is to vanish. The sources say Baba vanishes; they don't say how an engine should record it.
|
||||
- **`X IS NOT Y` with `Y` a noun is just a negation.** It cancels `X IS Y` and nothing else, as the NOT page says of `ROCK IS NOT GRASS`.
|
||||
- **Negation is settled twice.** Once per rule in the resolution pass (`activeRules()` drops `BABA IS YOU`), and once per object in `hasProperty`. Only the second matters when the subjects differ, which the Expansion Drop shows.
|
||||
- **Which objects a noun names is a fact about the word.** The reference's word list has an optional field, `"selects"`: `"named"` (the default: the noun's own things), `"text"` (every text object) or `"things"` (every object that isn't text). The engine registers `TEXT` with `"text"`. `Lexicon::names` / `Lexicon.names` reads the field.
|
||||
@@ -229,6 +229,8 @@ Decisions in the reference that the tests don't force:
|
||||
> - `X IS NOT X` makes X disappear "similarly to X IS EMPTY but with particles" (same page). The course has no `EMPTY`, and the disappearance waits for Module 9's turn.
|
||||
> - Base rules can also be negated by `BROKEN` ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). The course has no `BROKEN`.
|
||||
> - `BABA IS BABA` blocks every conversion of Baba, "including those not in ALL" ([IS](https://babaiswiki.fandom.com/wiki/IS)). The course has no conversion into `ALL`, and the wiki's [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) gives `X IS ALL` its own place in the turn ("X IS ALL takes the lowest priority").
|
||||
> - Course simplification: recording the deletion. The course keeps `X IS NOT X` in `activeRules()` as the record that every X is to vanish. The sources say X disappears ([NOT](https://babaiswiki.fandom.com/wiki/NOT)); none read for the course says how the game records that, so this record is the course's choice, not the game's.
|
||||
> - Course simplification: what `ALL` names (Expansion Drop). The course's `ALL` names every object on the board that isn't text. The [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) page says only that "TEXT is not included in ALL, but is included in NOT ALL"; the sources the course recorded don't define the rest of what `ALL` covers in the real game, so "every non-text object" is the course's rule, not a description of the game.
|
||||
|
||||
## Name it
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Write your answers down before you open the discussion, for example in `notes/10
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
1. **Two.** The rule's multiplier and the Warden's are the same kind of data, a trigger multiplier with `stacking: "max"`, so together they double and no more. Module 4 needed `stacking` because Hearthstone's doublers don't stack ("max") while the course's Test Chorus does ("additive"). SpellGrid's choice is that a rule on the grid is one more doubler like Baron, so it takes "max" too. Had it been "additive", the Warden and the rule would give three activations (1 + 1 + 1), not four: the course's additive stacking adds each multiplier's extra activations.
|
||||
1. **Two.** The rule's multiplier and the Warden's are the same kind of data, a trigger multiplier with `stacking: "max"`, so together they double and no more. Module 4 needed `stacking` because Baron doesn't stack ("max": "Rivendare's ability is not duplicated or multiplied by having more than one Baron Rivendare in play", [Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)) while the course's Test Chorus does ("additive"). SpellGrid's choice is that a rule on the grid is one more doubler like Baron, so it takes "max" too. Had it been "additive", the Warden and the rule would give three activations (1 + 1 + 1), not four: the course's additive stacking adds each multiplier's extra activations.
|
||||
2. **Once.** In Module 7, `X IS NOT Y` overrides `X IS Y` wherever it is written. SpellGrid gives the negated rule the same reach: `DEATHRATTLE IS NOT DOUBLE` caps Deathrattles at one activation, whatever multiplies them, card or rule. The rule `DEATHRATTLE IS DOUBLE` is cancelled by Module 7's resolution pass and never becomes a modifier; the Warden's aura is cancelled by the cap. Both are SpellGrid's choices, not a real game's.
|
||||
3. **Yes, with 1 Health.** Whirlwind deals 1 to everything: the knight goes to 2, the keg and the Warden to 0. They leave play in the same Death Creation Step. The Aura Update runs after the step, the Warden's aura is gone, and the keg's Deathrattle deals 1 once: the knight goes to 1. This is Module 4's Twisting Nether case with damage instead of a draw. An engine that reads the auras before the dead leave play doubles the Deathrattle, and the knight dies. Level 3 is built on that.
|
||||
4. **The same turn.** The scribe's death is a Sequence inside the turn's hazards phase. When the Sequence ends, `WIN` is written and the rules are reparsed, before the next hazard and before the win check. An engine that waits for the turn's own parse finds `BABA IS WIN` only after the win check, and wins a turn late.
|
||||
@@ -394,7 +394,7 @@ The differences are where the design is:
|
||||
|
||||
- **When the modifier is read.** Baron is read from a snapshot taken at fixed points (after a Death Creation Step, on a summon), so a Baron that dies mid-Phase still counts until the next update, and one that dies in the same step as the Deathrattle doesn't count. A Baba rule is read after a reparse, and the course reparses at fixed points in the turn. Both engines answer "when is the world looked at?" with a schedule, not with "whenever someone asks". Level 3 and Module 9's lava test are the same lesson, one per engine.
|
||||
- **What it changes.** Baron changes *how many times* something happens: a number, combined with other numbers by `stacking`. `NOT` changes *whether* a rule is in force: a yes or no, and no wins. That is why, in one game, the grid's negation became a cap: a yes/no from Part II meeting a count in Part I.
|
||||
- **Who wins a disagreement.** Two Barons don't stack (`max`); `X IS NOT Y` beats any number of `X IS Y`. Hearthstone has no "not doubled" card to disagree with Baron, so SpellGrid had to choose, and chose Baba's answer: negation beats every multiplier, from cards or from the grid.
|
||||
- **Who wins a disagreement.** Two Barons don't stack (`max`); `X IS NOT Y` beats any number of `X IS Y`. None of the Hearthstone cards the course uses can cancel Baron's doubling (the course hasn't surveyed the whole card pool), so SpellGrid had to choose, and chose Baba's answer: negation beats every multiplier, from cards or from the grid.
|
||||
|
||||
What SpellGrid had to decide: that a rule on the grid is the same kind of data as a card's aura (so `stacking` applies, and Warden + `DOUBLE` is two); that it enters the snapshot at the same moments as a card's (so it obeys the Aura Update schedule); and that `NOT` from the grid caps card multipliers too. None of those came from either game. Each is one line of data or one line in `activations`, and each is a test.
|
||||
|
||||
|
||||
@@ -31,15 +31,17 @@ Researched 2026-10-28. Every behavioral claim below cites a page that was actual
|
||||
- **Course use:** a good example of how to represent rules as data and pin behavior with tests. Do **not** use it as an authority on official game semantics. It does not implement IS NOT for properties or nouns, it hard-codes text as pushable, and its FLOAT is partial.
|
||||
|
||||
### 2. Game Developer: "Designing Baba Is You's delightfully innovative rule-writing system"
|
||||
- URL: https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system (**loaded** via WebFetch)
|
||||
- **Summary.** Written by John Harris, published May 8, 2019. Arvi "Hempuli" Teikari describes how rules are rebuilt: the game rebuilds only when text has changed, clears the old rules, detects candidate sentence starts, and resolves conflicts such as NOT at the end. He says stacked text was one of the hardest parts of development and explains why he used Lua on top of Multimedia Fusion 2 (MMF2).
|
||||
- Quotes (excerpts returned by the fetch tool):
|
||||
- URL: https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system (**loaded** via WebFetch; **re-verified 2026-10-29 (Task 17)** by downloading the page HTML with curl: every quote below and in lessons 06/07 appears verbatim in the article text)
|
||||
- **Summary.** Written by John Harris, published May 8, 2019. Arvi "Hempuli" Teikari describes how rules are rebuilt: the game rebuilds only when text has changed, clears the old rules, detects candidate sentence starts, and resolves conflicts such as NOT at the end. Teikari says reworking the rule system for stacked text was "one of the larger undertakings over the game's development" and explains the choice of Lua on top of Multimedia Fusion 2 (MMF2). (The article does not state Teikari's pronouns; the course uses the name.)
|
||||
- Quotes (first taken from fetch-tool excerpts; all confirmed verbatim against the page HTML in Task 17):
|
||||
- "The game only updates the rules if it needs to"
|
||||
- "The previous rules are removed entirely, and words that could potentially start rule sentences are detected on the level."
|
||||
- "Finally, once everything is (hopefully) set, the game goes through the final set of rules and adjusts them if needed."
|
||||
- "(For example, if the rule "Baba Is Not You" exists, "Baba Is You" is marked as being not valid.)"
|
||||
- "the stacking adds the need to go through all the possible permutations a rule could have"
|
||||
- "Lua allowed me to implement the game logic in ways MMF2's base "event system" couldn't have done without a lot of hassle"
|
||||
- "turned out to be one of the larger undertakings over the game's development, because the stacking adds the need to go through all the possible permutations a rule could have" (added Task 17; lesson 06 quotes this fuller sentence)
|
||||
- From Harris's introduction, not Teikari (added Task 17): "When three blocks in a row make a sentence, like "ROCK IS PUSH," it creates a rule that immediately takes effect in-game" and "If you push one of the words in a ruleset out of line, that effect instantly ceases."
|
||||
|
||||
### 3. Baba Is You Wiki: Order of Operations
|
||||
- URL: https://babaiswiki.fandom.com/wiki/Order_of_Operations
|
||||
@@ -91,9 +93,9 @@ All pages were loaded as wikitext via `https://babaiswiki.fandom.com/api.php?act
|
||||
|---|---|---|
|
||||
| PUSH | loaded | "PUSH is a property that makes an object pushable by any moving object. PUSH also makes the object "solid" for collision checks like STOP does." / "TEXT IS PUSH as a base rule." / "If something tries to move into a PUSH object, it will try to move in the same direction to make room. ** This includes other PUSH objects. ** This ignores float levels. ** This movement is instant" |
|
||||
| STOP | loaded | "STOP is a property that prevents most forms of movement from moving into the object." / "Some movement checks also override STOP to allow a movement, including EAT OPEN SHUT WEAK." / "FLOAT does not allow bypassing STOP." |
|
||||
| OPEN | loaded | "If an OPEN object is on, or moved onto, a same-float SHUT object (or vice-versa), both are removed." / "The movement check applies to standard forms of movement, including FEAR AUTO CHILL FALL MOVE NUDGEDOWN PULL PUSH SHIFT YOU YOU2 3D." / "If the movement check succeeds in finding an "unlock", the normal obstruction of movement by STOP PUSH PULL does not apply. *** However, other obstructing objects may still prevent the movement, and this will also prevent the destruction from OPEN SHUT since no movement actually occurs." / "Destruction happens before the moving object actually moves onto the other object, therefore anything it HAS will spawn on the space the object was moving from." / "Only one SHUT object is destroyed per OPEN object (and vice versa), based on the first object in update order." / "If both the OPEN and SHUT objects are SAFE, the check fails. If only one is, the check succeeds and the appropriate object is destroyed." |
|
||||
| OPEN (quotes used by Module 8 re-verified in Task 17 against the MediaWiki API wikitext) | loaded | "If an OPEN object is on, or moved onto, a same-float SHUT object (or vice-versa), both are removed." / "The movement check applies to standard forms of movement, including FEAR AUTO CHILL FALL MOVE NUDGEDOWN PULL PUSH SHIFT YOU YOU2 3D." / "If the movement check succeeds in finding an "unlock", the normal obstruction of movement by STOP PUSH PULL does not apply. *** However, other obstructing objects may still prevent the movement, and this will also prevent the destruction from OPEN SHUT since no movement actually occurs." / "Destruction happens before the moving object actually moves onto the other object, therefore anything it HAS will spawn on the space the object was moving from." / "Only one SHUT object is destroyed per OPEN object (and vice versa), based on the first object in update order." / "If both the OPEN and SHUT objects are SAFE, the check fails. If only one is, the check succeeds and the appropriate object is destroyed." |
|
||||
| SHUT | loaded | "If a SHUT object is on, or moved onto, a same-float OPEN object (or vice-versa), both are removed. As OPEN and SHUT are a pair, see OPEN for details." |
|
||||
| PULL (loaded 2026-10-29, for Module 8) | loaded | "PULL is a Property that makes an object pullable by any moving object. PULL also makes the object "solid" for collision checks like STOP does." / "If something directly adjacent from a PULL object moves away from it, the PULL object will try to move in the same direction." / "If a PULL object is being PULLed by a PULL movement, it can move through solid objects (though will still PUSH things)." / "If LEVEL IS PULL, the overall level can be pulled at the border, rotating it internally but not visually." / "If the same PULL object is being PULLed in multiple ways in the same movement stage, the result is based on update order." |
|
||||
| PULL (loaded 2026-10-29, for Module 8; quotes re-verified in Task 17 against the MediaWiki API wikitext) | loaded | "PULL is a Property that makes an object pullable by any moving object. PULL also makes the object "solid" for collision checks like STOP does." / "If something directly adjacent from a PULL object moves away from it, the PULL object will try to move in the same direction." / "If a PULL object is being PULLed by a PULL movement, it can move through solid objects (though will still PUSH things)." / "If LEVEL IS PULL, the overall level can be pulled at the border, rotating it internally but not visually." / "If the same PULL object is being PULLed in multiple ways in the same movement stage, the result is based on update order." |
|
||||
| HOT | loaded | "HOT is a property that causes the object to destroy any same-float MELT objects it contacts." / "An object that is both HOT and MELT destroys itself." |
|
||||
| MELT | loaded | "MELT is a property that causes the object to be destroyed if contacting any same-float object that is HOT." / "MELT obeys HAS and SAFE as expected." |
|
||||
| SINK | loaded | "SINK is a property that causes an object to destroy all overlapping same-float objects along with itself when there is at least one overlapping object." / "A stack of two or more SINK objects will mutually SINK each other." / "SINKing order: Overlapping objects are destroyed (unless SAFE) in priority order, then the SINK object itself." / "SINK has no movement check, only an overlap check." |
|
||||
@@ -115,8 +117,8 @@ All pages were loaded as wikitext via `https://babaiswiki.fandom.com/api.php?act
|
||||
|
||||
### 6. GDC talk: "Reading the Rules of Baba Is You"
|
||||
- URL: https://www.youtube.com/watch?v=Jf5O8S5GiOo
|
||||
- **Existence confirmed** via YouTube oEmbed (https://www.youtube.com/oembed?url=...Jf5O8S5GiOo): `"title":"Reading the Rules of Baba Is You","author_name":"GDC Festival of Gaming"` (the GDC channel). WebFetch of the watch page also returned the title "Reading the Rules of Baba Is You."
|
||||
- **Speaker**: WebSearch results (a Game Developer video post and gdconf.com news) describe it as Arvi Teikari's GDC 2020 Design-track talk about turning in-level objects into sentences the game logic can read, and about building with a non-programming tool plus Lua. The gdconf.com news page returned 404 when fetched directly, so the speaker attribution rests on search snippets and was not read on a page. The video itself was not watched, and no claim in this document relies on it.
|
||||
- **Existence confirmed** via YouTube oEmbed (https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=Jf5O8S5GiOo&format=json): `"title":"Reading the Rules of Baba Is You","author_name":"GDC Festival of Gaming"` (the GDC channel). WebFetch of the watch page also returned the title "Reading the Rules of Baba Is You."
|
||||
- **Speaker** (confirmed 2026-10-29, Task 17): the Game Developer post [Video: Understanding the rules of Baba Is You](https://www.gamedeveloper.com/design/video-understanding-the-rules-of-i-baba-is-you-i-) (April 14, 2020), loaded with curl, says: "In this GDC 2020 virtual talk Arvi Teikari, developer of the innovative and award-winning puzzle game Baba Is You, describes how the game's unique sentence-based rule system was implemented." The post points to the official GDC YouTube channel; its HTML does not contain the video id, so the match to `Jf5O8S5GiOo` rests on the title and channel. The gdconf.com news page still returns 404. The video itself was not watched, and no claim in this document relies on it. No lesson cites the talk.
|
||||
|
||||
---
|
||||
|
||||
@@ -197,7 +199,7 @@ Inference status: this follows directly from the documented order. The wiki give
|
||||
- The key's push counts as unobstructed, so Baba's move succeeds and Baba moves one tile, onto the key's *former* tile. Baba does not reach the door's tile that turn. (This is an inference from the documented rules; the wiki does not spell out the pusher's final position.)
|
||||
- Anything the key HAS spawns on the key's former tile.
|
||||
- Only one SHUT is destroyed per OPEN in a movement check.
|
||||
- The interaction fails if the two are on different float levels, if both are SAFE, or if another obstructing object is on the door tile.
|
||||
- The interaction fails if the two are on different float levels or if both are SAFE. The OPEN page adds that "other obstructing objects may still prevent the movement", without listing which objects count; see the course reading below for how the course interprets a separate STOP object on the door tile.
|
||||
- **Reading, not stated (added 2026-10-29, Module 8).** After an unlock, "the normal obstruction of movement by STOP PUSH PULL does not apply", yet "other obstructing objects may still prevent the movement". The OPEN page doesn't say whether a *separate* STOP (or PUSH) object on the door tile still obstructs. The course reads it as yes: only the unlocked partner stops obstructing, and any other STOP object blocks the move and cancels the destruction.
|
||||
|
||||
- **The edge of the level (added 2026-10-29, for Module 8).** No source read says in so many words that the edge blocks ordinary movement.
|
||||
@@ -251,7 +253,7 @@ Inference status: this follows directly from the documented order. The wiki give
|
||||
- Both are destroyed during movement. The OPEN/SHUT movement check applies to pushed objects and overrides STOP/PUSH obstruction.
|
||||
- Baba moves one tile, into the key's former tile, not onto the door's tile. Destruction happens "before the moving object actually moves onto the other object".
|
||||
- The pusher's exact final position is inferred, not explicitly documented.
|
||||
- It also requires matching float levels, at most one of the pair being SAFE, and no other obstruction on the door tile.
|
||||
- It also requires matching float levels and at most one of the pair being SAFE. "Other obstructing objects may still prevent the movement" (OPEN page); which objects count is not stated, so "no other STOP object on the door tile" is the course's reading (section f).
|
||||
|
||||
5. **"TEXT IS PUSH is an implicit engine rule."** *True.*
|
||||
- It is listed as a base rule, "enabled by default, even though there isn't any text saying so", alongside LEVEL IS STOP and CURSOR IS SELECT.
|
||||
@@ -259,6 +261,6 @@ Inference status: this follows directly from the documented order. The wiki give
|
||||
- Do not cite baba-is-auto for this. It hard-codes text pushability.
|
||||
|
||||
### Unverified / not sourced
|
||||
- The GDC talk's speaker attribution (Teikari) comes only from search snippets. The gdconf.com page returned 404 and the video was not watched.
|
||||
- The GDC talk's speaker attribution (Teikari) is now confirmed on a Game Developer page (see source 6); the video was not watched.
|
||||
- The fandom wiki is community-written. Its Order of Operations page is detailed and cites internal function names, but it is not an official Hempuli document.
|
||||
- Game Developer quotes are excerpts returned by a summarizing fetch tool. Check them against the page before publishing.
|
||||
- Game Developer quotes were re-verified verbatim against the downloaded page HTML on 2026-10-29 (Task 17).
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"test:tools": "node --test tools/test/*.test.mjs",
|
||||
"check": "node tools/sync-check.mjs",
|
||||
"test:site": "node --test site/scripts/*.test.mjs"
|
||||
"test:site": "node --test site/scripts/*.test.mjs",
|
||||
"check:links": "node tools/check-links.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// Report-only external link check. Lists every external URL found in course/,
|
||||
// docs/research/ and README.md with its HTTP status. Always exits 0.
|
||||
//
|
||||
// node tools/check-links.mjs (or: npm run check:links)
|
||||
//
|
||||
// Reddit URLs are listed as "unverifiable": reddit blocks automated fetches,
|
||||
// and the course already excludes reddit as a source.
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".astro", ".git"]);
|
||||
const EXTS = new Set([".md", ".json", ".rs", ".ts", ".mjs"]);
|
||||
const TIMEOUT_MS = 20000;
|
||||
const CONCURRENCY = 6;
|
||||
const HOST_GAP_MS = 1000;
|
||||
|
||||
/** Extract http(s) URLs from text. Handles markdown links and trailing punctuation. */
|
||||
export function extractUrls(text) {
|
||||
const out = [];
|
||||
for (const m of text.matchAll(/https?:\/\/[^\s<>"'`\]]+/g)) {
|
||||
let url = m[0];
|
||||
// Drop trailing sentence punctuation.
|
||||
url = url.replace(/[.,;:!?*]+$/, "");
|
||||
// Drop closing parens that aren't balanced inside the URL (markdown "(url)").
|
||||
while (url.endsWith(")") && count(url, "(") < count(url, ")")) {
|
||||
url = url.slice(0, -1).replace(/[.,;:!?*]+$/, "");
|
||||
}
|
||||
out.push(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const count = (s, ch) => s.split(ch).length - 1;
|
||||
|
||||
function* walk(path) {
|
||||
const st = statSync(path);
|
||||
if (st.isFile()) {
|
||||
if (EXTS.has(extname(path))) yield path;
|
||||
return;
|
||||
}
|
||||
for (const e of readdirSync(path)) {
|
||||
if (SKIP_DIRS.has(e)) continue;
|
||||
yield* walk(join(path, e));
|
||||
}
|
||||
}
|
||||
|
||||
function classifyUnverifiable(url) {
|
||||
const host = new URL(url).hostname;
|
||||
if (/(^|\.)reddit\.com$/.test(host)) return "unverifiable (reddit blocks fetch tools; excluded as a source)";
|
||||
return null;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function get(url, method) {
|
||||
return fetch(url, { method, redirect: "follow", signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
}
|
||||
|
||||
// Fandom answers 403 to scripts. Its public MediaWiki API still says whether the page exists.
|
||||
async function fandomExists(url) {
|
||||
const u = new URL(url);
|
||||
const m = u.hostname.endsWith(".fandom.com") && u.pathname.match(/^\/wiki\/(.+)$/);
|
||||
if (!m) return null;
|
||||
const api = `${u.origin}/api.php?action=query&format=json&titles=${m[1]}`;
|
||||
try {
|
||||
const pages = (await (await get(api, "GET")).json()).query?.pages ?? {};
|
||||
return Object.keys(pages).some((k) => k !== "-1") ? "page exists per MediaWiki API" : "page MISSING per MediaWiki API";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function status(url) {
|
||||
try {
|
||||
let r = await get(url, "HEAD");
|
||||
if (r.status >= 400) r = await get(url, "GET");
|
||||
for (let tries = 0; r.status === 429 && tries < 3; tries++) {
|
||||
const wait = Math.min(Number(r.headers.get("retry-after")) || 10, 30);
|
||||
await sleep(wait * 1000);
|
||||
r = await get(url, "GET");
|
||||
}
|
||||
if (r.status === 403 || r.status === 402) {
|
||||
const note = await fandomExists(url);
|
||||
if (note) return `${r.status} (${note})`;
|
||||
}
|
||||
return String(r.status);
|
||||
} catch (e) {
|
||||
return `error (${e.cause?.code ?? e.name})`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = join(fileURLToPath(import.meta.url), "..", "..");
|
||||
const seen = new Map(); // url -> Set(file)
|
||||
for (const top of ["course", "docs/research", "README.md"]) {
|
||||
for (const f of walk(join(root, top))) {
|
||||
for (const url of extractUrls(readFileSync(f, "utf8"))) {
|
||||
if (!seen.has(url)) seen.set(url, new Set());
|
||||
seen.get(url).add(relative(root, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
const urls = [...seen.keys()].sort();
|
||||
// One request at a time per host (wikis rate-limit), up to CONCURRENCY hosts at once.
|
||||
const byHost = new Map();
|
||||
for (const url of urls) {
|
||||
const host = new URL(url).hostname;
|
||||
if (!byHost.has(host)) byHost.set(host, []);
|
||||
byHost.get(host).push(url);
|
||||
}
|
||||
const queues = [...byHost.values()];
|
||||
const results = new Map();
|
||||
await Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, async () => {
|
||||
for (let q = queues.shift(); q; q = queues.shift()) {
|
||||
for (const url of q) {
|
||||
results.set(url, classifyUnverifiable(url) ?? (await status(url)));
|
||||
await sleep(HOST_GAP_MS);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
const tally = {};
|
||||
for (const url of urls) {
|
||||
const s = results.get(url);
|
||||
const key = /^\d/.test(s) ? s : s.split(" ")[0];
|
||||
tally[key] = (tally[key] ?? 0) + 1;
|
||||
console.log(`${s}\t${url}\t${[...seen.get(url)].join(", ")}`);
|
||||
}
|
||||
console.log(`\n${urls.length} unique external URLs. By status: ${JSON.stringify(tally)}`);
|
||||
console.log("Report only: exit code is always 0.");
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractUrls } from "../check-links.mjs";
|
||||
|
||||
test("check_links_extracts_markdown_and_bare_urls", () => {
|
||||
const md =
|
||||
"See [Rule](https://babaiswiki.fandom.com/wiki/Rule). Also https://example.com/a, and " +
|
||||
"(https://en.wikipedia.org/wiki/Foo_(bar)) and `https://x.org/y`.";
|
||||
assert.deepEqual(extractUrls(md), [
|
||||
"https://babaiswiki.fandom.com/wiki/Rule",
|
||||
"https://example.com/a",
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
"https://x.org/y",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user