feat: module 07 rule resolution

Adds IS NOT to the Part II parser and a resolution pass (negation, then
identity lock) that turns parsed rules into the rules in force; harness
gains activeRules() and hasProperty(). A leading NOT now breaks the
sentence instead of being ignored (carried from M6 review). Nouns say
which objects they name ("selects"), so the ALL Expansion Drop is one
line of data. Corrects lesson 06's NOT wording; harness.md M7 details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 03:47:48 +01:00
co-authored by Claude Opus 5.5
parent 68fe9834c3
commit d993457772
58 changed files with 6091 additions and 2 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ subjects = NOUN { AND NOUN }
complements = (NOUN | PROPERTY) { AND (NOUN | PROPERTY) }
```
read left to right in a row and top to bottom in a column. `NOT` is a known operator but not part of the grammar yet (Module 7): a `NOT` breaks a sentence, as an unknown word does.
read left to right in a row and top to bottom in a column. `NOT` is a known operator but not part of the grammar yet (Module 7). Where a sentence needs a noun or a property, a `NOT` stops it, as an unknown word does: `BABA IS NOT YOU` spells nothing, and in `BABA IS YOU AND NOT WIN` the run of complements ends at the `NOT`. In front of a subject, though, nothing looks at it: `NOT BABA IS YOU` reads as `BABA IS YOU`, which is wrong. Module 7 makes the parser refuse that sentence.
<details><summary>Hint 1 — nudge</summary>
@@ -0,0 +1,24 @@
# Module 7 Expansion Drop: ALL
The lesson's [Expansion Drop](../lesson.md#expansion-drop) beat explains the task. This file holds the scenario, the test locations and the check.
- Word: ALL, a meta-noun: as a subject it names every object that is not text. The Baba Is You Wiki's TEXT page: "TEXT is not included in ALL, but is included in NOT ALL." (recorded in `docs/research/baba-is-you.md` §5).
- Tests: `tests/rust/test_07x.rs` and `tests/ts/test_07x.test.ts`. Copy the one for your track into your workspace's `tests/`.
- Reference answer: `solution/words.json`, one file for both tracks: the Module 7 reference's `src/content/words.json` with one entry added, `{ "name": "ALL", "role": "noun", "selects": "things" }`. Copy it over your workspace's `src/content/words.json`. Nothing under `src/engine/` changes, and neither does the harness. The `"selects"` field is the hook the Module 7 engine already reads: the engine's own `TEXT` uses it too (`"text"`).
- Check: commit your workspace before you start, then run `node tools/expansion-check.mjs <your-workspace> <that-commit>` from the course repository. It fails if any file under `src/engine/` changed.
## Scenario
### test_07x_all_meta_noun
- Setup: `newBaba` with an empty level 4 wide and 5 high. Place `ALL (0,0) IS (1,0) YOU (2,0)`, an object `baba` at (0,4), an object `rock` at (1,4) and a text `text:FLAG` at (3,4).
- Assertions 1:
- `activeRules()` is exactly `["ALL IS YOU", "TEXT IS PUSH"]`.
- `hasProperty(baba, "YOU")` and `hasProperty(rock, "YOU")` are true: ALL names every thing, whatever its noun.
- `hasProperty(flagText, "YOU")` is false: text is not in ALL.
- Action: place `BABA (0,2) IS (1,2) NOT (2,2) YOU (3,2)`.
- Assertions 2:
- `activeRules()` is exactly `["ALL IS YOU", "BABA IS NOT YOU", "TEXT IS PUSH"]`. No rule cancels another as written: `BABA IS NOT YOU` and `ALL IS YOU` have different subjects.
- `hasProperty(baba, "YOU")` is false and `hasProperty(rock, "YOU")` is still true. The negation is settled per object: Baba is named by both `ALL` and `BABA`, and "BABA IS NOT YOU prevents BABA from having the property YOU" (wiki, IS page).
Before the drop the lexicon doesn't know `ALL`, so the first sentence spells nothing and Baba is not `YOU`.
@@ -0,0 +1,16 @@
[
{ "name": "BABA", "role": "noun" },
{ "name": "ROCK", "role": "noun" },
{ "name": "WALL", "role": "noun" },
{ "name": "FLAG", "role": "noun" },
{ "name": "LAVA", "role": "noun" },
{ "name": "KEY", "role": "noun" },
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "ALL", "role": "noun", "selects": "things" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" }
]
@@ -0,0 +1,33 @@
// Module 7 Expansion Drop: ALL. The scenario is in expansion/README.md.
use engine::harness::*;
#[test]
fn test_07x_all_meta_noun() {
let mut b = new_baba(LevelDef {
width: 4,
height: 5,
..Default::default()
});
for (word, x) in [("ALL", 0), ("IS", 1), ("YOU", 2)] {
b.place(&format!("text:{word}"), x, 0);
}
let baba = b.place("baba", 0, 4);
let rock = b.place("rock", 1, 4);
let flag_text = b.place("text:FLAG", 3, 4);
assert_eq!(b.active_rules(), vec!["ALL IS YOU", "TEXT IS PUSH"]);
assert!(b.has_property(baba, "YOU"));
assert!(b.has_property(rock, "YOU"));
assert!(!b.has_property(flag_text, "YOU"));
for (word, x) in [("BABA", 0), ("IS", 1), ("NOT", 2), ("YOU", 3)] {
b.place(&format!("text:{word}"), x, 2);
}
assert_eq!(
b.active_rules(),
vec!["ALL IS YOU", "BABA IS NOT YOU", "TEXT IS PUSH"]
);
assert!(!b.has_property(baba, "YOU"));
assert!(b.has_property(rock, "YOU"));
}
@@ -0,0 +1,35 @@
// Module 7 Expansion Drop: ALL. The scenario is in expansion/README.md.
import { expect, test } from "vitest";
import { newBaba } from "../src/harness";
test("test_07x_all_meta_noun", () => {
const b = newBaba({ width: 4, height: 5, objects: [] });
for (const [word, x] of [
["ALL", 0],
["IS", 1],
["YOU", 2],
] as const) {
b.place(`text:${word}`, x, 0);
}
const baba = b.place("baba", 0, 4);
const rock = b.place("rock", 1, 4);
const flagText = b.place("text:FLAG", 3, 4);
expect(b.activeRules()).toEqual(["ALL IS YOU", "TEXT IS PUSH"]);
expect(b.hasProperty(baba, "YOU")).toBe(true);
expect(b.hasProperty(rock, "YOU")).toBe(true);
expect(b.hasProperty(flagText, "YOU")).toBe(false);
for (const [word, x] of [
["BABA", 0],
["IS", 1],
["NOT", 2],
["YOU", 3],
] as const) {
b.place(`text:${word}`, x, 2);
}
expect(b.activeRules()).toEqual(["ALL IS YOU", "BABA IS NOT YOU", "TEXT IS PUSH"]);
expect(b.hasProperty(baba, "YOU")).toBe(false);
expect(b.hasProperty(rock, "YOU")).toBe(true);
});
+318
View File
@@ -0,0 +1,318 @@
# Module 7: Resolving rules: NOT, identity, meta-nouns
Module 6 built a parser that reports everything the board says. This module decides what the board *means* when its sentences disagree. `BABA IS YOU` and `BABA IS NOT YOU` can lie on the same board, and so can `BABA IS BABA` and `BABA IS ROCK`. The parser reads them all. A second stage, the resolution pass, reads the whole rule list at once and decides which rules are in force. Then every question an object asks ("am I YOU?") goes through one function that looks only at the rules in force.
Part I had a step like this too. In Module 4, Baron Rivendare changed what a Deathrattle *does* without the Deathrattle's code knowing he exists: an interceptor, consulted at the moment the trigger runs. `BABA IS NOT YOU` changes whether `BABA IS YOU` applies, and nothing in `BABA IS YOU` knows about it either. Keep both in mind while you build. The course's closing question asks you to compare them.
You need your Module 6 workspace with all Module 1–6 tests passing, under git.
## 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 `NOT` does, from the wiki's [NOT](https://babaiswiki.fandom.com/wiki/NOT) page:
> When added after a verb it makes the corresponding rule not apply, overriding other instances of the rule it disables.
> ROCK IS NOT PUSH makes ROCK NOT PUSHable, even if ROCK IS PUSH exists elsewhere.
It works on conversions as well as properties: "ROCK IS NOT GRASS disables ROCK IS GRASS." The [IS](https://babaiswiki.fandom.com/wiki/IS) page says the same about properties: "BABA IS NOT YOU prevents BABA from having the property YOU, overriding any BABA IS YOU rule."
A noun can be its own complement. The [IS](https://babaiswiki.fandom.com/wiki/IS) page:
> BABA IS BABA prevents BABA from transforming into any object, including those not in ALL. It does not affect transforming objects into BABA.
And the negation of that, `X IS NOT X`, from the [NOT](https://babaiswiki.fandom.com/wiki/NOT) page:
> X IS NOT X makes an object disappear, similarly to X IS EMPTY but with particles. ... As it negates X IS X, it enables other X IS Y rules, which can result in transformation in addition to the disappearance.
The IS page puts it more briefly: "BABA IS NOT BABA causes BABA to vanish in a puff of logic."
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. -->
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.
## Predict
Write your answers down before you open the discussion, for example in `notes/07.md` in your workspace.
1. `BABA IS YOU` on the top row and `BABA IS NOT YOU` two rows below. Is Baba YOU? Would the answer change if the rows swapped places?
2. Three sentences: `BABA IS BABA`, `BABA IS ROCK`, `ROCK IS BABA`. Which of them are in force?
3. Three sentences: `BABA IS NOT BABA`, `BABA IS BABA`, `BABA IS ROCK`. Which are in force, and what happens to Baba?
4. `TEXT IS NOT PUSH` is on the board. Is the text spelling it pushable? Is a rock?
5. One row: `NOT BABA IS YOU`. Your Module 6 parser reads something from it. What, and is that right?
6. Back to Part I: Baron makes a Deathrattle activate twice; `BABA IS NOT YOU` makes `BABA IS YOU` not apply. In your engines, where is each of those decisions made, and when?
<details><summary>Discussion</summary>
1. No, and no. `NOT` overrides "other instances of the rule it disables", wherever they are. If your engine let the later sentence win, the answer would depend on which row the parser reads first, and moving a sentence up a row would change the game. Negation is a fact about the whole rule set, not about reading order.
2. `BABA IS BABA` and `ROCK IS BABA`. `BABA IS BABA` blocks Baba converting into anything, so `BABA IS ROCK` is out. It doesn't stop rocks becoming Baba.
3. `BABA IS NOT BABA` and `BABA IS ROCK`. `BABA IS NOT BABA` is the negation of `BABA IS BABA`, so it cancels it exactly as `BABA IS NOT YOU` cancels `BABA IS YOU`; with the lock gone, the conversion applies again. And every Baba vanishes. Which happens to a given Baba first, conversion or vanishing, is a question about the turn (Module 9); this module only has to know that both are in force.
4. No, and no. The base rule is a rule like any other; `NOT` cancels it, and no text on the board is pushable. A rock was never pushable: `TEXT` names text objects, not the things they name.
5. `BABA IS YOU`. The subject run walks back from `IS`, finds `BABA`, finds no `AND` before it, and stops: the `NOT` is ignored. That reads a sentence that isn't on the board. Module 7's parser has to do something with it. See Build for the course's choice and "How the real game differs" for the game's.
6. Baron is consulted *while a trigger runs*: the step asks the modifiers in play how many times to activate. `NOT` is consulted *when the rules are compiled*, before anyone asks a question: by the time an object asks "am I YOU?", the cancelled rule is already gone. Hold on to that difference; you'll come back to it at the end of the course.
</details>
## Spec
From the course repository, copy the module's tests into your workspace:
```sh
node tools/start-module.mjs 07 ~/my-engine
```
### The harness
Two new functions on `Baba`, and one change:
| TypeScript | Rust |
|---|---|
| `activeRules(): string[]` | `active_rules(&self) -> Vec<String>` |
| `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.
- `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
The full statements are in `course/07-rule-resolution/scenarios.md`.
- `test_07_not_overrides`: Predict question 1. Baba is YOU until `BABA IS NOT YOU` is written below `BABA IS YOU`. `rules()` shows both sentences; `activeRules()` shows only the negation and the base rule.
- `test_07_x_is_x_blocks_conversion`: Predict question 2, exactly.
- `test_07_x_is_not_x_deletes_and_unlocks`: Predict question 3. `activeRules()` contains `BABA IS ROCK` and not `BABA IS BABA`.
- `test_07_text_is_not_push`: Predict question 4, checked with `hasProperty` on a piece of text and on a rock, before and after.
- `test_07_leading_not_breaks_sentence`: Predict question 5. `NOT BABA IS YOU` spells nothing in the course's grammar.
The third test, which carries the module's subtlest point:
```rust
#[test]
fn test_07_x_is_not_x_deletes_and_unlocks() {
let mut b = new_baba(empty_level(4, 5));
write(
&mut b,
&[
("BABA", 0, 0),
("IS", 1, 0),
("NOT", 2, 0),
("BABA", 3, 0),
("BABA", 0, 2),
("IS", 1, 2),
("BABA", 2, 2),
("BABA", 0, 4),
("IS", 1, 4),
("ROCK", 2, 4),
],
);
assert_eq!(
b.rules(),
vec![
"BABA IS BABA",
"BABA IS NOT BABA",
"BABA IS ROCK",
"TEXT IS PUSH"
]
);
let active = b.active_rules();
assert!(active.contains(&"BABA IS ROCK".to_string()));
assert!(!active.contains(&"BABA IS BABA".to_string()));
}
```
```ts
test("test_07_x_is_not_x_deletes_and_unlocks", () => {
const b = newBaba(emptyLevel(4, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["NOT", 2, 0],
["BABA", 3, 0],
["BABA", 0, 2],
["IS", 1, 2],
["BABA", 2, 2],
["BABA", 0, 4],
["IS", 1, 4],
["ROCK", 2, 4],
]);
expect(b.rules()).toEqual(["BABA IS BABA", "BABA IS NOT BABA", "BABA IS ROCK", "TEXT IS PUSH"]);
expect(b.activeRules()).toContain("BABA IS ROCK");
expect(b.activeRules()).not.toContain("BABA IS BABA");
});
```
The test doesn't check that Baba vanishes. Nothing moves or vanishes until the turn exists (Module 9). This module decides that Baba is to vanish; Module 9 does it.
Run `cargo test` or `npm test`. The Module 7 tests fail to compile (Rust) or fail (TypeScript). Modules 1–6 still pass. That is RED.
## Build
Make the five tests pass, and keep Modules 1–6 green. Where code goes:
- **The parser learns `NOT`, and only reads.** After `IS`, a `NOT` negates the complement right after it: `BABA IS NOT YOU AND WIN` is `BABA IS NOT YOU` and `BABA IS WIN`. A rule now carries a flag, negated or not. The parser never cancels anything; it reports what is written.
- **A `NOT` before a subject breaks the sentence.** The course's grammar has no negated subjects. When the subject run meets a `NOT` in front of a noun, it stops and drops that noun. Reading `NOT BABA IS YOU` as `BABA IS YOU` is the one thing it must not do.
- **A separate resolution pass.** A function from the parsed rules to the rules in force, run after every parse. It sees the whole list at once, so where a sentence sits on the board can't matter.
- **One question, one place.** `hasProperty(id, prop)` looks only at the rules in force, and asks one more function: does this noun name this object? `BABA` names objects of kind `baba`; `TEXT` names every text object. `TEXT` is the first noun you meet that names a group rather than its own kind. Decide whether that is a special case in your engine or a fact about the word.
- **Keep the record.** `X IS NOT X` means "every X vanishes". Nothing vanishes yet, but the decision is made here, and the turn in Module 9 will need to find it.
<details><summary>Hint 1 — nudge</summary>
You will be tempted to do it in the parser: when you read `BABA IS NOT YOU`, delete `BABA IS YOU` from the list. Try Predict question 1 with the rows swapped. Then try question 3: `BABA IS NOT BABA` cancels `BABA IS BABA`, which would have cancelled `BABA IS ROCK`. How many orders of reading do you have to get right?
</details>
<details><summary>Hint 2 — concept</summary>
- **Two steps, in a fixed order, over the whole list.** First negation: drop every positive rule `X IS Y` for which a negated `X IS NOT Y` exists. Keep the negated rules: they are still in force. Then identity: for each positive `X IS X` that survived, drop every `X IS Y` where `Y` is a noun other than `X`.
- **The order does the work for `X IS NOT X`.** It negates `X IS X` in the first step, so in the second step there is no lock. There's no special case for it.
- **Telling conversions from properties.** The identity step needs to know whether a complement is a noun. The parser knows (the lexer gave every word a role): store it on the rule.
- **`hasProperty` is about objects, not rules.** Collect the rules in force whose complement is the property and whose subject names the object. The object has the property if one of them is positive and none is negated. Doing the negation per object, not only per rule, is what lets a negation about `BABA` beat a positive rule written about a larger group.
- **Base rules are rules.** If `TEXT IS PUSH` is in the parsed list like any other rule, `TEXT IS NOT PUSH` needs no code of its own.
</details>
<details><summary>Hint 3 — pseudo-code</summary>
```text
complements(tiles, at, step): // after IS
found = []
branches = [(not negated, at)] + [(negated, at + step) for each NOT in tiles[at]]
for (sign, p) in branches:
words = [w in tiles[p] where w is noun or property]
if words is empty: continue
found += words, each with this sign
if tiles[p + step] has an AND:
found += complements(tiles, p + 2 * step, step)
return found
subjects(tiles, at, step): // before IS, walking backwards
if tiles[at + step] has NOT: return []
... as in Module 6
resolve(rules):
kept = [r in rules where not exists n: n.negated and not r.negated
and n.subject == r.subject and n.complement == r.complement]
locked(x) = exists r in kept: not r.negated and r.subject == x and r.complement == x
return [r in kept where r.negated or r.complement is not a noun
or r.subject == r.complement or not locked(r.subject)]
hasProperty(id, prop):
about = [r in active where r.complement == prop and names(r.subject, object id)]
return any(r positive in about) and not any(r negated in about)
```
</details>
When your tests pass, compare with the reference engine in `course/07-rule-resolution/reference/rust/src/engine/puzzle/` or `course/07-rule-resolution/reference/ts/src/engine/puzzle/`: `parse` (now with `NOT`), the new `resolve`, `level` (`has_property` / `hasProperty`) and `word` (`Lexicon::names`).
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.
- **`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.
- **One `NOT` only.** `IS NOT NOT PUSH` spells nothing (see the box below).
- **Duplicates as before.** The engine keeps a rule once per set of text objects, now including the `NOT`; the harness deduplicates.
> **How the real game differs**
> - `NOT` before a noun is real grammar in the game. The [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) page says "TEXT is not included in ALL, but is included in NOT ALL", so `NOT ALL` names a group. The course's parser doesn't read negated subjects: it refuses the sentence rather than misread it. baba-is-auto makes the same cut: "Negated subjects, predicates, condition targets, repeated `NOT`, and nested forms are unsupported" ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md)).
> - `NOT` can stack: "NOT negates itself before negating anything else, so ROCK IS NOT NOT PUSH makes ROCK IS PUSH" ([NOT](https://babaiswiki.fandom.com/wiki/NOT)). The course reads one `NOT`.
> - `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").
## Name it
The pattern is a **resolution pass** (a post-pass): parse everything into candidate rules, then resolve the conflicts in one deterministic pass over the whole set, and let every query read only the result. The parser stays simple because it never decides anything. The resolver stays simple because it never reads the board. And every system that asks "is Baba YOU?" gets the same answer, because there is exactly one place that answers.
The reference's whole resolver:
```rust
pub fn resolve(rules: &[Rule]) -> Vec<Rule> {
let survives_negation: Vec<&Rule> = rules
.iter()
.filter(|r| !rules.iter().any(|n| n.negates(r)))
.collect();
let locked = |subject: &str| {
survives_negation
.iter()
.any(|r| !r.negated && r.is_identity() && r.subject == subject)
};
survives_negation
.iter()
.filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(&r.subject))
.map(|&r| r.clone())
.collect()
}
```
```ts
export function resolve(rules: readonly Rule[]): Rule[] {
const survivesNegation = rules.filter((r) => !rules.some((n) => negates(n, r)));
const locked = (subject: string) =>
survivesNegation.some((r) => !r.negated && isIdentity(r) && r.subject === subject);
return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r.subject));
}
```
The order of the two steps is part of the meaning. Swap them and `BABA IS NOT BABA` no longer unlocks `BABA IS ROCK`, because the lock would already have removed it.
Magic: The Gathering has the large version of this. When many continuous effects change the same object, rule 613.1 of the Comprehensive Rules says: "Then all applicable continuous effects are applied in a series of layers in the following order", and inside a layer, "determining which order effects are applied in is usually done using a timestamp system" (613.7) and "is sometimes done using a dependency system" (613.8) ([Comprehensive Rules](https://magic.wizards.com/en/rules)). The shape is the same as yours: collect every effect first, then apply them in an order fixed by the rules, not by where the cards sit. Your resolver has two layers, negation and identity, and needs no timestamps, because a rule's position on the board never matters.
Compare it with Module 4. Baron's interceptor is consulted at the moment a Deathrattle runs, and it changes *how many times* something happens. `NOT` is applied when the rules are compiled, and it changes *whether* a rule exists at all. Both keep the thing they modify ignorant of them. Which one would you rather debug? The course's closing question returns to Baron and `BABA IS NOT YOU`; keep your notes.
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. The resolver names no noun: it compares subjects and complements, whatever they spell.
## Expansion Drop
A new word arrives: `ALL`, a meta-noun. As a subject it names every object on the board that is not text: the [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) page is explicit that "TEXT is not included in ALL".
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either. Only your word list should.
1. Commit your workspace and note the commit: `git add -A && git commit -m "module 7 done"`.
2. Copy the expansion test for your track from `course/07-rule-resolution/expansion/tests/rust/test_07x.rs` or `course/07-rule-resolution/expansion/tests/ts/test_07x.test.ts` into your workspace's `tests/`. Run it and watch it fail.
3. Add `ALL` to your word list.
4. When the tests pass, run the check from the course repository:
```sh
node tools/expansion-check.mjs ~/my-engine HEAD
```
The test, `test_07x_all_meta_noun`, writes `ALL IS YOU` with a `baba`, a `rock` and a piece of text on the board. Baba and the rock are YOU; the text is not. Then it writes `BABA IS NOT YOU`: Baba stops being YOU, the rock doesn't. The full scenario is in `course/07-rule-resolution/expansion/README.md`.
<details><summary>Discussion: what this drop measures</summary>
If "which objects does this noun name" is a fact about the word, `ALL` is one line:
```json
{ "name": "ALL", "role": "noun", "selects": "things" }
```
That is the reference answer, in `course/07-rule-resolution/expansion/solution/words.json` (the Module 7 word list with that line added), the same file for both tracks.
Shapes that fail this drop:
- **`TEXT` as a branch in the engine.** If `hasProperty` says `if subject == TEXT { object is text } else { object's noun == subject }`, then `ALL` is a third branch, under `src/engine/`. The test can't pass without an engine change. The fix is to move the question "which objects does this noun name" into the word list, and let `TEXT` be the first entry that uses it. Make it a normal commit, then redo the drop.
- **Negation only per rule.** If your resolver cancels `X IS Y` only when a rule `X IS NOT Y` has the very same subject, and `hasProperty` then trusts any positive rule, `ALL IS YOU` survives and Baba stays YOU after `BABA IS NOT YOU`. The second half of the test fails. The negation has to be checked for the object, against every noun that names it.
- **Expanding `ALL` in the parser.** Turning `ALL IS YOU` into `BABA IS YOU`, `ROCK IS YOU`, ... for every noun in the word list works for this test, but the parser now needs to know the board's objects, and `activeRules()` shows rules nobody wrote. Keep the parser about text.
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
</details>
## Further reading
- The Baba Is You Wiki's [NOT](https://babaiswiki.fandom.com/wiki/NOT) and [IS](https://babaiswiki.fandom.com/wiki/IS) pages: every case in this module, and the ones the course leaves out (stacked `NOT`, `NOT` before nouns). Then [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) for `TEXT IS PUSH` and `ALL`.
- John Harris, [*Designing Baba Is You's delightfully innovative rule-writing system*](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system), Game Developer, 8 May 2019: Teikari on the final pass over the rule set, the pass you just built.
- Wizards of the Coast, [Magic: The Gathering Comprehensive Rules](https://magic.wizards.com/en/rules), rule 613 (613.1, 613.7, 613.8): interaction of continuous effects, in layers, by timestamp and by dependency. A resolution pass for a game with thousands of cards.
- Argentum, [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md), §2.3 "Rule 613: Base State vs. Projected State": "the engine doesn't need to 'undo' +3/+3. It simply removes the floating effect." The same idea as recompiling the rules from the board.
- baba-is-auto, `Game::ParseRules` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (around line 611) and `RuleManager::HasProperty` in [`Sources/baba-is-auto/Rules/RuleManager.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Rules/RuleManager.cpp): one place that answers "does this object have this property?". It is an example of rules as data, **not an authority** on the game: it has no `IS NOT` for properties or nouns and hard-codes text as pushable ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md)), so it can't run this module's tests.
@@ -0,0 +1 @@
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "07" }
@@ -0,0 +1 @@
target/
@@ -0,0 +1,9 @@
[package]
name = "engine"
version = "0.1.0"
edition = "2021"
[dependencies]
# Module 5: cards are JSON data.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -0,0 +1,17 @@
# Module 7 reference engine (Rust)
The course's own engine as of the end of Module 7. Part I's card engine is unchanged, and all of its tests still pass. The Part II engine in `src/engine/puzzle/` now reads `IS NOT` and resolves the parsed rules into the rules in force. Module 6's reference was the starting point, so a diff between the two shows what this module adds.
- `cards/` (at the crate root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it.
- `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT` and the property `PUSH` of the base rule `TEXT IS PUSH`.
- `grid.rs`: the level format, object kinds and the board. Module 7 adds `Board::get`.
- `word.rs`: the lexicon and the lexer. Module 7 adds `Selects`: which objects a noun names as a subject (its own things by default, every text object for `TEXT`, or every non-text object), read from the word list's optional `"selects"` field, and `Lexicon::names`.
- `parse.rs`: the parser. Module 7 adds `NOT`: before a complement it negates that complement (`Rule::negated`); before a subject it ends the run of subjects without that subject. Each rule also records its complement's role, so a conversion (`X IS <noun>`) can be told apart.
- `resolve.rs` (new): the resolution pass. Negation first (`X IS NOT Y` cancels every `X IS Y` and stays in force), then the identity lock (a surviving `X IS X` cancels `X IS Y` for nouns `Y`).
- `level.rs`: a level in play. Every change to the board parses, then resolves. `has_property` answers for one object from the rules in force.
- `mod.rs`: the module's exports.
- The rest of `src/engine/`: Part I, unchanged.
- `src/content/words.json`: unchanged from Module 6.
- `src/harness.rs`: the adapter the course tests call. Module 7 adds `Baba::active_rules` and `Baba::has_property`; `rules()` now lists negated rules too (`"BABA IS NOT YOU"`).
Run it with the module's tests: `node tools/verify-references.mjs 07` from the course repository.
@@ -0,0 +1,48 @@
//! Content: the cards and the Baba Is You words. Only this directory (and the card data in `cards/`) may name
//! specific cards, or specific Baba Is You nouns.
//!
//! From Module 5 every card is a JSON file in `cards/` at the crate root, in the
//! course's card format (`course/schema/card.schema.json`). The functions that
//! were card effects in Modules 2–4 are gone: the engine interprets the data.
//! `start-module` copies the course's card files into `cards/`; add your own there.
use std::path::Path;
use std::sync::OnceLock;
use crate::engine::CardDef;
/// Every card in `cards/`, in file-name order. Read once per test run.
pub fn cards() -> Vec<CardDef> {
static CARDS: OnceLock<Vec<CardDef>> = OnceLock::new();
CARDS
.get_or_init(|| load_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("cards")))
.clone()
}
/// Parse every `.json` file in `dir` as a card definition.
pub fn load_dir(dir: &Path) -> Vec<CardDef> {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|e| panic!("cannot read card directory {}: {e}", dir.display()));
let mut paths: Vec<_> = entries
.map(|entry| entry.expect("card directory entry").path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.collect();
paths.sort();
paths
.iter()
.map(|path| {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{} is not a valid card: {e}", path.display()))
})
.collect()
}
/// Part II: the words of Baba Is You the parser can read, besides the engine's own
/// (`IS`, `AND`, `NOT`, `TEXT`). Nouns and properties are data: a new word is one
/// more entry in `words.json` in this directory.
pub fn words() -> Vec<crate::engine::puzzle::WordDef> {
serde_json::from_str(include_str!("words.json"))
.expect("src/content/words.json is not a word list")
}
@@ -0,0 +1,15 @@
[
{ "name": "BABA", "role": "noun" },
{ "name": "ROCK", "role": "noun" },
{ "name": "WALL", "role": "noun" },
{ "name": "FLAG", "role": "noun" },
{ "name": "LAVA", "role": "noun" },
{ "name": "KEY", "role": "noun" },
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" }
]
@@ -0,0 +1,153 @@
//! Assemble: "Get a random minion. Change half its text."
//!
//! Card text is a list of clauses, so changing half of it is list surgery: the base
//! card's `text[..split]` (top) or `text[split..]` (bottom) is replaced by the same
//! half of a donor card. Name, cost and stats stay the base's. This works only
//! because a card's behaviour is its data: the new card needs no new code.
//!
//! Hearthstone's wiki doesn't define "half". The course's `split` field is its
//! definition: each card's data says where its top half ends.
use std::fmt;
use super::card::{CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Half {
Top,
Bottom,
}
impl Half {
pub const BOTH: [Half; 2] = [Half::Top, Half::Bottom];
pub fn label(self) -> &'static str {
match self {
Half::Top => "top",
Half::Bottom => "bottom",
}
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
self.base,
self.half.label(),
self.donor
)
}
}
impl std::error::Error for AssembleError {}
/// The base card with `half` of its text replaced by the same half of `donor`.
/// It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef, AssembleError> {
let (base_top, base_bottom) = base.text.split_at(base.split);
let (donor_top, donor_bottom) = donor.text.split_at(donor.split);
let (top, bottom) = match half {
Half::Top => (donor_top, base_bottom),
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
split: top.len(),
text,
..base.clone()
})
}
/// Register an assembled card and return its name. If a different card already has
/// the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
let stem = def.name.clone();
let mut n = 1;
while world.has_card(&def.name) && world.card(&def.name).def != def {
n += 1;
def.name = format!("{} {n})", stem.trim_end_matches(')'));
}
let name = def.name.clone();
world.register_assembled(def);
name
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor that changes its text, and the half to replace. The
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
/// random and offers two donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
filter: &PoolFilter,
) -> Option<EntityId> {
// Each pool card, and whether the filter lets it be the base.
let pool: Vec<(CardDef, bool)> = world
.pool()
.into_iter()
.map(|c| {
(
c.def.clone(),
filter.trigger.map_or(true, |on| c.has_trigger(on)),
)
})
.collect();
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
return None;
}
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
let half = if half == "top" {
Half::Top
} else {
Half::Bottom
};
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -0,0 +1,53 @@
//! Auras, and the one routine that applies trigger multipliers.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
//! something. It takes a snapshot of the auras in force at defined points (an Aura
//! Update) and every rule reads the snapshot.
//!
//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
//! Health/Attack auras before the Death Creation Step, and one for every other aura
//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
use super::world::{ActiveAura, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
let mut active = Vec::new();
for id in world.minions_in_play() {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
aura,
});
}
}
world.set_active_auras(active);
}
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations.
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
let mut max = 1;
let mut extra = 0;
for active in world.active_auras() {
let Aura::TriggerMultiplier {
trigger,
times,
stacking,
} = active.aura;
if active.owner != owner || trigger != on {
continue;
}
match stacking {
Stacking::Max => max = max.max(times),
Stacking::Additive => extra += times - 1,
}
}
max + extra
}
@@ -0,0 +1,290 @@
//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
//! read straight from JSON. The engine compiles each definition once, when it is
//! registered, into the lists it reads while resolving: triggers, auras and a
//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
use std::collections::HashMap;
use serde::Deserialize;
/// What a triggered ability listens for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TriggerOn {
/// This minion takes damage.
SelfDamaged,
/// A minion with the same owner (this one included) takes damage.
FriendlyMinionDamaged,
/// Any minion takes damage.
AnyMinionDamaged,
/// Either hero takes damage.
HeroDamaged,
/// This minion dies. It resolves after the minion has left play.
Deathrattle,
/// This minion is played from hand.
Battlecry,
/// The end of its owner's turn. The course has no turns, so it never fires; card
/// data may still name it, for example in a trigger multiplier.
EndOfTurn,
}
impl TriggerOn {
/// The trigger's family name, as reported in `Triggered` events.
pub fn label(self) -> &'static str {
match self {
TriggerOn::SelfDamaged
| TriggerOn::FriendlyMinionDamaged
| TriggerOn::AnyMinionDamaged
| TriggerOn::HeroDamaged => "damaged",
TriggerOn::Deathrattle => "deathrattle",
TriggerOn::Battlecry => "battlecry",
TriggerOn::EndOfTurn => "endOfTurn",
}
}
}
/// How several trigger multipliers for the same trigger combine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Stacking {
/// The largest multiplier wins: two copies of a doubler still double.
Max,
/// Each copy adds its extra activations.
Additive,
}
/// An aura: a continuous effect a minion has while it is in play. The engine reads
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Aura {
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
/// owner, activate `times` times each.
TriggerMultiplier {
trigger: TriggerOn,
times: u32,
stacking: Stacking,
},
}
/// Who an effect touches. `This` is the schema's `"self"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Target {
#[serde(rename = "self")]
This,
AllMinions,
AllOtherMinions,
AllOtherCharacters,
EnemyHero,
Chosen,
}
/// Which minions an Assemble may pick as its base. An empty filter allows any.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct PoolFilter {
/// Only minions with a trigger clause listening for this.
#[serde(default)]
pub trigger: Option<TriggerOn>,
}
/// One effect: a node of a card's ability, interpreted by `effect.rs`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Effect {
DealDamage {
target: Target,
amount: i32,
},
Draw {
count: u32,
},
Summon {
card: String,
count: u32,
},
GainArmor {
amount: i32,
},
GainAttack {
target: Target,
amount: i32,
},
Destroy {
target: Target,
},
/// Part III (SpellGrid) only: puts a word on the grid.
SpawnText {
word: String,
at: String,
},
/// Get a minion from the pool, with half its text replaced (see `assemble.rs`).
Assemble {
filter: PoolFilter,
},
/// Give every minion in the owner's hand +attack/+health.
BuffHand {
attack: i32,
health: i32,
},
}
/// One line of card text. A trigger clause applies to the effect clauses after it,
/// until the next trigger, keyword or aura. Effects before any trigger are what a
/// spell does when cast.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Clause {
Keyword { keyword: String },
Trigger { on: TriggerOn },
Aura { modifier: Aura },
Effect { effect: Effect },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CardType {
Minion,
Spell,
}
/// A card as data, in the course's card format. Fields the engine doesn't use
/// (`fictional`, `game`, `noun`) are ignored.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct CardDef {
pub name: String,
#[serde(rename = "type")]
pub card_type: CardType,
pub cost: i32,
#[serde(default)]
pub attack: i32,
#[serde(default)]
pub health: i32,
pub text: Vec<Clause>,
/// `text[..split]` is the top half, `text[split..]` the bottom half.
pub split: usize,
}
/// A triggered ability: "whenever <on>, <effects>".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trigger {
pub on: TriggerOn,
pub effects: Vec<Effect>,
}
/// A registered card: its definition, and the definition's text compiled into the
/// lists the engine reads while resolving.
#[derive(Debug, Clone)]
pub struct Card {
pub def: CardDef,
/// Triggered abilities, in printed order.
pub triggers: Vec<Trigger>,
/// Auras this minion has while in play.
pub auras: Vec<Aura>,
/// What the card does when cast (a spell's effects).
pub on_cast: Vec<Effect>,
/// May an Assemble offer this card? Minions with text that were not themselves assembled.
pub in_pool: bool,
}
impl Card {
/// Compile a definition's text. Panics if `split` is past the end of the text.
pub fn compile(def: CardDef, in_pool: bool) -> Card {
assert!(
def.split <= def.text.len(),
"{:?}: split {} is past the end of its text",
def.name,
def.split
);
let mut triggers: Vec<Trigger> = Vec::new();
let mut auras = Vec::new();
let mut on_cast = Vec::new();
// Is the latest clause a trigger whose effects are still being listed?
let mut open_trigger = false;
for clause in &def.text {
match clause {
Clause::Trigger { on } => {
triggers.push(Trigger {
on: *on,
effects: Vec::new(),
});
open_trigger = true;
}
Clause::Effect { effect } if open_trigger => {
triggers.last_mut().unwrap().effects.push(effect.clone());
}
Clause::Effect { effect } => on_cast.push(effect.clone()),
Clause::Aura { modifier } => {
auras.push(*modifier);
open_trigger = false;
}
// The course has no combat, so keywords such as Taunt do nothing here.
Clause::Keyword { .. } => open_trigger = false,
}
}
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
Card {
def,
triggers,
auras,
on_cast,
in_pool,
}
}
pub fn is_spell(&self) -> bool {
self.def.card_type == CardType::Spell
}
pub fn has_trigger(&self, on: TriggerOn) -> bool {
self.triggers.iter().any(|t| t.on == on)
}
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
cards: HashMap<String, Card>,
}
impl CardLibrary {
pub fn new(defs: Vec<CardDef>) -> CardLibrary {
let mut library = CardLibrary::default();
for def in defs {
library.register(def);
}
library
}
/// Add a card, or replace the card with the same name. It joins the Assemble pool
/// if it is a minion with text.
pub fn register(&mut self, def: CardDef) {
self.insert(Card::compile(def, true));
}
/// Add a card made by Assemble. It never joins the pool.
pub fn register_assembled(&mut self, def: CardDef) {
self.insert(Card::compile(def, false));
}
fn insert(&mut self, card: Card) {
self.cards.insert(card.def.name.clone(), card);
}
pub fn contains(&self, name: &str) -> bool {
self.cards.contains_key(name)
}
pub fn get(&self, name: &str) -> &Card {
self.cards
.get(name)
.unwrap_or_else(|| panic!("unknown card {name:?}: add its file to cards/"))
}
/// The Assemble pool: every card with `in_pool`, sorted by name.
pub fn pool(&self) -> Vec<&Card> {
let mut pool: Vec<&Card> = self.cards.values().filter(|c| c.in_pool).collect();
pool.sort_by(|a, b| a.def.name.cmp(&b.def.name));
pool
}
}
@@ -0,0 +1,30 @@
//! The Death Creation Step: the one place minions leave play.
//!
//! Damage and destroy effects only mark a minion (0 or less Health, or pending
//! destroy). It stays in play, and keeps triggering, until the outermost Phase
//! ends. Then this step removes every marked minion at once.
use super::event::Event;
use super::world::World;
/// Remove every mortally wounded or pending-destroy minion from play, simultaneously.
/// Returns their Death Events in order of play; empty if nothing died.
pub fn death_creation_step(world: &mut World) -> Vec<Event> {
// Every position is read before anything moves: removal is simultaneous, and each
// Death Event remembers where its minion stood when the step began.
let mut deaths = Vec::new();
for id in world.minions_in_play() {
if world.is_doomed(id) {
let position = world
.board_index(id)
.expect("a minion in play is on the board");
deaths.push(Event::Destroyed { id, position });
}
}
for death in &deaths {
if let Event::Destroyed { id, position } = *death {
world.kill(id, position);
}
}
deaths
}
@@ -0,0 +1,91 @@
//! The interpreter: turns one effect, as data, into engine actions.
//!
//! This is the only place that knows what an effect *means*. A card's text is a
//! small tree (clauses holding effects holding targets); the engine walks it. The
//! effect vocabulary is closed: a card can only do what this match can do, which is
//! what makes a data card safe to load.
use super::card::{Effect, Target};
use super::queue::{Action, Ctx};
use super::world::EntityId;
/// The actions one effect performs, in order, given where and for whom it runs.
pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
let owner = ctx.owner;
match effect {
Effect::DealDamage { target, amount } => vec![Action::Damage {
targets: targets(*target, ctx),
amount: *amount,
}],
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.collect(),
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
}],
Effect::GainAttack { target, amount } => targets(*target, ctx)
.into_iter()
.map(|id| Action::GainAttack {
id,
amount: *amount,
})
.collect(),
Effect::Destroy { target } => vec![Action::Destroy {
targets: targets(*target, ctx),
}],
Effect::Assemble { filter } => vec![Action::Assemble {
player: owner,
filter: filter.clone(),
}],
Effect::BuffHand { attack, health } => ctx
.world
.minions_in_hand(owner)
.into_iter()
.map(|id| Action::Buff {
id,
attack: *attack,
health: *health,
})
.collect(),
Effect::SpawnText { .. } => panic!("spawnText needs a grid: it is a Part III effect"),
}
}
/// The entities a target names, from where the effect runs.
fn targets(target: Target, ctx: &Ctx) -> Vec<EntityId> {
let world = ctx.world;
let others = |ids: Vec<EntityId>| -> Vec<EntityId> {
ids.into_iter()
.filter(|&id| Some(id) != ctx.source)
.collect()
};
match target {
Target::This => vec![ctx.minion()],
Target::AllMinions => world.minions_in_play(),
Target::AllOtherMinions => others(world.minions_in_play()),
Target::AllOtherCharacters => {
let mut ids = vec![world.hero(0), world.hero(1)];
ids.extend(world.minions_in_play());
others(ids)
}
Target::EnemyHero => vec![ctx.enemy_hero()],
Target::Chosen => ctx.target.into_iter().collect(),
}
}
/// Where a summon goes: to the right of a source still on the board; where a dead
/// source stood (its Death Event's position); at the right end for a spell.
fn summon_position(ctx: &Ctx) -> Option<usize> {
match ctx.source.and_then(|s| ctx.world.board_index(s)) {
Some(index) => Some(index + 1),
None => ctx.position,
}
}
@@ -0,0 +1,51 @@
//! What happened, in order. The engine records an event each time it changes the game.
use super::card::TriggerOn;
use super::world::{EntityId, PlayerId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Summoned {
id: EntityId,
},
Damaged {
target: EntityId,
amount: i32,
},
Triggered {
source: EntityId,
on: TriggerOn,
},
Drew {
player: PlayerId,
id: EntityId,
},
Fatigue {
player: PlayerId,
},
ArmorGained {
player: PlayerId,
amount: i32,
},
StatsChanged {
id: EntityId,
attack: i32,
health: i32,
},
SpellCast {
card: String,
},
/// A Death Event: the Death Creation Step moved `id` from play to the graveyard.
/// `position` is the board index it had when the step began; its Deathrattle summons there.
Destroyed {
id: EntityId,
position: usize,
},
DeathPhase {
round: u32,
},
/// A minion was played from hand (it has just entered the board). Its Battlecry answers this.
Played {
id: EntityId,
},
}
@@ -0,0 +1,44 @@
//! Engine core: generic rules machinery. Never name a specific card here.
//!
//! Module 1: every card in the game is an entity addressed by an id handle.
//! The engine knows which zone each entity is in, who owns it, and when it
//! entered play.
//!
//! Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
//! action queue that resolves a Sequence depth-first, recording an event log.
//!
//! Module 3: deferred death. Damage and destroy effects only mark minions; the
//! Death Creation Step removes them when the outermost Phase ends, and Death
//! Phases resolve their Deathrattles until no new deaths occur.
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
//! minion from hand, and Battlecries.
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
//! Module 6: the grid, the lexer and the parser.
//! Module 7: `IS NOT`, and the resolution pass that turns parsed rules into rules in force.
mod assemble;
mod aura;
mod card;
mod death;
mod effect;
mod event;
pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -0,0 +1,103 @@
//! The grid: objects on tiles. `x` grows right, `y` grows down, and any number of
//! objects may share a tile.
use std::collections::BTreeMap;
use serde::Deserialize;
pub type ObjectId = u32;
/// A level in the course's level format (`course/schema/level.schema.json`).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct LevelDef {
pub width: i32,
pub height: i32,
#[serde(default)]
pub objects: Vec<LevelObject>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LevelObject {
pub kind: String,
pub x: i32,
pub y: i32,
}
/// What an object is: a thing named by a noun (kind `"<noun>"`, the noun in lower case),
/// or a piece of text carrying one word (kind `"text:<WORD>"`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
Thing { noun: String },
Text { word: String },
}
impl Kind {
const TEXT_PREFIX: &'static str = "text:";
pub fn parse(kind: &str) -> Kind {
match kind.strip_prefix(Self::TEXT_PREFIX) {
Some(word) => Kind::Text {
word: word.to_uppercase(),
},
None => Kind::Thing {
noun: kind.to_uppercase(),
},
}
}
}
#[derive(Debug, Clone)]
pub struct Object {
pub id: ObjectId,
pub kind: Kind,
pub x: i32,
pub y: i32,
}
#[derive(Debug, Clone, Default)]
pub struct Board {
pub width: i32,
pub height: i32,
objects: BTreeMap<ObjectId, Object>,
next_id: ObjectId,
}
impl Board {
pub fn new(width: i32, height: i32) -> Board {
Board {
width,
height,
..Default::default()
}
}
pub fn in_bounds(&self, x: i32, y: i32) -> bool {
(0..self.width).contains(&x) && (0..self.height).contains(&y)
}
/// Add an object; ids start at 1 and are never reused. Panics off the board.
pub fn place(&mut self, kind: Kind, x: i32, y: i32) -> ObjectId {
assert!(
self.in_bounds(x, y),
"({x}, {y}) is outside the {}x{} level",
self.width,
self.height
);
self.next_id += 1;
let id = self.next_id;
self.objects.insert(id, Object { id, kind, x, y });
id
}
/// The object `id`. Panics if there is none.
pub fn get(&self, id: ObjectId) -> &Object {
self.objects
.get(&id)
.unwrap_or_else(|| panic!("no object {id}"))
}
/// Every object, oldest first.
pub fn objects(&self) -> impl Iterator<Item = &Object> {
self.objects.values()
}
}
@@ -0,0 +1,69 @@
//! A level in play: the board, the words content provides, and the rules compiled
//! from them. Every change to the board recompiles the rules from scratch: parse,
//! then resolve. Every question about what an object is goes through `has_property`.
use super::grid::{Board, Kind, LevelDef, ObjectId};
use super::parse::{parse, Rule};
use super::resolve::resolve;
use super::word::{Lexicon, WordDef};
#[derive(Debug, Clone)]
pub struct Level {
board: Board,
lexicon: Lexicon,
rules: Vec<Rule>,
active: Vec<Rule>,
}
impl Level {
pub fn new(def: &LevelDef, words: &[WordDef]) -> Level {
let mut level = Level {
board: Board::new(def.width, def.height),
lexicon: Lexicon::new(words),
rules: vec![],
active: vec![],
};
for o in &def.objects {
level.board.place(Kind::parse(&o.kind), o.x, o.y);
}
level.reparse();
level
}
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId {
let id = self.board.place(Kind::parse(kind), x, y);
self.reparse();
id
}
pub fn board(&self) -> &Board {
&self.board
}
/// Every rule the text spells, as parsed: negated rules and duplicates included.
pub fn rules(&self) -> &[Rule] {
&self.rules
}
/// The rules in force after the resolution pass.
pub fn active_rules(&self) -> &[Rule] {
&self.active
}
/// Does object `id` have `property`? Yes if an active rule gives it to a noun
/// that names the object and no active negated rule takes it from one.
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
let kind = &self.board.get(id).kind;
let about_it: Vec<&Rule> = self
.active
.iter()
.filter(|r| r.complement == property && self.lexicon.names(&r.subject, kind))
.collect();
about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated)
}
fn reparse(&mut self) {
self.rules = parse(&self.board, &self.lexicon);
self.active = resolve(&self.rules);
}
}
@@ -0,0 +1,23 @@
//! Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is
//! compiled from the text on the board, and recompiled whenever the board changes.
//!
//! Module 6: the grid, the lexer (text objects to words with grammatical roles)
//! and the parser (words to rules). The engine knows the operators `IS`, `AND`,
//! `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word,
//! nouns included, is data handed in by content.
//!
//! Module 7: `IS NOT` in the parser, and the resolution pass (negation, then the
//! identity lock) that turns the parsed rules into the rules in force. Objects are
//! asked about through `Level::has_property`, which knows which nouns name them.
mod grid;
mod level;
mod parse;
mod resolve;
mod word;
pub use grid::{Board, Kind, LevelDef, LevelObject, Object, ObjectId};
pub use level::Level;
pub use parse::{parse, Rule, BASE_RULES};
pub use resolve::resolve;
pub use word::{lex, Lexicon, Role, Selects, Token, WordDef};
@@ -0,0 +1,166 @@
//! The parser: words on the grid become rules.
//!
//! Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`,
//! read left to right in a row and top to bottom in a column. The parser starts at
//! every `IS`, collects the subjects before it and the complements after it, and
//! emits one rule per pair: AND distributes over both sides. A tile holding several
//! words contributes each of them, so stacked text yields every possible rule.
//!
//! `NOT` before a complement negates that one complement. `NOT` before a subject is
//! not in the grammar: the run of subjects stops there and that subject is dropped.
//!
//! The parser only reads; which rules win is the resolution pass (`resolve`).
use std::collections::BTreeMap;
use std::fmt;
use super::grid::{Board, ObjectId};
use super::word::{lex, Lexicon, Role, Token, AND, IS, NOT, TEXT};
/// One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`.
/// `text` is the text objects that spell it (subject, `IS`, `NOT` if any,
/// complement), or empty for a base rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub subject: String,
pub complement: String,
pub complement_role: Role,
pub negated: bool,
pub text: Vec<ObjectId>,
}
impl Rule {
/// `X IS Y` where `Y` is a noun: every X is to become a Y.
pub fn is_conversion(&self) -> bool {
self.complement_role == Role::Noun
}
/// `X IS X` (or `X IS NOT X`).
pub fn is_identity(&self) -> bool {
self.subject == self.complement
}
/// Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`.
pub fn negates(&self, other: &Rule) -> bool {
self.negated
&& !other.negated
&& self.subject == other.subject
&& self.complement == other.complement
}
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let not = if self.negated { " NOT" } else { "" };
write!(f, "{} {IS}{not} {}", self.subject, self.complement)
}
}
/// Rules in force with no text: the game's base rules (`(subject, property)`).
pub const BASE_RULES: &[(&str, &str)] = &[(TEXT, "PUSH")];
type Tiles = BTreeMap<(i32, i32), Vec<Token>>;
/// A word in a run, with the `NOT` in front of it if there is one.
struct Word<'a> {
not: Option<&'a Token>,
token: &'a Token,
}
/// Every rule the board spells, base rules first, negated rules included. A rule
/// spelled by two different sets of text objects appears twice.
pub fn parse(board: &Board, lexicon: &Lexicon) -> Vec<Rule> {
let tiles = lex(board, lexicon);
let mut rules: Vec<Rule> = BASE_RULES
.iter()
.map(|&(subject, property)| Rule {
subject: subject.to_string(),
complement: property.to_string(),
complement_role: Role::Property,
negated: false,
text: vec![],
})
.collect();
for (&(x, y), tokens) in &tiles {
for is in tokens.iter().filter(|t| t.word == IS) {
for (dx, dy) in [(1, 0), (0, 1)] {
let subjects = subjects(&tiles, (x - dx, y - dy), (-dx, -dy));
let complements = complements(&tiles, (x + dx, y + dy), (dx, dy));
for s in &subjects {
for c in &complements {
let mut text = vec![s.id, is.id];
text.extend(c.not.map(|n| n.id));
text.push(c.token.id);
rules.push(Rule {
subject: s.word.clone(),
complement: c.token.word.clone(),
complement_role: c.token.role,
negated: c.not.is_some(),
text,
});
}
}
}
}
}
rules
}
fn words_at(tiles: &Tiles, p: (i32, i32)) -> &[Token] {
tiles.get(&p).map(Vec::as_slice).unwrap_or(&[])
}
fn holds(tiles: &Tiles, p: (i32, i32), word: &str) -> bool {
words_at(tiles, p).iter().any(|t| t.word == word)
}
fn ahead(p: (i32, i32), step: (i32, i32)) -> (i32, i32) {
(p.0 + step.0, p.1 + step.1)
}
/// The run of subjects before an `IS`, walking backwards from `at`: the nouns at
/// `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT`
/// before a noun ends the run without it (a negated subject is not in the grammar).
fn subjects(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec<&Token> {
let before = ahead(at, step);
if holds(tiles, before, NOT) {
return vec![];
}
let mut found: Vec<&Token> = words_at(tiles, at)
.iter()
.filter(|t| t.role == Role::Noun)
.collect();
if !found.is_empty() && holds(tiles, before, AND) {
found.extend(subjects(tiles, ahead(before, step), step));
}
found
}
/// The run of complements after an `IS`, walking forwards from `at`: the nouns
/// and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it,
/// negated; then, if the next tile holds an AND, the run beyond it.
fn complements(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec<Word<'_>> {
let fits = |t: &&Token| matches!(t.role, Role::Noun | Role::Property);
let mut found = vec![];
// Plain words here, and negated words one tile on: a stack may hold both.
let mut branches: Vec<(Option<&Token>, (i32, i32))> = vec![(None, at)];
for not in words_at(tiles, at).iter().filter(|t| t.word == NOT) {
branches.push((Some(not), ahead(at, step)));
}
for (not, p) in branches {
let words: Vec<Word> = words_at(tiles, p)
.iter()
.filter(fits)
.map(|token| Word { not, token })
.collect();
if words.is_empty() {
continue;
}
found.extend(words);
let and_at = ahead(p, step);
if holds(tiles, and_at, AND) {
found.extend(complements(tiles, ahead(and_at, step), step));
}
}
found
}
@@ -0,0 +1,33 @@
//! The resolution pass: from every rule the text spells to the rules in force.
//!
//! The parser reports what is written; this pass decides what wins, over the whole
//! rule list at once, so it doesn't matter where on the board each rule is written:
//!
//! 1. **Negation.** `X IS NOT Y` cancels every `X IS Y`. The negated rule stays in
//! force: it keeps overriding, and `X IS NOT X` is also the record that every X is
//! to vanish (the turn removes them, Module 9).
//! 2. **Identity.** A surviving `X IS X` cancels every conversion `X IS Y` (`Y` a noun):
//! X can't become anything else. It doesn't stop other nouns becoming X. Because
//! negation runs first, `X IS NOT X` cancels `X IS X` and conversions work again.
//!
//! Base rules are rules like any other, so `TEXT IS NOT PUSH` cancels `TEXT IS PUSH`.
use super::parse::Rule;
/// The rules in force, in the order they were parsed.
pub fn resolve(rules: &[Rule]) -> Vec<Rule> {
let survives_negation: Vec<&Rule> = rules
.iter()
.filter(|r| !rules.iter().any(|n| n.negates(r)))
.collect();
let locked = |subject: &str| {
survives_negation
.iter()
.any(|r| !r.negated && r.is_identity() && r.subject == subject)
};
survives_negation
.iter()
.filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(&r.subject))
.map(|&r| r.clone())
.collect()
}
@@ -0,0 +1,119 @@
//! The lexer: text objects become words with a grammatical role.
//!
//! The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`.
//! Every other word comes from content as data: the engine never names a noun.
//! A noun also says which objects it names (`Selects`), so a meta-noun that names
//! a whole group of objects can be data too.
use std::collections::{BTreeMap, HashMap};
use serde::Deserialize;
use super::grid::{Board, Kind, ObjectId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Noun,
Property,
Operator,
}
/// Which objects a noun names when it is the subject of a rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Selects {
/// The things of its own name: `<NOUN>` names every object of kind `"<noun>"`.
#[default]
Named,
/// Every text object (the meta-noun `TEXT`).
Text,
/// Every object that is not text, whatever its noun.
Things,
}
/// One entry of the word list content provides: `{ "name": "<NOUN>", "role": "noun" }`,
/// optionally with `"selects"` (default `"named"`).
#[derive(Debug, Clone, Deserialize)]
pub struct WordDef {
pub name: String,
pub role: Role,
#[serde(default)]
pub selects: Selects,
}
pub const IS: &str = "IS";
pub const AND: &str = "AND";
pub const NOT: &str = "NOT";
/// The meta-noun for every text object.
pub const TEXT: &str = "TEXT";
/// Every word the parser can read, with its role.
#[derive(Debug, Clone, Default)]
pub struct Lexicon {
roles: HashMap<String, Role>,
selects: HashMap<String, Selects>,
}
impl Lexicon {
/// The engine's own words plus `words`. Content can't redefine an engine word.
pub fn new(words: &[WordDef]) -> Lexicon {
let mut roles: HashMap<String, Role> = words
.iter()
.map(|w| (w.name.to_uppercase(), w.role))
.collect();
let mut selects: HashMap<String, Selects> = words
.iter()
.map(|w| (w.name.to_uppercase(), w.selects))
.collect();
for op in [IS, AND, NOT] {
roles.insert(op.to_string(), Role::Operator);
}
roles.insert(TEXT.to_string(), Role::Noun);
selects.insert(TEXT.to_string(), Selects::Text);
Lexicon { roles, selects }
}
/// `None` for a word the lexicon doesn't know: such text spells nothing.
pub fn role(&self, word: &str) -> Option<Role> {
self.roles.get(word).copied()
}
/// Does the noun `word`, as the subject of a rule, name an object of this kind?
pub fn names(&self, word: &str, kind: &Kind) -> bool {
let selects = self.selects.get(word).copied().unwrap_or_default();
match (selects, kind) {
(Selects::Named, Kind::Thing { noun }) => noun == word,
(Selects::Text, Kind::Text { .. }) => true,
(Selects::Things, Kind::Thing { .. }) => true,
_ => false,
}
}
}
/// A piece of text the parser can read: which object, which word, which role.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub id: ObjectId,
pub word: String,
pub role: Role,
}
/// The words on the board, by tile; a tile's words in object order (oldest first).
/// Text whose word the lexicon doesn't know is left out.
pub fn lex(board: &Board, lexicon: &Lexicon) -> BTreeMap<(i32, i32), Vec<Token>> {
let mut tiles: BTreeMap<(i32, i32), Vec<Token>> = BTreeMap::new();
for object in board.objects() {
let Kind::Text { word } = &object.kind else {
continue;
};
if let Some(role) = lexicon.role(word) {
tiles.entry((object.x, object.y)).or_default().push(Token {
id: object.id,
word: word.clone(),
role,
});
}
}
tiles
}
@@ -0,0 +1,285 @@
//! The action queue: how one player action (a Sequence) resolves.
//!
//! Performing an action changes the world and produces events. Every trigger that
//! responds to those events goes into one Queue, oldest listener first, and the Queue
//! is locked. Each queued trigger then resolves completely, including everything
//! its own actions cause, before the next one starts: depth-first.
//!
//! Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
//! Death Creation Step removes the dead, and a Death Phase resolves their
//! Deathrattles. That repeats until a step finds no new deaths.
//!
//! Module 4: a queued trigger may activate more than once. How many times is
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
use super::event::Event;
use super::world::{EntityId, PlayerId, World, Zone};
/// Something the engine can do. Card effects return these instead of changing the world.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Deal `amount` to each target at once: all damage first, then the reactions.
Damage {
targets: Vec<EntityId>,
amount: i32,
},
Draw {
player: PlayerId,
},
GainArmor {
player: PlayerId,
amount: i32,
},
GainAttack {
id: EntityId,
amount: i32,
},
Cast {
card: String,
player: PlayerId,
target: Option<EntityId>,
},
/// Mark each target pending destroy. They leave play in the next Death Creation Step.
Destroy {
targets: Vec<EntityId>,
},
/// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`.
Summon {
card: String,
player: PlayerId,
at: Option<usize>,
},
/// Play a minion from its owner's hand: it enters the right end of the board, then its
/// Battlecry resolves.
Play {
id: EntityId,
},
/// Put an Assembled minion into `player`'s hand (see `assemble.rs`).
Assemble {
player: PlayerId,
filter: PoolFilter,
},
/// Give one entity +attack/+health.
Buff {
id: EntityId,
attack: i32,
health: i32,
},
}
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
/// The player the effect works for.
pub owner: PlayerId,
/// The minion whose trigger is resolving; `None` for a spell.
pub source: Option<EntityId>,
/// The chosen target, if any.
pub target: Option<EntityId>,
/// The source's board position. For a Deathrattle, the source has left play,
/// and this is the position its Death Event remembered.
pub position: Option<usize>,
}
impl Ctx<'_> {
/// The minion whose trigger is resolving.
pub fn minion(&self) -> EntityId {
self.source.expect("only a triggered ability has a source")
}
pub fn enemy_hero(&self) -> EntityId {
self.world.hero(1 - self.owner)
}
}
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
// The outermost Phase has ended. Each Death Phase is itself an outermost
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let mut deaths = end_of_outermost_phase(world);
let mut round = 0;
while !deaths.is_empty() {
round += 1;
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
}
}
/// The steps that run only when the outermost Phase ends; returns the Death Events.
/// Hearthstone runs an Aura Update (Health/Attack) first. The course has no
/// Health/Attack auras, so that step has nothing to do and is left out.
fn end_of_outermost_phase(world: &mut World) -> Vec<Event> {
let deaths = death_creation_step(world);
// After the step: a minion that just died no longer contributes its auras
// to the Death Phase that resolves these deaths.
aura_update_other(world);
deaths
}
/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an
/// Aura Update.
pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option<usize>) -> EntityId {
let id = world.create_at(card, player, Zone::Board, at);
aura_update_other(world);
id
}
fn perform(world: &mut World, action: Action) {
match action {
Action::Damage {
mut targets,
amount,
} => {
// Area damage: every Damage Event is created, in order of play, before any reaction.
targets.sort_by_key(|&id| world.order_of_play(id));
let mut events = Vec::new();
for target in targets {
world.damage(target, amount);
events.push(Event::Damaged { target, amount });
}
react(world, &events);
}
Action::Draw { player } => world.draw(player),
Action::GainArmor { player, amount } => world.gain_armor(player, amount),
Action::GainAttack { id, amount } => world.gain_attack(id, amount),
Action::Destroy { targets } => {
for target in targets {
world.mark_destroyed(target);
}
}
Action::Summon { card, player, at } => {
summon(world, &card, player, at);
}
Action::Play { id } => {
assert!(
world.zone_of(id) == Zone::Hand,
"play: entity {id} is not in a hand"
);
// Playing is not summoning: no Aura Update here. The played minion's own
// auras join at the end of the outermost Phase.
world.move_to(id, Zone::Board);
let played = Event::Played { id };
world.record(played.clone());
react(world, &[played]);
}
Action::Cast {
card,
player,
target,
} => {
let spell = world.card(&card);
assert!(spell.is_spell(), "{card:?} is not a spell");
let effects = spell.on_cast.clone();
world.record(Event::SpellCast { card });
run_effects(world, &effects, player, None, target, None);
}
Action::Assemble { player, filter } => {
assemble_from_pool(world, player, &filter);
}
Action::Buff { id, attack, health } => world.buff(id, attack, health),
}
}
/// Interpret and perform an ability's effects in printed order. Each effect sees the
/// world as the effects before it left it. Depth-first: each action, and every
/// reaction it causes, finishes before the next.
fn run_effects(
world: &mut World,
effects: &[Effect],
owner: PlayerId,
source: Option<EntityId>,
target: Option<EntityId>,
position: Option<usize>,
) {
for effect in effects {
let ctx = Ctx {
world,
owner,
source,
target,
position,
};
for action in actions(effect, &ctx) {
perform(world, action);
}
}
}
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, trigger, event) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
for _ in 0..times {
world.record(Event::Triggered {
source,
on: trigger.on,
});
let position = match *event {
Event::Destroyed { id, position } if id == source => Some(position),
_ => world.board_index(source),
};
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
}
}
}
/// Who may respond to `event`, in order of play: every minion in play and, for a
/// Death Event, the minion that died. It has left play, but its Deathrattle still answers.
fn listeners(world: &World, event: &Event) -> Vec<EntityId> {
let mut ids = world.minions_in_play();
if let Event::Destroyed { id, .. } = *event {
ids.push(id);
ids.sort_by_key(|&id| world.order_of_play(id));
}
ids
}
/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`?
fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool {
if let Event::Destroyed { id, .. } = *event {
return on == TriggerOn::Deathrattle && id == listener;
}
if let Event::Played { id } = *event {
return on == TriggerOn::Battlecry && id == listener;
}
let Event::Damaged { target, .. } = *event else {
return false;
};
let is_minion = !world.is_hero(target);
match on {
TriggerOn::SelfDamaged => target == listener,
TriggerOn::FriendlyMinionDamaged => {
is_minion && world.owner_of(target) == world.owner_of(listener)
}
TriggerOn::AnyMinionDamaged => is_minion,
TriggerOn::HeroDamaged => !is_minion,
TriggerOn::Deathrattle | TriggerOn::Battlecry | TriggerOn::EndOfTurn => false,
}
}
@@ -0,0 +1,378 @@
//! Entities, zones, order of play, stats and the event log: the game's state.
//! The rules for resolving actions live in `queue.rs`.
use super::card::{Aura, Card, CardDef, CardLibrary};
use super::event::Event;
/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone.
pub type EntityId = u32;
pub type PlayerId = u8;
/// Starting Health of each hero.
pub const HERO_HEALTH: i32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Zone {
Deck,
Hand,
Board,
Graveyard,
}
#[derive(Debug)]
struct Entity {
card: String,
owner: PlayerId,
zone: Zone,
/// Stamped each time the entity enters the board; `None` until it first does.
order_of_play: Option<u32>,
attack: i32,
/// Current Health. At 0 or below the entity is mortally wounded: still in play until the
/// Death Creation Step.
health: i32,
/// Hit by a destroy effect: still in play until the Death Creation Step.
pending_destroy: bool,
}
/// An aura in force: which player's minion has it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
pub aura: Aura,
}
/// One player's zones. Each is an ordered list of ids (the board is left to right).
#[derive(Debug, Default)]
struct Side {
deck: Vec<EntityId>,
hand: Vec<EntityId>,
board: Vec<EntityId>,
graveyard: Vec<EntityId>,
hero: EntityId,
armor: i32,
}
impl Side {
fn zone(&self, zone: Zone) -> &Vec<EntityId> {
match zone {
Zone::Deck => &self.deck,
Zone::Hand => &self.hand,
Zone::Board => &self.board,
Zone::Graveyard => &self.graveyard,
}
}
fn zone_mut(&mut self, zone: Zone) -> &mut Vec<EntityId> {
match zone {
Zone::Deck => &mut self.deck,
Zone::Hand => &mut self.hand,
Zone::Board => &mut self.board,
Zone::Graveyard => &mut self.graveyard,
}
}
}
/// Makes a player's choices: called with what is being chosen and the options, it
/// returns one of the options. Hearthstone would ask the player or pick at random.
pub type Chooser = Box<dyn FnMut(&str, &[String]) -> String>;
pub struct World {
library: CardLibrary,
chooser: Chooser,
/// Indexed by `EntityId`.
entities: Vec<Entity>,
sides: [Side; 2],
next_order_of_play: u32,
log: Vec<Event>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
impl World {
/// A new game: both heroes are in play from the start, so they are the oldest entities.
pub fn new(library: CardLibrary) -> World {
let mut world = World {
library,
// Until a chooser is set, take the first option.
chooser: Box::new(|_, options| options[0].clone()),
entities: Vec::new(),
sides: Default::default(),
next_order_of_play: 0,
log: Vec::new(),
auras: Vec::new(),
};
for owner in 0..2 {
let id = world.entities.len() as EntityId;
world.next_order_of_play += 1;
world.entities.push(Entity {
card: "Hero".to_string(),
owner,
zone: Zone::Board,
order_of_play: Some(world.next_order_of_play),
attack: 0,
health: HERO_HEALTH,
pending_destroy: false,
});
// In play, but not on the board list: the board holds minions only.
world.sides[owner as usize].hero = id;
}
world
}
/// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board).
pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId {
self.create_at(card, owner, zone, None)
}
/// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the
/// zone's length), or at the end if `at` is `None`.
pub fn create_at(
&mut self,
card: &str,
owner: PlayerId,
zone: Zone,
at: Option<usize>,
) -> EntityId {
assert!(owner < 2, "no player {owner}");
let def = &self.library.get(card).def;
let entity = Entity {
card: def.name.clone(),
owner,
zone,
order_of_play: None,
attack: def.attack,
health: def.health,
pending_destroy: false,
};
let id = self.entities.len() as EntityId;
self.entities.push(entity);
self.enter(id, zone, at);
id
}
/// Move an entity to the end of `zone` on its owner's side, keeping its id.
pub fn move_to(&mut self, id: EntityId, zone: Zone) {
let (owner, from) = (self.entity(id).owner, self.entity(id).zone);
self.sides[owner as usize]
.zone_mut(from)
.retain(|&e| e != id);
self.enter(id, zone, None);
}
/// The single place an entity arrives in a zone. Entering the board starts a new order of play.
fn enter(&mut self, id: EntityId, zone: Zone, at: Option<usize>) {
let owner = self.entity(id).owner;
let list = self.sides[owner as usize].zone_mut(zone);
list.insert(at.map_or(list.len(), |i| i.min(list.len())), id);
let entity = &mut self.entities[id as usize];
entity.zone = zone;
if zone == Zone::Board {
self.next_order_of_play += 1;
entity.order_of_play = Some(self.next_order_of_play);
self.record(Event::Summoned { id });
}
}
fn entity(&self, id: EntityId) -> &Entity {
self.entities
.get(id as usize)
.unwrap_or_else(|| panic!("unknown entity id {id}"))
}
fn entity_mut(&mut self, id: EntityId) -> &mut Entity {
self.entities
.get_mut(id as usize)
.unwrap_or_else(|| panic!("unknown entity id {id}"))
}
// ---- Queries -------------------------------------------------------
pub fn card(&self, name: &str) -> &Card {
self.library.get(name)
}
pub fn has_card(&self, name: &str) -> bool {
self.library.contains(name)
}
/// The cards an Assemble may offer, sorted by name.
pub fn pool(&self) -> Vec<&Card> {
self.library.pool()
}
pub fn zone_of(&self, id: EntityId) -> Zone {
self.entity(id).zone
}
pub fn card_name(&self, id: EntityId) -> &str {
&self.entity(id).card
}
pub fn owner_of(&self, id: EntityId) -> PlayerId {
self.entity(id).owner
}
/// When the entity last entered the board (larger is later); `None` if it never has.
pub fn order_of_play(&self, id: EntityId) -> Option<u32> {
self.entity(id).order_of_play
}
/// The ids in one player's zone, in zone order (left to right for the board).
pub fn zone(&self, owner: PlayerId, zone: Zone) -> &[EntityId] {
assert!(owner < 2, "no player {owner}");
self.sides[owner as usize].zone(zone)
}
pub fn hero(&self, owner: PlayerId) -> EntityId {
assert!(owner < 2, "no player {owner}");
self.sides[owner as usize].hero
}
pub fn is_hero(&self, id: EntityId) -> bool {
self.sides.iter().any(|s| s.hero == id)
}
/// Every minion on either board, oldest first (order of play).
pub fn minions_in_play(&self) -> Vec<EntityId> {
let mut ids: Vec<EntityId> = self.sides.iter().flat_map(|s| s.board.clone()).collect();
ids.sort_by_key(|&id| self.order_of_play(id));
ids
}
pub fn attack(&self, id: EntityId) -> i32 {
self.entity(id).attack
}
pub fn health(&self, id: EntityId) -> i32 {
self.entity(id).health
}
/// The cost printed on the entity's card.
pub fn cost(&self, id: EntityId) -> i32 {
self.card(self.card_name(id)).def.cost
}
/// The minions in a player's hand, in hand order.
pub fn minions_in_hand(&self, owner: PlayerId) -> Vec<EntityId> {
self.zone(owner, Zone::Hand)
.iter()
.copied()
.filter(|&id| !self.card(self.card_name(id)).is_spell())
.collect()
}
/// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next
/// Death Creation Step.
pub fn is_doomed(&self, id: EntityId) -> bool {
let entity = self.entity(id);
entity.health <= 0 || entity.pending_destroy
}
/// Index of a minion on its owner's board, left to right; `None` if it is not on the board.
pub fn board_index(&self, id: EntityId) -> Option<usize> {
self.zone(self.owner_of(id), Zone::Board)
.iter()
.position(|&e| e == id)
}
pub fn armor(&self, owner: PlayerId) -> i32 {
self.sides[owner as usize].armor
}
pub fn active_auras(&self) -> &[ActiveAura] {
&self.auras
}
pub fn log(&self) -> &[Event] {
&self.log
}
// ---- Changes: each one records its event ----------------------------
pub fn record(&mut self, event: Event) {
self.log.push(event);
}
/// Add a card to the library, or replace the card with the same name.
pub fn register(&mut self, def: CardDef) {
self.library.register(def);
}
/// Add a card made by Assemble (see `assemble.rs`).
pub fn register_assembled(&mut self, def: CardDef) {
self.library.register_assembled(def);
}
pub fn set_chooser(&mut self, chooser: Chooser) {
self.chooser = chooser;
}
/// Ask the chooser to pick one of `options` (not empty). Panics if it answers
/// something that isn't an option.
pub fn choose(&mut self, kind: &str, options: &[String]) -> String {
let answer = (self.chooser)(kind, options);
assert!(
options.contains(&answer),
"chooser answered {answer:?} for {kind:?}; the options were {options:?}"
);
answer
}
/// Only an Aura Update calls this.
pub fn set_active_auras(&mut self, auras: Vec<ActiveAura>) {
self.auras = auras;
}
pub fn clear_log(&mut self) {
self.log.clear();
}
pub fn damage(&mut self, target: EntityId, amount: i32) {
self.entity_mut(target).health -= amount;
self.record(Event::Damaged { target, amount });
}
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
match self.zone(player, Zone::Deck).first() {
Some(&id) => {
self.move_to(id, Zone::Hand);
self.record(Event::Drew { player, id });
}
None => self.record(Event::Fatigue { player }),
}
}
/// Mark for destruction. Nothing leaves play until the Death Creation Step.
pub fn mark_destroyed(&mut self, id: EntityId) {
self.entity_mut(id).pending_destroy = true;
}
/// Move a minion from play to its owner's graveyard, recording its Death Event.
pub fn kill(&mut self, id: EntityId, position: usize) {
self.entity_mut(id).pending_destroy = false;
self.move_to(id, Zone::Graveyard);
self.record(Event::Destroyed { id, position });
}
pub fn gain_armor(&mut self, player: PlayerId, amount: i32) {
self.sides[player as usize].armor += amount;
self.record(Event::ArmorGained { player, amount });
}
pub fn buff(&mut self, id: EntityId, attack: i32, health: i32) {
let entity = self.entity_mut(id);
entity.attack += attack;
entity.health += health;
let (attack, health) = (entity.attack, entity.health);
self.record(Event::StatsChanged { id, attack, health });
}
pub fn gain_attack(&mut self, id: EntityId, amount: i32) {
let entity = self.entity_mut(id);
entity.attack += amount;
let (attack, health) = (entity.attack, entity.health);
self.record(Event::StatsChanged { id, attack, health });
}
}
@@ -0,0 +1,240 @@
//! Test harness: the thin adapter the course tests call. It translates each
//! call into engine operations and holds no rules of its own.
use crate::content;
use crate::engine::{self, puzzle, run_sequence, summon, Action, CardLibrary, Event, World};
pub use crate::engine::{AssembleError, CardDef, Half, Zone};
pub type Id = u32;
pub type Player = u8;
#[derive(Debug, Default, Clone)]
pub struct GameOpts {
pub decks: Option<[Vec<String>; 2]>,
}
/// The events the tests read from `log()` (see course/harness.md).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GameEvent {
Summoned { id: Id },
Damaged { target: Id, amount: i32 },
Triggered { source: Id, trigger: String },
Destroyed { id: Id },
DeathPhase { round: u32 },
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
impl GameEvent {
/// The course's view of an engine event; `None` for engine events the course log
/// doesn't show.
fn from_engine(event: &Event) -> Option<GameEvent> {
Some(match event.clone() {
Event::Summoned { id } => GameEvent::Summoned { id },
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
Event::Triggered { source, on } => GameEvent::Triggered {
source,
trigger: on.label().to_string(),
},
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
Event::SpellCast { card } => GameEvent::SpellCast { card },
Event::Destroyed { id, .. } => GameEvent::Destroyed { id },
Event::DeathPhase { round } => GameEvent::DeathPhase { round },
// A played minion's `Summoned` already shows it entering the board.
Event::Played { .. } => return None,
})
}
}
pub struct Game {
world: World,
}
pub fn new_game(opts: GameOpts) -> Game {
let mut world = World::new(CardLibrary::new(content::cards()));
if let Some(decks) = opts.decks {
for (player, deck) in decks.iter().enumerate() {
for card in deck {
world.create(card, player as Player, Zone::Deck);
}
}
}
Game { world }
}
impl Game {
/// Put `card` at the right end of `player`'s board.
pub fn summon(&mut self, card: &str, player: Player) -> Id {
summon(&mut self.world, card, player, None)
}
pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Hand)
}
pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Deck)
}
/// Play a card from hand, as one Sequence: it enters the right end of the board, then
/// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.)
pub fn play(&mut self, hand_id: Id) {
run_sequence(&mut self.world, Action::Play { id: hand_id });
}
pub fn zone_of(&self, id: Id) -> Zone {
self.world.zone_of(id)
}
/// Board ids, left to right.
pub fn board(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Board).to_vec()
}
pub fn hand(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Hand).to_vec()
}
pub fn order_of_play(&self, id: Id) -> u32 {
self.world
.order_of_play(id)
.unwrap_or_else(|| panic!("entity {id} has not entered play"))
}
pub fn name_of(&self, id: Id) -> String {
self.world.card_name(id).to_string()
}
// ---- Module 2 ----
pub fn hero(&self, player: Player) -> Id {
self.world.hero(player)
}
/// Deal damage to one character, as one Sequence.
pub fn deal_damage(&mut self, target: Id, amount: i32) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Damage { targets, amount });
}
pub fn cast(&mut self, card: &str, player: Player, target: Option<Id>) {
let card = card.to_string();
run_sequence(
&mut self.world,
Action::Cast {
card,
player,
target,
},
);
}
pub fn health(&self, id: Id) -> i32 {
self.world.health(id)
}
pub fn attack(&self, id: Id) -> i32 {
self.world.attack(id)
}
pub fn armor(&self, player: Player) -> i32 {
self.world.armor(player)
}
pub fn log(&self) -> Vec<GameEvent> {
self.world
.log()
.iter()
.filter_map(GameEvent::from_engine)
.collect()
}
pub fn clear_log(&mut self) {
self.world.clear_log();
}
// ---- Module 3 ----
/// Mark `target` pending destroy, as one Sequence. It leaves play in the death step.
pub fn destroy(&mut self, target: Id) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Destroy { targets });
}
// ---- Module 5 ----
/// Add card definitions to this game, replacing any with the same name.
pub fn register_cards(&mut self, defs: Vec<CardDef>) {
for def in defs {
self.world.register(def);
}
}
/// Register `base` with `half` of its text replaced by the same half of `donor`,
/// and return the new card's name, `"<base> (Assembled)"`.
pub fn assemble(
&mut self,
base: &str,
donor: &str,
half: Half,
) -> Result<String, AssembleError> {
let base = &self.world.card(base).def;
let donor = &self.world.card(donor).def;
let def = engine::assemble(base, donor, half)?;
Ok(engine::register_assembled(&mut self.world, def))
}
pub fn cost(&self, id: Id) -> i32 {
self.world.cost(id)
}
/// Who makes choices (Assemble's base, donor and half): called with the kind of
/// choice (`"base"`, `"donor"`, `"half"`) and the options; returns one of them.
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.world.set_chooser(Box::new(chooser));
}
}
// ======== Part II: Baba Is You ========
pub use crate::engine::puzzle::{LevelDef, LevelObject};
/// A Baba Is You level in play (Part II).
pub struct Baba {
level: puzzle::Level,
}
/// A level from a level definition; its text is parsed straight away.
pub fn new_baba(level: LevelDef) -> Baba {
Baba {
level: puzzle::Level::new(&level, &content::words()),
}
}
impl Baba {
// ---- Module 6 ----
/// Add an object (`"baba"`) or a piece of text (`"text:BABA"`) at (x, y).
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id {
self.level.place(kind, x, y)
}
/// Every distinct rule the text spells, before resolution, normalised
/// (`"BABA IS YOU"`, `"BABA IS NOT YOU"`) and sorted.
pub fn rules(&self) -> Vec<String> {
normalised(self.level.rules())
}
// ---- Module 7 ----
/// Every distinct rule in force after resolution, normalised and sorted.
pub fn active_rules(&self) -> Vec<String> {
normalised(self.level.active_rules())
}
/// Does object `id` have the property `prop` under the rules in force?
pub fn has_property(&self, id: Id, prop: &str) -> bool {
self.level.has_property(id, prop)
}
}
fn normalised(rules: &[puzzle::Rule]) -> Vec<String> {
let mut rules: Vec<String> = rules.iter().map(|r| r.to_string()).collect();
rules.sort();
rules.dedup();
rules
}
@@ -0,0 +1,3 @@
pub mod content;
pub mod engine;
pub mod harness;
@@ -0,0 +1 @@
{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "07" }
@@ -0,0 +1 @@
node_modules/
@@ -0,0 +1,17 @@
# Module 7 reference engine (TypeScript)
The course's own engine as of the end of Module 7. Part I's card engine is unchanged, and all of its tests still pass. The Part II engine in `src/engine/puzzle/` now reads `IS NOT` and resolves the parsed rules into the rules in force. Module 6's reference was the starting point, so a diff between the two shows what this module adds.
- `cards/` (at the package root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it.
- `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT` and the property `PUSH` of the base rule `TEXT IS PUSH`.
- `grid.ts`: the level format, object kinds and the board. Module 7 adds `Board.get`.
- `word.ts`: the lexicon and the lexer. Module 7 adds `Selects`: which objects a noun names as a subject (its own things by default, every text object for `TEXT`, or every non-text object), read from the word list's optional `"selects"` field, and `Lexicon.names`.
- `parse.ts`: the parser. Module 7 adds `NOT`: before a complement it negates that complement (`Rule.negated`); before a subject it ends the run of subjects without that subject. Each rule also records its complement's role, so a conversion (`X IS <noun>`) can be told apart.
- `resolve.ts` (new): the resolution pass. Negation first (`X IS NOT Y` cancels every `X IS Y` and stays in force), then the identity lock (a surviving `X IS X` cancels `X IS Y` for nouns `Y`).
- `level.ts`: a level in play. Every change to the board parses, then resolves. `hasProperty` answers for one object from the rules in force.
- `index.ts`: the module's exports.
- The rest of `src/engine/`: Part I, unchanged.
- `src/content/words.json`: unchanged from Module 6.
- `src/harness.ts`: the adapter the course tests call. Module 7 adds `activeRules` and `hasProperty` on `Baba`; `rules()` now lists negated rules too (`"BABA IS NOT YOU"`).
Run it with the module's tests: `node tools/verify-references.mjs 07` from the course repository.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "systemic-design-workspace",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
@@ -0,0 +1,32 @@
/// <reference types="vite/client" />
// Content: the cards and the Baba Is You words. Only this directory (and the card
// data in `cards/`) may name specific cards, or specific Baba Is You nouns.
//
// From Module 5 every card is a JSON file in `cards/` at the package root, in the
// course's card format (`course/schema/card.schema.json`). The functions that were
// card effects in Modules 2–4 are gone: the engine interprets the data.
// `start-module` copies the course's card files into `cards/`; add your own there.
//
// `import.meta.glob` is Vite's (Vitest runs on Vite): it imports every matching
// JSON file when this module loads, so no file system code is needed.
import type { CardDef, puzzle } from "../engine";
import wordList from "./words.json";
const files = import.meta.glob<CardDef>("../../cards/*.json", { eager: true, import: "default" });
/** Every card in `cards/`, in file-name order. */
export function cards(): CardDef[] {
return Object.keys(files)
.sort()
.map((path) => files[path]);
}
/**
* Part II: the words of Baba Is You the parser can read, besides the engine's own
* (`IS`, `AND`, `NOT`, `TEXT`). Nouns and properties are data: a new word is one
* more entry in `words.json` in this directory.
*/
export function words(): puzzle.WordDef[] {
return wordList as puzzle.WordDef[];
}
@@ -0,0 +1,15 @@
[
{ "name": "BABA", "role": "noun" },
{ "name": "ROCK", "role": "noun" },
{ "name": "WALL", "role": "noun" },
{ "name": "FLAG", "role": "noun" },
{ "name": "LAVA", "role": "noun" },
{ "name": "KEY", "role": "noun" },
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" }
]
@@ -0,0 +1,102 @@
// Assemble: "Get a random minion. Change half its text."
//
// Card text is a list of clauses, so changing half of it is list surgery: the base
// card's `text.slice(0, split)` (top) or `text.slice(split)` (bottom) is replaced by
// the same half of a donor card. Name, cost and stats stay the base's. This works
// only because a card's behaviour is its data: the new card needs no new code.
//
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
// definition: each card's data says where its top half ends.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
this.name = "AssembleError";
}
}
/** JSON with object keys sorted, so two clauses written with their keys in a different order compare equal. */
const canonical = (value: unknown): string =>
JSON.stringify(value, (_key, v) =>
v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)))
: v,
);
const sameText = (a: CardDef["text"], b: CardDef["text"]): boolean => canonical(a) === canonical(b);
/**
* The base card with `half` of its text replaced by the same half of `donor`.
* It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
*/
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const topOf = (c: CardDef) => c.text.slice(0, c.split);
const bottomOf = (c: CardDef) => c.text.slice(c.split);
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
return true;
} catch (e) {
if (e instanceof AssembleError) return false;
throw e;
}
};
/**
* Register an assembled card and return its name. If a different card already has
* the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
*/
export function registerAssembled(world: World, def: CardDef): string {
const stem = def.name.replace(/\)$/, "");
let name = def.name;
// Same base, so the same name and stats: only the text can differ.
for (let n = 2; world.hasCard(name) && !sameText(world.card(name).def.text, def.text); n++) {
name = `${stem} ${n})`;
}
world.registerAssembled({ ...def, name });
return name;
}
/**
* The `assemble` effect: the owner's chooser picks a base minion from the pool
* (filtered), a donor that changes its text, and the half to replace. The
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
* random and offers two donors; here every choice is the chooser's.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
) as Half;
const name = registerAssembled(world, assemble(base, donor, half));
return world.create(name, player, "hand");
}
@@ -0,0 +1,42 @@
// Auras, and the one routine that applies trigger multipliers.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
// something. It takes a snapshot of the auras in force at defined points (an Aura
// Update) and every rule reads the snapshot.
//
// Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
// Health/Attack auras before the Death Creation Step, and one for every other aura
// after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
// Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
import type { PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
),
);
}
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
* force the answer is 1. Multipliers with `stacking: "max"` contribute their largest
* `times`; each `"additive"` one adds `times - 1` extra activations.
*/
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
for (const active of world.activeAuras()) {
const { trigger, times, stacking } = active.aura;
if (active.owner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
return max + extra;
}
@@ -0,0 +1,175 @@
// Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
// read straight from JSON. The engine compiles each definition once, when it is
// registered, into the lists it reads while resolving: triggers, auras and a
// spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
/** What a triggered ability listens for. */
export type TriggerOn =
| "selfDamaged" // this minion takes damage
| "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage
| "anyMinionDamaged" // any minion takes damage
| "heroDamaged" // either hero takes damage
| "deathrattle" // this minion dies (it resolves after the minion has left play)
| "battlecry" // this minion is played from hand
| "endOfTurn"; // the end of its owner's turn: the course has no turns, so it never fires
/** The trigger's family name, as reported in `Triggered` events. */
export function triggerLabel(on: TriggerOn): string {
switch (on) {
case "selfDamaged":
case "friendlyMinionDamaged":
case "anyMinionDamaged":
case "heroDamaged":
return "damaged";
case "deathrattle":
case "battlecry":
case "endOfTurn":
return on;
}
}
/**
* An aura: a continuous effect a minion has while it is in play. The engine reads
* auras only at the Aura Update points (see `aura.ts`), never mid-Phase.
*
* `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
*/
export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
/** Which minions an Assemble may pick as its base. An empty filter allows any. */
export interface PoolFilter {
/** Only minions with a trigger clause listening for this. */
trigger?: TriggerOn;
}
/** One effect: a node of a card's ability, interpreted by `effect.ts`. */
export type Effect =
| { type: "dealDamage"; target: Target; amount: number }
| { type: "draw"; count: number }
| { type: "summon"; card: string; count: number }
| { type: "gainArmor"; amount: number }
| { type: "gainAttack"; target: Target; amount: number }
| { type: "destroy"; target: Target }
/** Part III (SpellGrid) only: puts a word on the grid. */
| { type: "spawnText"; word: string; at: "self" }
/** Get a minion from the pool, with half its text replaced (see `assemble.ts`). */
| { type: "assemble"; filter: PoolFilter }
/** Give every minion in the owner's hand +attack/+health. */
| { type: "buffHand"; attack: number; health: number };
/**
* One line of card text. A trigger clause applies to the effect clauses after it,
* until the next trigger, keyword or aura. Effects before any trigger are what a
* spell does when cast.
*/
export type Clause =
| { kind: "keyword"; keyword: string }
| { kind: "trigger"; on: TriggerOn }
| { kind: "aura"; modifier: Aura }
| { kind: "effect"; effect: Effect };
/** A card as data, in the course's card format. */
export interface CardDef {
name: string;
type: "minion" | "spell";
cost: number;
attack?: number;
health?: number;
noun?: string;
text: Clause[];
/** `text.slice(0, split)` is the top half, `text.slice(split)` the bottom half. */
split: number;
fictional?: boolean;
game?: string;
}
/** A triggered ability: "whenever <on>, <effects>". */
export interface Trigger {
on: TriggerOn;
effects: Effect[];
}
/** A registered card: its definition, and the definition's text compiled into the lists the engine reads. */
export interface Card {
def: CardDef;
/** Triggered abilities, in printed order. */
triggers: Trigger[];
/** Auras this minion has while in play. */
auras: Aura[];
/** What the card does when cast (a spell's effects). */
onCast: Effect[];
/** May an Assemble offer this card? Minions with text that were not themselves assembled. */
inPool: boolean;
}
/** Compile a definition's text. Throws if `split` is past the end of the text. */
export function compile(def: CardDef, inPool: boolean): Card {
if (def.split > def.text.length) throw new Error(`"${def.name}": split ${def.split} is past the end of its text`);
const card: Card = { def, triggers: [], auras: [], onCast: [], inPool: false };
// The trigger whose effects are still being listed, if any.
let open: Trigger | undefined;
for (const clause of def.text) {
switch (clause.kind) {
case "trigger":
open = { on: clause.on, effects: [] };
card.triggers.push(open);
break;
case "effect":
(open ? open.effects : card.onCast).push(clause.effect);
break;
case "aura":
card.auras.push(clause.modifier);
open = undefined;
break;
case "keyword": // The course has no combat, so keywords such as Taunt do nothing here.
open = undefined;
break;
}
}
card.inPool = inPool && def.type === "minion" && def.text.length > 0;
return card;
}
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
constructor(defs: CardDef[]) {
for (const def of defs) this.register(def);
}
/** Add a card, or replace the card with the same name. It joins the Assemble pool if it is a minion with text. */
register(def: CardDef): void {
this.cards.set(def.name, compile(def, true));
}
/** Add a card made by Assemble. It never joins the pool. */
registerAssembled(def: CardDef): void {
this.cards.set(def.name, compile(def, false));
}
has(name: string): boolean {
return this.cards.has(name);
}
get(name: string): Card {
const card = this.cards.get(name);
if (!card) throw new Error(`unknown card "${name}": add its file to cards/`);
return card;
}
/** The Assemble pool: every card with `inPool`, sorted by name. */
pool(): Card[] {
return [...this.cards.values()]
.filter((c) => c.inPool)
.sort((a, b) => (a.def.name < b.def.name ? -1 : a.def.name > b.def.name ? 1 : 0));
}
}
@@ -0,0 +1,21 @@
// The Death Creation Step: the one place minions leave play.
//
// Damage and destroy effects only mark a minion (0 or less Health, or pending
// destroy). It stays in play, and keeps triggering, until the outermost Phase
// ends. Then this step removes every marked minion at once.
import type { Event } from "./event";
import type { World } from "./world";
/**
* Remove every mortally wounded or pending-destroy minion from play, simultaneously.
* Returns their Death Events in order of play; empty if nothing died.
*/
export function deathCreationStep(world: World): Event[] {
const dying = world.minionsInPlay().filter((id) => world.isDoomed(id));
// Every position is read before anything moves: removal is simultaneous, and each
// Death Event remembers where its minion stood when the step began.
const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, position: world.boardIndex(id)! }));
for (const { id, position } of deaths) world.kill(id, position);
return deaths;
}
@@ -0,0 +1,68 @@
// The interpreter: turns one effect, as data, into engine actions.
//
// This is the only place that knows what an effect *means*. A card's text is a
// small tree (clauses holding effects holding targets); the engine walks it. The
// effect vocabulary is closed: a card can only do what this switch can do, which is
// what makes a data card safe to load.
import type { Effect, Target } from "./card";
import type { Action, Ctx } from "./queue";
import type { EntityId } from "./world";
const times = <T>(count: number, make: () => T): T[] => Array.from({ length: count }, make);
/** The actions one effect performs, in order, given where and for whom it runs. */
export function actions(effect: Effect, ctx: Ctx): Action[] {
const player = ctx.owner;
switch (effect.type) {
case "dealDamage":
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
return targets(effect.target, ctx).map((id) => ({ type: "gainAttack", id, amount: effect.amount }));
case "destroy":
return [{ type: "destroy", targets: targets(effect.target, ctx) }];
case "assemble":
return [{ type: "assemble", player, filter: effect.filter }];
case "buffHand":
return ctx.world
.minionsInHand(player)
.map((id) => ({ type: "buff", id, attack: effect.attack, health: effect.health }));
case "spawnText":
throw new Error("spawnText needs a grid: it is a Part III effect");
}
}
/** The entities a target names, from where the effect runs. */
function targets(target: Target, ctx: Ctx): EntityId[] {
const { world } = ctx;
const others = (ids: EntityId[]) => ids.filter((id) => id !== ctx.source);
switch (target) {
case "self":
return [ctx.minion()];
case "allMinions":
return world.minionsInPlay();
case "allOtherMinions":
return others(world.minionsInPlay());
case "allOtherCharacters":
return others([world.hero(0), world.hero(1), ...world.minionsInPlay()]);
case "enemyHero":
return [ctx.enemyHero()];
case "chosen":
return ctx.target === undefined ? [] : [ctx.target];
}
}
/**
* Where a summon goes: to the right of a source still on the board; where a dead
* source stood (its Death Event's position); at the right end for a spell.
*/
function summonPosition(ctx: Ctx): number | undefined {
const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source);
return index === undefined ? ctx.position : index + 1;
}
@@ -0,0 +1,22 @@
// What happened, in order. The engine records an event each time it changes the game.
import type { TriggerOn } from "./card";
import type { EntityId, PlayerId } from "./world";
export type Event =
| { type: "Summoned"; id: EntityId }
| { type: "Damaged"; target: EntityId; amount: number }
| { type: "Triggered"; source: EntityId; on: TriggerOn }
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
* A Death Event: the Death Creation Step moved `id` from play to the graveyard.
* `position` is the board index it had when the step began; its Deathrattle summons there.
*/
| { type: "Destroyed"; id: EntityId; position: number }
| { type: "DeathPhase"; round: number }
/** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */
| { type: "Played"; id: EntityId };
@@ -0,0 +1,44 @@
// Engine core: generic rules machinery. Never name a specific card here.
//
// Module 1: every card in the game is an entity addressed by an id handle.
// The engine knows which zone each entity is in, who owns it, and when it
// entered play.
//
// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
// action queue that resolves a Sequence depth-first, recording an event log.
//
// Module 3: deferred death. Damage and destroy effects only mark minions; the
// Death Creation Step removes them when the outermost Phase ends, and Death
// Phases resolve their Deathrattles until no new deaths occur.
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
// minion from hand, and Battlecries.
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
// builds a new card by swapping half of one card's text for half of another's.
//
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
// beside the first.
// Module 6: the grid, the lexer and the parser.
// Module 7: `IS NOT`, and the resolution pass that turns parsed rules into rules in force.
export * as puzzle from "./puzzle";
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
export {
CardLibrary,
triggerLabel,
type Aura,
type Card,
type CardDef,
type Clause,
type Effect,
type PoolFilter,
type Target,
type Trigger,
type TriggerOn,
} from "./card";
export type { Event } from "./event";
export { Ctx, runSequence, summon, type Action } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -0,0 +1,74 @@
// The grid: objects on tiles. `x` grows right, `y` grows down, and any number of
// objects may share a tile.
export type ObjectId = number;
/** A level in the course's level format (`course/schema/level.schema.json`). */
export interface LevelDef {
width: number;
height: number;
objects: LevelObject[];
}
export interface LevelObject {
kind: string;
x: number;
y: number;
}
/**
* What an object is: a thing named by a noun (kind `"<noun>"`, the noun in lower case),
* or a piece of text carrying one word (kind `"text:<WORD>"`).
*/
export type Kind = { type: "thing"; noun: string } | { type: "text"; word: string };
const TEXT_PREFIX = "text:";
export const Kind = {
parse(kind: string): Kind {
return kind.startsWith(TEXT_PREFIX)
? { type: "text", word: kind.slice(TEXT_PREFIX.length).toUpperCase() }
: { type: "thing", noun: kind.toUpperCase() };
},
};
export interface Obj {
id: ObjectId;
kind: Kind;
x: number;
y: number;
}
export class Board {
private readonly objectsById = new Map<ObjectId, Obj>();
private nextId = 0;
constructor(
readonly width: number,
readonly height: number,
) {}
inBounds(x: number, y: number): boolean {
return x >= 0 && x < this.width && y >= 0 && y < this.height;
}
/** Add an object; ids start at 1 and are never reused. Throws off the board. */
place(kind: Kind, x: number, y: number): ObjectId {
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
const id = ++this.nextId;
this.objectsById.set(id, { id, kind, x, y });
return id;
}
/** The object `id`. Throws if there is none. */
get(id: ObjectId): Obj {
const o = this.objectsById.get(id);
if (o === undefined) throw new Error(`no object ${id}`);
return o;
}
/** Every object, oldest first. */
objects(): Obj[] {
return [...this.objectsById.values()];
}
}
@@ -0,0 +1,17 @@
// Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is
// compiled from the text on the board, and recompiled whenever the board changes.
//
// Module 6: the grid, the lexer (text objects to words with grammatical roles)
// and the parser (words to rules). The engine knows the operators `IS`, `AND`,
// `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word,
// nouns included, is data handed in by content.
//
// Module 7: `IS NOT` in the parser, and the resolution pass (negation, then the
// identity lock) that turns the parsed rules into the rules in force. Objects are
// asked about through `Level.hasProperty`, which knows which nouns name them.
export { Board, Kind, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid";
export { Level } from "./level";
export { BASE_RULES, isConversion, isIdentity, negates, parse, ruleText, type Rule } from "./parse";
export { resolve } from "./resolve";
export { lex, Lexicon, type Role, type Selects, type Token, type WordDef } from "./word";
@@ -0,0 +1,53 @@
// A level in play: the board, the words content provides, and the rules compiled
// from them. Every change to the board recompiles the rules from scratch: parse,
// then resolve. Every question about what an object is goes through `hasProperty`.
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
import { parse, type Rule } from "./parse";
import { resolve } from "./resolve";
import { Lexicon, type WordDef } from "./word";
export class Level {
readonly board: Board;
private readonly lexicon: Lexicon;
private compiled: Rule[] = [];
private active: Rule[] = [];
constructor(def: LevelDef, words: readonly WordDef[]) {
this.board = new Board(def.width, def.height);
this.lexicon = new Lexicon(words);
for (const o of def.objects) this.board.place(Kind.parse(o.kind), o.x, o.y);
this.reparse();
}
place(kind: string, x: number, y: number): ObjectId {
const id = this.board.place(Kind.parse(kind), x, y);
this.reparse();
return id;
}
/** Every rule the text spells, as parsed: negated rules and duplicates included. */
rules(): readonly Rule[] {
return this.compiled;
}
/** The rules in force after the resolution pass. */
activeRules(): readonly Rule[] {
return this.active;
}
/**
* Does object `id` have `property`? Yes if an active rule gives it to a noun
* that names the object and no active negated rule takes it from one.
*/
hasProperty(id: ObjectId, property: string): boolean {
const kind = this.board.get(id).kind;
const aboutIt = this.active.filter((r) => r.complement === property && this.lexicon.names(r.subject, kind));
return aboutIt.some((r) => !r.negated) && !aboutIt.some((r) => r.negated);
}
private reparse(): void {
this.compiled = parse(this.board, this.lexicon);
this.active = resolve(this.compiled);
}
}
@@ -0,0 +1,130 @@
// The parser: words on the grid become rules.
//
// Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`,
// read left to right in a row and top to bottom in a column. The parser starts at
// every `IS`, collects the subjects before it and the complements after it, and
// emits one rule per pair: AND distributes over both sides. A tile holding several
// words contributes each of them, so stacked text yields every possible rule.
//
// `NOT` before a complement negates that one complement. `NOT` before a subject is
// not in the grammar: the run of subjects stops there and that subject is dropped.
//
// The parser only reads; which rules win is the resolution pass (`resolve`).
import type { Board, ObjectId } from "./grid";
import { AND, IS, lex, NOT, TEXT, tileKey, type Lexicon, type Role, type Token } from "./word";
/**
* One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`.
* `text` is the text objects that spell it (subject, `IS`, `NOT` if any,
* complement), or empty for a base rule.
*/
export interface Rule {
subject: string;
complement: string;
complementRole: Role;
negated: boolean;
text: ObjectId[];
}
/** The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`. */
export const ruleText = (r: Rule): string => `${r.subject} ${IS}${r.negated ? ` ${NOT}` : ""} ${r.complement}`;
/** `X IS Y` where `Y` is a noun: every X is to become a Y. */
export const isConversion = (r: Rule): boolean => r.complementRole === "noun";
/** `X IS X` (or `X IS NOT X`). */
export const isIdentity = (r: Rule): boolean => r.subject === r.complement;
/** Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`. */
export const negates = (n: Rule, r: Rule): boolean =>
n.negated && !r.negated && n.subject === r.subject && n.complement === r.complement;
/** Rules in force with no text: the game's base rules (`[subject, property]`). */
export const BASE_RULES: readonly (readonly [string, string])[] = [[TEXT, "PUSH"]];
type Tiles = Map<string, Token[]>;
type Pos = readonly [number, number];
/** A word in a run, with the `NOT` in front of it if there is one. */
interface Word {
not?: Token;
token: Token;
}
/**
* Every rule the board spells, base rules first, negated rules included. A rule
* spelled by two different sets of text objects appears twice.
*/
export function parse(board: Board, lexicon: Lexicon): Rule[] {
const tiles = lex(board, lexicon);
const rules: Rule[] = BASE_RULES.map(([subject, property]) => ({
subject,
complement: property,
complementRole: "property",
negated: false,
text: [],
}));
for (const o of board.objects()) {
if (o.kind.type !== "text" || o.kind.word !== IS) continue;
for (const [dx, dy] of [
[1, 0],
[0, 1],
] as const) {
const ss = subjects(tiles, [o.x - dx, o.y - dy], [-dx, -dy]);
const cs = complements(tiles, [o.x + dx, o.y + dy], [dx, dy]);
for (const s of ss) {
for (const c of cs) {
rules.push({
subject: s.word,
complement: c.token.word,
complementRole: c.token.role,
negated: c.not !== undefined,
text: c.not ? [s.id, o.id, c.not.id, c.token.id] : [s.id, o.id, c.token.id],
});
}
}
}
}
return rules;
}
const wordsAt = (tiles: Tiles, [x, y]: Pos): Token[] => tiles.get(tileKey(x, y)) ?? [];
const holds = (tiles: Tiles, p: Pos, word: string): boolean => wordsAt(tiles, p).some((t) => t.word === word);
const ahead = ([x, y]: Pos, [dx, dy]: Pos): Pos => [x + dx, y + dy];
/**
* The run of subjects before an `IS`, walking backwards from `at`: the nouns at
* `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT`
* before a noun ends the run without it (a negated subject is not in the grammar).
*/
function subjects(tiles: Tiles, at: Pos, step: Pos): Token[] {
const before = ahead(at, step);
if (holds(tiles, before, NOT)) return [];
const found = wordsAt(tiles, at).filter((t) => t.role === "noun");
if (found.length > 0 && holds(tiles, before, AND)) found.push(...subjects(tiles, ahead(before, step), step));
return found;
}
/**
* The run of complements after an `IS`, walking forwards from `at`: the nouns
* and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it,
* negated; then, if the next tile holds an AND, the run beyond it.
*/
function complements(tiles: Tiles, at: Pos, step: Pos): Word[] {
const fits = (t: Token) => t.role === "noun" || t.role === "property";
const found: Word[] = [];
// Plain words here, and negated words one tile on: a stack may hold both.
const branches: [Token | undefined, Pos][] = [[undefined, at]];
for (const not of wordsAt(tiles, at).filter((t) => t.word === NOT)) branches.push([not, ahead(at, step)]);
for (const [not, p] of branches) {
const words = wordsAt(tiles, p)
.filter(fits)
.map((token) => ({ not, token }));
if (words.length === 0) continue;
found.push(...words);
const andAt = ahead(p, step);
if (holds(tiles, andAt, AND)) found.push(...complements(tiles, ahead(andAt, step), step));
}
return found;
}
@@ -0,0 +1,23 @@
// The resolution pass: from every rule the text spells to the rules in force.
//
// The parser reports what is written; this pass decides what wins, over the whole
// rule list at once, so it doesn't matter where on the board each rule is written:
//
// 1. Negation. `X IS NOT Y` cancels every `X IS Y`. The negated rule stays in
// force: it keeps overriding, and `X IS NOT X` is also the record that every X is
// to vanish (the turn removes them, Module 9).
// 2. Identity. A surviving `X IS X` cancels every conversion `X IS Y` (`Y` a noun):
// X can't become anything else. It doesn't stop other nouns becoming X. Because
// negation runs first, `X IS NOT X` cancels `X IS X` and conversions work again.
//
// Base rules are rules like any other, so `TEXT IS NOT PUSH` cancels `TEXT IS PUSH`.
import { isConversion, isIdentity, negates, type Rule } from "./parse";
/** The rules in force, in the order they were parsed. */
export function resolve(rules: readonly Rule[]): Rule[] {
const survivesNegation = rules.filter((r) => !rules.some((n) => negates(n, r)));
const locked = (subject: string) =>
survivesNegation.some((r) => !r.negated && isIdentity(r) && r.subject === subject);
return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r.subject));
}
@@ -0,0 +1,96 @@
// The lexer: text objects become words with a grammatical role.
//
// The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`.
// Every other word comes from content as data: the engine never names a noun.
// A noun also says which objects it names (`Selects`), so a meta-noun that names
// a whole group of objects can be data too.
import type { Board, Kind, ObjectId } from "./grid";
export type Role = "noun" | "property" | "operator";
/**
* Which objects a noun names when it is the subject of a rule:
* - `"named"` (the default): the things of its own name, `<NOUN>` names every object of kind `"<noun>"`;
* - `"text"`: every text object (the meta-noun `TEXT`);
* - `"things"`: every object that is not text, whatever its noun.
*/
export type Selects = "named" | "text" | "things";
/**
* One entry of the word list content provides: `{ "name": "<NOUN>", "role": "noun" }`,
* optionally with `"selects"` (default `"named"`).
*/
export interface WordDef {
name: string;
role: Role;
selects?: Selects;
}
export const IS = "IS";
export const AND = "AND";
export const NOT = "NOT";
/** The meta-noun for every text object. */
export const TEXT = "TEXT";
/** Every word the parser can read, with its role. */
export class Lexicon {
private readonly roles = new Map<string, Role>();
private readonly selects = new Map<string, Selects>();
/** The engine's own words plus `words`. Content can't redefine an engine word. */
constructor(words: readonly WordDef[]) {
for (const w of words) {
this.roles.set(w.name.toUpperCase(), w.role);
this.selects.set(w.name.toUpperCase(), w.selects ?? "named");
}
for (const op of [IS, AND, NOT]) this.roles.set(op, "operator");
this.roles.set(TEXT, "noun");
this.selects.set(TEXT, "text");
}
/** `undefined` for a word the lexicon doesn't know: such text spells nothing. */
role(word: string): Role | undefined {
return this.roles.get(word);
}
/** Does the noun `word`, as the subject of a rule, name an object of this kind? */
names(word: string, kind: Kind): boolean {
switch (this.selects.get(word) ?? "named") {
case "named":
return kind.type === "thing" && kind.noun === word;
case "text":
return kind.type === "text";
case "things":
return kind.type === "thing";
}
}
}
/** A piece of text the parser can read: which object, which word, which role. */
export interface Token {
id: ObjectId;
word: string;
role: Role;
}
/** Tile id for maps: `"x,y"`. */
export const tileKey = (x: number, y: number): string => `${x},${y}`;
/**
* The words on the board, by tile; a tile's words in object order (oldest first).
* Text whose word the lexicon doesn't know is left out.
*/
export function lex(board: Board, lexicon: Lexicon): Map<string, Token[]> {
const tiles = new Map<string, Token[]>();
for (const o of board.objects()) {
if (o.kind.type !== "text") continue;
const role = lexicon.role(o.kind.word);
if (role === undefined) continue;
const tile = tileKey(o.x, o.y);
const tokens = tiles.get(tile) ?? [];
tokens.push({ id: o.id, word: o.kind.word, role });
tiles.set(tile, tokens);
}
return tiles;
}
@@ -0,0 +1,227 @@
// The action queue: how one player action (a Sequence) resolves.
//
// Performing an action changes the world and produces events. Every trigger that
// responds to those events goes into one Queue, oldest listener first, and the Queue
// is locked. Each queued trigger then resolves completely, including everything
// its own actions cause, before the next one starts: depth-first.
//
// Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
// Death Creation Step removes the dead, and a Death Phase resolves their
// Deathrattles. That repeats until a step finds no new deaths.
//
// Module 4: a queued trigger may activate more than once. How many times is
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
import type { Event } from "./event";
import type { EntityId, PlayerId, World } from "./world";
/** Something the engine can do. Card effects return these instead of changing the world. */
export type Action =
/** Deal `amount` to each target at once: all damage first, then the reactions. */
| { type: "damage"; targets: EntityId[]; amount: number }
| { type: "draw"; player: PlayerId }
| { type: "gainArmor"; player: PlayerId; amount: number }
| { type: "gainAttack"; id: EntityId; amount: number }
| { type: "cast"; card: string; player: PlayerId; target?: EntityId }
/** Mark each target pending destroy. They leave play in the next Death Creation Step. */
| { type: "destroy"; targets: EntityId[] }
/** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */
| { type: "summon"; card: string; player: PlayerId; at?: number }
/** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */
| { type: "play"; id: EntityId }
/** Put an Assembled minion into `player`'s hand (see `assemble.ts`). */
| { type: "assemble"; player: PlayerId; filter: PoolFilter }
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number };
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
readonly world: World,
/** The player the effect works for. */
readonly owner: PlayerId,
/** The minion whose trigger is resolving; undefined for a spell. */
readonly source?: EntityId,
/** The chosen target, if any. */
readonly target?: EntityId,
/**
* The source's board position. For a Deathrattle, the source has left play,
* and this is the position its Death Event remembered.
*/
readonly position?: number,
) {}
/** The minion whose trigger is resolving. */
minion(): EntityId {
if (this.source === undefined) throw new Error("only a triggered ability has a source");
return this.source;
}
enemyHero(): EntityId {
return this.world.hero(this.owner === 0 ? 1 : 0);
}
}
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
// The outermost Phase has ended. Each Death Phase is itself an outermost
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
}
}
/**
* The steps that run only when the outermost Phase ends; returns the Death Events.
* Hearthstone runs an Aura Update (Health/Attack) first. The course has no
* Health/Attack auras, so that step has nothing to do and is left out.
*/
function endOfOutermostPhase(world: World): Event[] {
const deaths = deathCreationStep(world);
// After the step: a minion that just died no longer contributes its auras
// to the Death Phase that resolves these deaths.
auraUpdateOther(world);
return deaths;
}
/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */
export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId {
const id = world.create(card, player, "board", at);
auraUpdateOther(world);
return id;
}
function perform(world: World, action: Action): void {
switch (action.type) {
case "damage": {
// Area damage: every Damage Event is created, in order of play, before any reaction.
const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
const events: Event[] = [];
for (const target of targets) {
world.damage(target, action.amount);
events.push({ type: "Damaged", target, amount: action.amount });
}
react(world, events);
break;
}
case "draw":
world.draw(action.player);
break;
case "gainArmor":
world.gainArmor(action.player, action.amount);
break;
case "gainAttack":
world.gainAttack(action.id, action.amount);
break;
case "destroy":
for (const target of action.targets) world.markDestroyed(target);
break;
case "summon":
summon(world, action.card, action.player, action.at);
break;
case "play": {
if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.id} is not in a hand`);
// Playing is not summoning: no Aura Update here. The played minion's own
// auras join at the end of the outermost Phase.
world.moveTo(action.id, "board");
const played: Event = { type: "Played", id: action.id };
world.record(played);
react(world, [played]);
break;
}
case "cast": {
const spell = world.card(action.card);
if (spell.def.type !== "spell") throw new Error(`"${action.card}" is not a spell`);
world.record({ type: "SpellCast", card: action.card });
runEffects(world, spell.onCast, new Ctx(world, action.player, undefined, action.target));
break;
}
case "assemble":
assembleFromPool(world, action.player, action.filter);
break;
case "buff":
world.buff(action.id, action.attack, action.health);
break;
}
}
/**
* Interpret and perform an ability's effects in printed order. Each effect sees the
* world as the effects before it left it. Depth-first: each action, and every
* reaction it causes, finishes before the next.
*/
function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
for (const effect of effects) {
for (const action of actions(effect, ctx)) perform(world, action);
}
}
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
for (let i = 0; i < times; i++) {
world.record({ type: "Triggered", source, on: trigger.on });
const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
}
}
}
/**
* Who may respond to `event`, in order of play: every minion in play and, for a
* Death Event, the minion that died. It has left play, but its Deathrattle still answers.
*/
function listeners(world: World, event: Event): EntityId[] {
const inPlay = world.minionsInPlay();
if (event.type !== "Destroyed") return inPlay;
return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
}
/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */
function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean {
if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener;
if (event.type === "Played") return on === "battlecry" && event.id === listener;
if (event.type !== "Damaged") return false;
const isMinion = !world.isHero(event.target);
switch (on) {
case "selfDamaged":
return event.target === listener;
case "friendlyMinionDamaged":
return isMinion && world.ownerOf(event.target) === world.ownerOf(listener);
case "anyMinionDamaged":
return isMinion;
case "heroDamaged":
return !isMinion;
case "deathrattle":
case "battlecry":
case "endOfTurn":
return false;
}
}
@@ -0,0 +1,301 @@
// Entities, zones, order of play, stats and the event log: the game's state.
// The rules for resolving actions live in `queue.ts`.
import type { Aura, Card, CardDef, CardLibrary } from "./card";
import type { Event } from "./event";
/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */
export type EntityId = number;
export type PlayerId = 0 | 1;
export type Zone = "deck" | "hand" | "board" | "graveyard";
/** Starting Health of each hero. */
export const HERO_HEALTH = 30;
interface Entity {
card: string;
owner: PlayerId;
zone: Zone;
/** Stamped each time the entity enters the board; undefined until it first does. */
orderOfPlay?: number;
attack: number;
/** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */
health: number;
/** Hit by a destroy effect: still in play until the Death Creation Step. */
pendingDestroy: boolean;
}
/** An aura in force: which player's minion has it. */
export interface ActiveAura {
owner: PlayerId;
aura: Aura;
}
/**
* Makes a player's choices: called with what is being chosen and the options, it
* returns one of the options. Hearthstone would ask the player or pick at random.
*/
export type Chooser = (kind: string, options: readonly string[]) => string;
/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */
type Side = Record<Zone, EntityId[]> & { hero: EntityId; armor: number };
export class World {
/** Indexed by EntityId. */
private readonly entities: Entity[] = [];
private readonly sides: [Side, Side];
private nextOrderOfPlay = 0;
private events: Event[] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
private chooser: Chooser = (_kind, options) => options[0];
/** A new game: both heroes are in play from the start, so they are the oldest entities. */
constructor(private readonly library: CardLibrary) {
const side = (owner: PlayerId): Side => {
const hero = this.entities.length;
this.entities.push({
card: "Hero",
owner,
zone: "board",
orderOfPlay: ++this.nextOrderOfPlay,
attack: 0,
health: HERO_HEALTH,
pendingDestroy: false,
});
// In play, but not on the board list: the board holds minions only.
return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 };
};
this.sides = [side(0), side(1)];
}
/**
* Create an entity of `card` for `owner` in `zone`: at the end (the right end for the board),
* or at index `at` when given (clamped to the zone's length).
*/
create(card: string, owner: PlayerId, zone: Zone, at?: number): EntityId {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
const { def } = this.library.get(card);
const id = this.entities.length;
this.entities.push({
card: def.name,
owner,
zone,
attack: def.attack ?? 0,
health: def.health ?? 0,
pendingDestroy: false,
});
this.enter(id, zone, at);
return id;
}
/** Move an entity to the end of `zone` on its owner's side, keeping its id. */
moveTo(id: EntityId, zone: Zone): void {
const { owner, zone: from } = this.entity(id);
const list = this.sides[owner][from];
list.splice(list.indexOf(id), 1);
this.enter(id, zone);
}
/** The single place an entity arrives in a zone. Entering the board starts a new order of play. */
private enter(id: EntityId, zone: Zone, at?: number): void {
const entity = this.entity(id);
const list = this.sides[entity.owner][zone];
list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id);
entity.zone = zone;
if (zone === "board") {
entity.orderOfPlay = ++this.nextOrderOfPlay;
this.record({ type: "Summoned", id });
}
}
private entity(id: EntityId): Entity {
const entity = this.entities[id];
if (!entity) throw new Error(`unknown entity id ${id}`);
return entity;
}
// ---- Queries ----
card(name: string): Card {
return this.library.get(name);
}
hasCard(name: string): boolean {
return this.library.has(name);
}
/** The cards an Assemble may offer, sorted by name. */
pool(): Card[] {
return this.library.pool();
}
zoneOf(id: EntityId): Zone {
return this.entity(id).zone;
}
cardName(id: EntityId): string {
return this.entity(id).card;
}
ownerOf(id: EntityId): PlayerId {
return this.entity(id).owner;
}
/** When the entity last entered the board (larger is later); undefined if it never has. */
orderOfPlay(id: EntityId): number | undefined {
return this.entity(id).orderOfPlay;
}
/** The ids in one player's zone, in zone order (left to right for the board). */
zone(owner: PlayerId, zone: Zone): readonly EntityId[] {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
return this.sides[owner][zone];
}
hero(owner: PlayerId): EntityId {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
return this.sides[owner].hero;
}
isHero(id: EntityId): boolean {
return this.sides.some((s) => s.hero === id);
}
/** Every minion on either board, oldest first (order of play). */
minionsInPlay(): EntityId[] {
return [...this.sides[0].board, ...this.sides[1].board].sort(
(a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!,
);
}
attack(id: EntityId): number {
return this.entity(id).attack;
}
health(id: EntityId): number {
return this.entity(id).health;
}
/** The cost printed on the entity's card. */
cost(id: EntityId): number {
return this.card(this.cardName(id)).def.cost;
}
/** The minions in a player's hand, in hand order. */
minionsInHand(owner: PlayerId): EntityId[] {
return this.zone(owner, "hand").filter((id) => this.card(this.cardName(id)).def.type === "minion");
}
/** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */
isDoomed(id: EntityId): boolean {
const entity = this.entity(id);
return entity.health <= 0 || entity.pendingDestroy;
}
/** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */
boardIndex(id: EntityId): number | undefined {
const index = this.sides[this.ownerOf(id)].board.indexOf(id);
return index < 0 ? undefined : index;
}
armor(owner: PlayerId): number {
return this.sides[owner].armor;
}
activeAuras(): readonly ActiveAura[] {
return this.auras;
}
log(): readonly Event[] {
return this.events;
}
// ---- Changes: each one records its event ----
record(event: Event): void {
this.events.push(event);
}
/** Add a card to the library, or replace the card with the same name. */
register(def: CardDef): void {
this.library.register(def);
}
/** Add a card made by Assemble (see `assemble.ts`). */
registerAssembled(def: CardDef): void {
this.library.registerAssembled(def);
}
setChooser(chooser: Chooser): void {
this.chooser = chooser;
}
/** Ask the chooser to pick one of `options` (not empty). Throws if it answers something that isn't an option. */
choose(kind: string, options: readonly string[]): string {
const answer = this.chooser(kind, options);
if (!options.includes(answer)) {
throw new Error(`chooser answered "${answer}" for "${kind}"; the options were ${JSON.stringify(options)}`);
}
return answer;
}
/** Only an Aura Update calls this. */
setActiveAuras(auras: ActiveAura[]): void {
this.auras = auras;
}
clearLog(): void {
this.events = [];
}
damage(target: EntityId, amount: number): void {
this.entity(target).health -= amount;
this.record({ type: "Damaged", target, amount });
}
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
*/
draw(player: PlayerId): void {
const id = this.zone(player, "deck")[0];
if (id === undefined) {
this.record({ type: "Fatigue", player });
return;
}
this.moveTo(id, "hand");
this.record({ type: "Drew", player, id });
}
/** Mark for destruction. Nothing leaves play until the Death Creation Step. */
markDestroyed(id: EntityId): void {
this.entity(id).pendingDestroy = true;
}
/** Move a minion from play to its owner's graveyard, recording its Death Event. */
kill(id: EntityId, position: number): void {
this.entity(id).pendingDestroy = false;
this.moveTo(id, "graveyard");
this.record({ type: "Destroyed", id, position });
}
gainArmor(player: PlayerId, amount: number): void {
this.sides[player].armor += amount;
this.record({ type: "ArmorGained", player, amount });
}
buff(id: EntityId, attack: number, health: number): void {
const entity = this.entity(id);
entity.attack += attack;
entity.health += health;
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
}
gainAttack(id: EntityId, amount: number): void {
const entity = this.entity(id);
entity.attack += amount;
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
}
}
@@ -0,0 +1,144 @@
// Test harness: the thin adapter the course tests call. It translates each
// call into engine operations and holds no rules of its own.
import { cards, words } from "./content";
import {
assemble,
CardLibrary,
puzzle,
registerAssembled,
runSequence,
summon,
triggerLabel,
World,
type CardDef,
type Chooser,
type Event,
type Half,
type Zone,
} from "./engine";
export { AssembleError } from "./engine";
export type { CardDef, Chooser, Half, Zone };
export type Id = number;
export type Player = 0 | 1;
/** The events the tests read from `log()` (see course/harness.md). */
export type GameEvent =
| { type: "Summoned"; id: Id }
| { type: "Damaged"; target: Id; amount: number }
| { type: "Triggered"; source: Id; trigger: string }
| { type: "Destroyed"; id: Id }
| { type: "DeathPhase"; round: number }
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */
function toGameEvent(event: Event): GameEvent | undefined {
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
if (event.type === "Destroyed") return { type: "Destroyed", id: event.id };
// A played minion's `Summoned` already shows it entering the board.
if (event.type === "Played") return undefined;
return { ...event };
}
export interface Game {
summon(card: string, player?: Player): Id; // to right end of board
addToHand(card: string, player?: Player): Id;
addToDeck(card: string, player?: Player): Id;
play(handId: Id): void; // Module 1 Expansion Drop; required from Module 4 (Battlecry)
zoneOf(id: Id): Zone;
board(player: Player): Id[]; // left to right
hand(player: Player): Id[];
orderOfPlay(id: Id): number;
nameOf(id: Id): string;
// Module 2
hero(player: Player): Id;
dealDamage(target: Id, amount: number): void; // one Sequence
cast(card: string, player?: Player, target?: Id): void;
health(id: Id): number;
attack(id: Id): number;
armor(player: Player): number;
log(): GameEvent[];
clearLog(): void;
// Module 3
destroy(target: Id): void; // marks pending destroy, one Sequence
// Module 5
registerCards(defs: CardDef[]): void; // add card definitions, replacing any with the same name
assemble(base: string, donor: string, half: Half): string; // registers "<base> (Assembled)"; throws AssembleError
cost(id: Id): number;
setChooser(choose: Chooser): void; // makes Assemble's choices: kind "base" | "donor" | "half"
}
export function newGame(opts?: { decks?: [string[], string[]] }): Game {
const world = new World(new CardLibrary(cards()));
opts?.decks?.forEach((deck, player) => {
for (const card of deck) world.create(card, player as Player, "deck");
});
return {
summon: (card, player = 0) => summon(world, card, player),
addToHand: (card, player = 0) => world.create(card, player, "hand"),
addToDeck: (card, player = 0) => world.create(card, player, "deck"),
// Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves.
play: (handId) => runSequence(world, { type: "play", id: handId }),
zoneOf: (id) => world.zoneOf(id),
board: (player) => [...world.zone(player, "board")],
hand: (player) => [...world.zone(player, "hand")],
orderOfPlay: (id) => {
const order = world.orderOfPlay(id);
if (order === undefined) throw new Error(`entity ${id} has not entered play`);
return order;
},
nameOf: (id) => world.cardName(id),
hero: (player) => world.hero(player),
dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }),
cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }),
health: (id) => world.health(id),
attack: (id) => world.attack(id),
armor: (player) => world.armor(player),
log: () => world.log().flatMap((event) => toGameEvent(event) ?? []),
clearLog: () => world.clearLog(),
destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }),
registerCards: (defs) => defs.forEach((def) => world.register(def)),
assemble: (base, donor, half) =>
registerAssembled(world, assemble(world.card(base).def, world.card(donor).def, half)),
cost: (id) => world.cost(id),
setChooser: (choose) => world.setChooser(choose),
};
}
// ======== Part II: Baba Is You ========
export type { LevelDef, LevelObject } from "./engine/puzzle";
/** A Baba Is You level in play (Part II). */
export interface Baba {
// Module 6
place(kind: string, x: number, y: number): Id; // "baba" or "text:BABA"
rules(): string[]; // distinct, normalised ("BABA IS YOU", "BABA IS NOT YOU"), sorted; before resolution
// Module 7
activeRules(): string[]; // the same, after resolution (NOT, identity)
hasProperty(id: Id, prop: string): boolean;
}
/** Distinct rules as normalised strings, sorted. */
const normalised = (rules: readonly puzzle.Rule[]): string[] => [...new Set(rules.map(puzzle.ruleText))].sort();
/** A level from a level definition; its text is parsed straight away. */
export function newBaba(level: puzzle.LevelDef): Baba {
const lvl = new puzzle.Level(level, words());
return {
place: (kind, x, y) => lvl.place(kind, x, y),
rules: () => normalised(lvl.rules()),
activeRules: () => normalised(lvl.activeRules()),
hasProperty: (id, prop) => lvl.hasProperty(id, prop),
};
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src", "tests"]
}
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["tests/**/*.test.ts"], passWithNoTests: true },
});
+59
View File
@@ -0,0 +1,59 @@
# Module 7 scenarios: Resolving rules (NOT, identity, meta-nouns)
Each heading is a test name. The Rust test lives in `tests/rust/test_07.rs` and the TypeScript test in `tests/ts/test_07.test.ts`; both state exactly what is written here.
The level format, `place`, `rules()` and the way a test writes a sentence (`BABA (0,0) IS (1,0) YOU (2,0)` is three text objects) are those of Module 6. This module adds the operator `NOT` to the grammar, and two harness functions:
- `rules()` is still everything the text spells, before resolution, and now includes negated rules, written `"BABA IS NOT YOU"`.
- `activeRules()` is the rule set after the resolution pass: sorted, normalised strings, each distinct rule once. A rule `X IS NOT Y` cancels every `X IS Y`, wherever each is written; the negated rule itself stays in the set, because it keeps overriding. A rule `X IS X` that survives cancels every `X IS Y` whose `Y` is a noun other than `X` (Baba can't convert). The base rule `TEXT IS PUSH` is in it unless `TEXT IS NOT PUSH` cancels it.
- `hasProperty(id, prop)` asks whether one object has a property under the active rules. A thing is named by its noun (a `baba` object by `BABA`); a text object is named by `TEXT`. The object has the property if an active rule gives it to one of its names and no active negated rule takes it away from one of its names.
`X IS NOT X` is decided here and acted on later. It cancels `X IS X` (it is the negation of that rule, like any other), so other `X IS Y` conversions work again, and it stays in `activeRules()` as the record that every `X` is to vanish. Removing the objects is part of the turn, in Module 9. The tests check the cancellation and the unlocked conversion, which the sources state; they do not check that `"BABA IS NOT BABA"` is listed in `activeRules()`, which is this course's way of recording the deletion, not something the sources describe.
`NOT` in front of a complement negates that complement only: `BABA IS NOT YOU AND WIN` is `BABA IS NOT YOU` and `BABA IS WIN`. `NOT` in front of a subject is not part of the course's grammar: the sentence it stands in spells nothing.
Words used: the nouns `BABA`, `ROCK`; the properties `YOU`, `PUSH`; the operators `IS`, `NOT`; the meta-noun `TEXT`.
### test_07_not_overrides
- Setup: `newBaba` with an empty level 4 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and an object `baba` at (0,4).
- Assertions 1: `hasProperty(baba, "YOU")` is true.
- Action: place `BABA (0,2) IS (1,2) NOT (2,2) YOU (3,2)`.
- Assertions 2:
- `rules()` is exactly `["BABA IS NOT YOU", "BABA IS YOU", "TEXT IS PUSH"]`: both sentences are still on the board.
- `activeRules()` is exactly `["BABA IS NOT YOU", "TEXT IS PUSH"]`: the negation overrides `BABA IS YOU`, although that rule is written above it.
- `hasProperty(baba, "YOU")` is false.
### test_07_x_is_x_blocks_conversion
- Setup: `newBaba` with an empty level 3 wide and 5 high. Place `BABA (0,0) IS (1,0) BABA (2,0)`, `BABA (0,2) IS (1,2) ROCK (2,2)` and `ROCK (0,4) IS (1,4) BABA (2,4)`.
- Assertions:
- `rules()` is exactly `["BABA IS BABA", "BABA IS ROCK", "ROCK IS BABA", "TEXT IS PUSH"]`.
- `activeRules()` is exactly `["BABA IS BABA", "ROCK IS BABA", "TEXT IS PUSH"]`. `BABA IS BABA` blocks Baba from converting into anything, so `BABA IS ROCK` is not active. It doesn't block other things from converting into Baba, so `ROCK IS BABA` is.
### test_07_x_is_not_x_deletes_and_unlocks
- Setup: `newBaba` with an empty level 4 wide and 5 high. Place `BABA (0,0) IS (1,0) NOT (2,0) BABA (3,0)`, `BABA (0,2) IS (1,2) BABA (2,2)` and `BABA (0,4) IS (1,4) ROCK (2,4)`.
- Assertions:
- `rules()` is exactly `["BABA IS BABA", "BABA IS NOT BABA", "BABA IS ROCK", "TEXT IS PUSH"]`.
- `activeRules()` contains `"BABA IS ROCK"` and does not contain `"BABA IS BABA"`. `BABA IS NOT BABA` cancels the lock, so the conversion is active again. (Baba is also flagged to vanish; see the note above on what the test leaves open.)
### test_07_text_is_not_push
- Setup: `newBaba` with an empty level 4 wide and 3 high. Place a text `text:ROCK` at (0,2) and an object `rock` at (3,2).
- Assertions 1:
- `activeRules()` is exactly `["TEXT IS PUSH"]`.
- `hasProperty(rockText, "PUSH")` is true: the base rule applies to every text object.
- `hasProperty(rock, "PUSH")` is false: `TEXT` names text, not the rock the text names.
- Action: place `TEXT (0,0) IS (1,0) NOT (2,0) PUSH (3,0)`.
- Assertions 2:
- `rules()` is exactly `["TEXT IS NOT PUSH", "TEXT IS PUSH"]`.
- `activeRules()` is exactly `["TEXT IS NOT PUSH"]`: the base rule is a rule like any other and NOT cancels it.
- `hasProperty(rockText, "PUSH")` is false, and so is `hasProperty` of the `TEXT` text at (0,0).
### test_07_leading_not_breaks_sentence
- Setup: `newBaba` with an empty level 4 wide and 2 high. Place `NOT (0,0) BABA (1,0) IS (2,0) YOU (3,0)` and an object `baba` at (0,1).
- Assertions:
- `rules()` and `activeRules()` are both exactly `["TEXT IS PUSH"]`. A `NOT` in front of the subject is not in the course's grammar, and the parser must not read the rest of the sentence as `BABA IS YOU`: the `NOT` is there, and dropping it would change the meaning.
- `hasProperty(baba, "YOU")` is false.
@@ -0,0 +1,138 @@
// Module 7: Resolving rules. Each test matches a heading in scenarios.md.
use engine::harness::*;
/// An empty level `width` wide and `height` high.
fn empty_level(width: i32, height: i32) -> LevelDef {
LevelDef {
width,
height,
..Default::default()
}
}
/// Place one text object per word: `(word, x, y)`.
fn write(b: &mut Baba, words: &[(&str, i32, i32)]) {
for &(word, x, y) in words {
b.place(&format!("text:{word}"), x, y);
}
}
#[test]
fn test_07_not_overrides() {
let mut b = new_baba(empty_level(4, 5));
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
let baba = b.place("baba", 0, 4);
assert!(b.has_property(baba, "YOU"));
write(
&mut b,
&[("BABA", 0, 2), ("IS", 1, 2), ("NOT", 2, 2), ("YOU", 3, 2)],
);
assert_eq!(
b.rules(),
vec!["BABA IS NOT YOU", "BABA IS YOU", "TEXT IS PUSH"]
);
assert_eq!(b.active_rules(), vec!["BABA IS NOT YOU", "TEXT IS PUSH"]);
assert!(!b.has_property(baba, "YOU"));
}
#[test]
fn test_07_x_is_x_blocks_conversion() {
let mut b = new_baba(empty_level(3, 5));
write(
&mut b,
&[
("BABA", 0, 0),
("IS", 1, 0),
("BABA", 2, 0),
("BABA", 0, 2),
("IS", 1, 2),
("ROCK", 2, 2),
("ROCK", 0, 4),
("IS", 1, 4),
("BABA", 2, 4),
],
);
assert_eq!(
b.rules(),
vec![
"BABA IS BABA",
"BABA IS ROCK",
"ROCK IS BABA",
"TEXT IS PUSH"
]
);
assert_eq!(
b.active_rules(),
vec!["BABA IS BABA", "ROCK IS BABA", "TEXT IS PUSH"]
);
}
#[test]
fn test_07_x_is_not_x_deletes_and_unlocks() {
let mut b = new_baba(empty_level(4, 5));
write(
&mut b,
&[
("BABA", 0, 0),
("IS", 1, 0),
("NOT", 2, 0),
("BABA", 3, 0),
("BABA", 0, 2),
("IS", 1, 2),
("BABA", 2, 2),
("BABA", 0, 4),
("IS", 1, 4),
("ROCK", 2, 4),
],
);
assert_eq!(
b.rules(),
vec![
"BABA IS BABA",
"BABA IS NOT BABA",
"BABA IS ROCK",
"TEXT IS PUSH"
]
);
let active = b.active_rules();
assert!(active.contains(&"BABA IS ROCK".to_string()));
assert!(!active.contains(&"BABA IS BABA".to_string()));
}
#[test]
fn test_07_text_is_not_push() {
let mut b = new_baba(empty_level(4, 3));
let rock_text = b.place("text:ROCK", 0, 2);
let rock = b.place("rock", 3, 2);
assert_eq!(b.active_rules(), vec!["TEXT IS PUSH"]);
assert!(b.has_property(rock_text, "PUSH"));
assert!(!b.has_property(rock, "PUSH"));
let text_text = b.place("text:TEXT", 0, 0);
write(&mut b, &[("IS", 1, 0), ("NOT", 2, 0), ("PUSH", 3, 0)]);
assert_eq!(b.rules(), vec!["TEXT IS NOT PUSH", "TEXT IS PUSH"]);
assert_eq!(b.active_rules(), vec!["TEXT IS NOT PUSH"]);
assert!(!b.has_property(rock_text, "PUSH"));
assert!(!b.has_property(text_text, "PUSH"));
}
#[test]
fn test_07_leading_not_breaks_sentence() {
let mut b = new_baba(empty_level(4, 2));
write(
&mut b,
&[("NOT", 0, 0), ("BABA", 1, 0), ("IS", 2, 0), ("YOU", 3, 0)],
);
let baba = b.place("baba", 0, 1);
assert_eq!(b.rules(), vec!["TEXT IS PUSH"]);
assert_eq!(b.active_rules(), vec!["TEXT IS PUSH"]);
assert!(!b.has_property(baba, "YOU"));
}
@@ -0,0 +1,109 @@
// Module 7: Resolving rules. Each test matches a heading in scenarios.md.
import { expect, test } from "vitest";
import { newBaba, type Baba } from "../src/harness";
/** An empty level `width` wide and `height` high. */
const emptyLevel = (width: number, height: number) => ({ width, height, objects: [] });
/** Place one text object per word: `[word, x, y]`. */
function write(b: Baba, words: [string, number, number][]): void {
for (const [word, x, y] of words) b.place(`text:${word}`, x, y);
}
test("test_07_not_overrides", () => {
const b = newBaba(emptyLevel(4, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
]);
const baba = b.place("baba", 0, 4);
expect(b.hasProperty(baba, "YOU")).toBe(true);
write(b, [
["BABA", 0, 2],
["IS", 1, 2],
["NOT", 2, 2],
["YOU", 3, 2],
]);
expect(b.rules()).toEqual(["BABA IS NOT YOU", "BABA IS YOU", "TEXT IS PUSH"]);
expect(b.activeRules()).toEqual(["BABA IS NOT YOU", "TEXT IS PUSH"]);
expect(b.hasProperty(baba, "YOU")).toBe(false);
});
test("test_07_x_is_x_blocks_conversion", () => {
const b = newBaba(emptyLevel(3, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["BABA", 2, 0],
["BABA", 0, 2],
["IS", 1, 2],
["ROCK", 2, 2],
["ROCK", 0, 4],
["IS", 1, 4],
["BABA", 2, 4],
]);
expect(b.rules()).toEqual(["BABA IS BABA", "BABA IS ROCK", "ROCK IS BABA", "TEXT IS PUSH"]);
expect(b.activeRules()).toEqual(["BABA IS BABA", "ROCK IS BABA", "TEXT IS PUSH"]);
});
test("test_07_x_is_not_x_deletes_and_unlocks", () => {
const b = newBaba(emptyLevel(4, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["NOT", 2, 0],
["BABA", 3, 0],
["BABA", 0, 2],
["IS", 1, 2],
["BABA", 2, 2],
["BABA", 0, 4],
["IS", 1, 4],
["ROCK", 2, 4],
]);
expect(b.rules()).toEqual(["BABA IS BABA", "BABA IS NOT BABA", "BABA IS ROCK", "TEXT IS PUSH"]);
expect(b.activeRules()).toContain("BABA IS ROCK");
expect(b.activeRules()).not.toContain("BABA IS BABA");
});
test("test_07_text_is_not_push", () => {
const b = newBaba(emptyLevel(4, 3));
const rockText = b.place("text:ROCK", 0, 2);
const rock = b.place("rock", 3, 2);
expect(b.activeRules()).toEqual(["TEXT IS PUSH"]);
expect(b.hasProperty(rockText, "PUSH")).toBe(true);
expect(b.hasProperty(rock, "PUSH")).toBe(false);
const textText = b.place("text:TEXT", 0, 0);
write(b, [
["IS", 1, 0],
["NOT", 2, 0],
["PUSH", 3, 0],
]);
expect(b.rules()).toEqual(["TEXT IS NOT PUSH", "TEXT IS PUSH"]);
expect(b.activeRules()).toEqual(["TEXT IS NOT PUSH"]);
expect(b.hasProperty(rockText, "PUSH")).toBe(false);
expect(b.hasProperty(textText, "PUSH")).toBe(false);
});
test("test_07_leading_not_breaks_sentence", () => {
const b = newBaba(emptyLevel(4, 2));
write(b, [
["NOT", 0, 0],
["BABA", 1, 0],
["IS", 2, 0],
["YOU", 3, 0],
]);
const baba = b.place("baba", 0, 1);
expect(b.rules()).toEqual(["TEXT IS PUSH"]);
expect(b.activeRules()).toEqual(["TEXT IS PUSH"]);
expect(b.hasProperty(baba, "YOU")).toBe(false);
});
+3 -1
View File
@@ -69,7 +69,7 @@ In TypeScript each event is a plain object with a `type` field, for example `{ t
## Part II (`Baba`): additions per module
- M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, each distinct rule once, includes `"TEXT IS PUSH"`).
- M7: `hasProperty(id, prop: string): boolean`; `activeRules(): string[]` (after NOT/identity post-pass).
- M7: `hasProperty(id, prop: string): boolean`; `activeRules(): string[]` (after NOT/identity post-pass; same format as `rules()`). From M7, `rules()` also lists negated rules (`"BABA IS NOT YOU"`).
- M8: `move(dir: "up"|"down"|"left"|"right"): void` (movement only, then reparse); `posOf(id): [number, number] | null` (null once destroyed); `kindsAt(x, y): string[]` (sorted).
- M9: `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain).
@@ -111,6 +111,8 @@ pub struct LevelObject {
`place` returns an `Id`, as in Part I; a `Baba` numbers its objects on its own. `rules()` is a set: a rule spelled twice on the board (stacked text can do that) appears once, and no test counts duplicates.
From M7, `rules()` is what the text spells before resolution, and `activeRules()` is the rule set in force after it: `X IS NOT Y` cancels every `X IS Y` and stays in the set itself; a surviving `X IS X` then cancels every conversion `X IS Y` (`Y` a noun other than `X`); `"TEXT IS PUSH"` is listed unless `TEXT IS NOT PUSH` cancels it. `X IS NOT X` stays in `activeRules()` as the record that every X is to vanish (the reference's choice; no test checks it; the objects are removed by the M9 turn). `hasProperty(id, prop)` is true if an active rule gives `prop` to a noun that names the object and no active negated rule takes it from one: a thing is named by its own noun, a text object by `TEXT` (and, after the M7 Expansion Drop, every thing by `ALL`). `hasProperty` on an id the level never issued panics (Rust) or throws (TypeScript).
## Part III (`SpellGrid`)
- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast`, `health`, `log`, `hero`, `won`. Minions are grid objects whose noun is their card's `"noun"` field.