feat: module 09 turn pipeline
Module 9 lesson, scenarios, tests (8, both tracks), reference engines (TURN phase list, conversion, content-registered hazards, win/lost), WEAK Expansion Drop, harness.md M9 semantics, research notes (WEAK page, baba-is-auto Game::MovePlayer, X IS NOT X placement reading). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e339788e64
commit
1f3359cfe2
@@ -0,0 +1,26 @@
|
||||
# Module 9 Expansion Drop: WEAK
|
||||
|
||||
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: WEAK, a property. From the Baba Is You Wiki's WEAK page (recorded in `docs/research/baba-is-you.md` §5): "WEAK is a property that makes an object be destroyed if it overlaps any same-float object or collides with any object.", and "Destruction by WEAK happens after SINK BOOM, but before other overlap checks." The drop adds the overlap half only.
|
||||
- Tests: `tests/rust/test_09x.rs` and `tests/ts/test_09x.test.ts`. Copy the one for your track into your workspace's `tests/`.
|
||||
- Reference answer, one change in data and one in content code:
|
||||
- `solution/words.json`, one file for both tracks: the Module 9 reference's `src/content/words.json` with one entry added, `{ "name": "WEAK", "role": "property" }`.
|
||||
- `solution/rust/src/content/hazards.rs` and `solution/ts/src/content/hazards.ts`: the Module 9 reference's hazards with one hazard added, `weak`, listed after `sink`.
|
||||
- Nothing under `src/engine/` changes, and neither does the harness. WEAK is a new hazard, so it is new code, but it is a hazard the engine's hazards phase runs like any other.
|
||||
- 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.
|
||||
|
||||
The other half of the WEAK page, "If a WEAK object attempts to move into a solid object and fails ... it is destroyed", is a movement check, not a hazard. The reference leaves it out and this test doesn't check it.
|
||||
|
||||
## Scenario
|
||||
|
||||
### test_09x_weak_breaks
|
||||
|
||||
- Setup: `newBaba` with an empty level 5 wide and 4 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `ROCK (0,1) IS (1,1) WEAK (2,1)`. Place an object `baba` at (0,3), an object `rock` at (1,3) (the near rock) and an object `rock` at (3,3) (the far rock).
|
||||
- Action: `step("right")`.
|
||||
- Assertions:
|
||||
- `posOf(baba)` is (1,3): a rock that is only WEAK doesn't block.
|
||||
- `posOf(nearRock)` is `null` and `kindsAt(1,3)` is `["baba"]`: Baba stepped onto the near rock, and a WEAK object that shares its tile with anything is destroyed.
|
||||
- `posOf(farRock)` is (3,3): alone on its tile, the far rock is untouched.
|
||||
|
||||
Before the drop the lexicon doesn't know `WEAK`, so `ROCK IS WEAK` spells nothing: Baba stands on the near rock and both survive.
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Part II: what destroys what at the end of a turn. The engine runs these
|
||||
//! hazards in this order (the order of the wiki's `block()`: SINK, then WEAK,
|
||||
//! then HOT/MELT, then DEFEAT), and destroys each one's victims together before
|
||||
//! the next runs.
|
||||
//! A hazard only reaches objects on the same float level: it asks the level what
|
||||
//! shares a tile (`Level::sharing`). A new hazard property is one more function here.
|
||||
|
||||
use crate::engine::puzzle::{Hazard, Level, ObjectId};
|
||||
|
||||
/// The hazards, in the order the engine runs them.
|
||||
pub fn hazards() -> Vec<Hazard> {
|
||||
vec![sink, weak, hot_melt, defeat]
|
||||
}
|
||||
|
||||
/// The objects that have `property`, oldest first.
|
||||
fn with(level: &Level, property: &str) -> Vec<ObjectId> {
|
||||
level
|
||||
.board()
|
||||
.objects()
|
||||
.map(|o| o.id)
|
||||
.filter(|&id| level.has_property(id, property))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A SINK object that shares its tile with anything destroys all of it, and itself.
|
||||
fn sink(level: &Level) -> Vec<ObjectId> {
|
||||
let mut doomed = vec![];
|
||||
for id in with(level, "SINK") {
|
||||
let others = level.sharing(id);
|
||||
if !others.is_empty() {
|
||||
doomed.push(id);
|
||||
doomed.extend(others);
|
||||
}
|
||||
}
|
||||
doomed
|
||||
}
|
||||
|
||||
/// A WEAK object that shares its tile with anything is destroyed.
|
||||
fn weak(level: &Level) -> Vec<ObjectId> {
|
||||
with(level, "WEAK")
|
||||
.into_iter()
|
||||
.filter(|&id| !level.sharing(id).is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one.
|
||||
fn hot_melt(level: &Level) -> Vec<ObjectId> {
|
||||
destroyed_by(level, "MELT", "HOT")
|
||||
}
|
||||
|
||||
/// A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one.
|
||||
fn defeat(level: &Level) -> Vec<ObjectId> {
|
||||
destroyed_by(level, "YOU", "DEFEAT")
|
||||
}
|
||||
|
||||
/// The objects with `victim` that have `killer` too, or share a tile with one that does.
|
||||
fn destroyed_by(level: &Level, victim: &str, killer: &str) -> Vec<ObjectId> {
|
||||
let deadly = |id: ObjectId| level.has_property(id, killer);
|
||||
with(level, victim)
|
||||
.into_iter()
|
||||
.filter(|&id| deadly(id) || level.sharing(id).into_iter().any(deadly))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Part II: what destroys what at the end of a turn. The engine runs these
|
||||
// hazards in this order (the order of the wiki's `block()`: SINK, then WEAK,
|
||||
// then HOT/MELT, then DEFEAT), and destroys each one's victims together before
|
||||
// the next runs.
|
||||
// A hazard only reaches objects on the same float level: it asks the level what
|
||||
// shares a tile (`Level.sharing`). A new hazard property is one more function here.
|
||||
|
||||
import type { puzzle } from "../engine";
|
||||
|
||||
type Level = puzzle.Level;
|
||||
type ObjectId = puzzle.ObjectId;
|
||||
|
||||
/** The objects that have `property`, oldest first. */
|
||||
const withProperty = (level: Level, property: string): ObjectId[] =>
|
||||
level.board
|
||||
.objects()
|
||||
.map((o) => o.id)
|
||||
.filter((id) => level.hasProperty(id, property));
|
||||
|
||||
/** A SINK object that shares its tile with anything destroys all of it, and itself. */
|
||||
function sink(level: Level): ObjectId[] {
|
||||
const doomed: ObjectId[] = [];
|
||||
for (const id of withProperty(level, "SINK")) {
|
||||
const others = level.sharing(id);
|
||||
if (others.length > 0) doomed.push(id, ...others);
|
||||
}
|
||||
return doomed;
|
||||
}
|
||||
|
||||
/** A WEAK object that shares its tile with anything is destroyed. */
|
||||
function weak(level: Level): ObjectId[] {
|
||||
return withProperty(level, "WEAK").filter((id) => level.sharing(id).length > 0);
|
||||
}
|
||||
|
||||
/** A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one. */
|
||||
function hotMelt(level: Level): ObjectId[] {
|
||||
return destroyedBy(level, "MELT", "HOT");
|
||||
}
|
||||
|
||||
/** A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one. */
|
||||
function defeat(level: Level): ObjectId[] {
|
||||
return destroyedBy(level, "YOU", "DEFEAT");
|
||||
}
|
||||
|
||||
/** The objects with `victim` that have `killer` too, or share a tile with one that does. */
|
||||
function destroyedBy(level: Level, victim: string, killer: string): ObjectId[] {
|
||||
const deadly = (id: ObjectId) => level.hasProperty(id, killer);
|
||||
return withProperty(level, victim).filter((id) => deadly(id) || level.sharing(id).some(deadly));
|
||||
}
|
||||
|
||||
/** The hazards, in the order the engine runs them. */
|
||||
export function hazards(): puzzle.Hazard[] {
|
||||
return [sink, weak, hotMelt, defeat];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{ "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" },
|
||||
{ "name": "OPEN", "role": "property" },
|
||||
{ "name": "SHUT", "role": "property" },
|
||||
{ "name": "HOT", "role": "property" },
|
||||
{ "name": "MELT", "role": "property" },
|
||||
{ "name": "SINK", "role": "property" },
|
||||
{ "name": "DEFEAT", "role": "property" },
|
||||
{ "name": "FLOAT", "role": "property" },
|
||||
{ "name": "WEAK", "role": "property" }
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
// Module 9 Expansion Drop: WEAK. The scenario is in expansion/README.md.
|
||||
use engine::harness::*;
|
||||
|
||||
#[test]
|
||||
fn test_09x_weak_breaks() {
|
||||
let mut b = new_baba(LevelDef {
|
||||
width: 5,
|
||||
height: 4,
|
||||
..Default::default()
|
||||
});
|
||||
for (word, x) in [("BABA", 0), ("IS", 1), ("YOU", 2)] {
|
||||
b.place(&format!("text:{word}"), x, 0);
|
||||
}
|
||||
for (word, x) in [("ROCK", 0), ("IS", 1), ("WEAK", 2)] {
|
||||
b.place(&format!("text:{word}"), x, 1);
|
||||
}
|
||||
let baba = b.place("baba", 0, 3);
|
||||
let near_rock = b.place("rock", 1, 3);
|
||||
let far_rock = b.place("rock", 3, 3);
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(baba), Some((1, 3)));
|
||||
assert_eq!(b.pos_of(near_rock), None);
|
||||
assert_eq!(b.kinds_at(1, 3), vec!["baba"]);
|
||||
assert_eq!(b.pos_of(far_rock), Some((3, 3)));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Module 9 Expansion Drop: WEAK. The scenario is in expansion/README.md.
|
||||
import { expect, test } from "vitest";
|
||||
import { newBaba } from "../src/harness";
|
||||
|
||||
test("test_09x_weak_breaks", () => {
|
||||
const b = newBaba({ width: 5, height: 4, objects: [] });
|
||||
for (const [word, x] of [
|
||||
["BABA", 0],
|
||||
["IS", 1],
|
||||
["YOU", 2],
|
||||
] as const) {
|
||||
b.place(`text:${word}`, x, 0);
|
||||
}
|
||||
for (const [word, x] of [
|
||||
["ROCK", 0],
|
||||
["IS", 1],
|
||||
["WEAK", 2],
|
||||
] as const) {
|
||||
b.place(`text:${word}`, x, 1);
|
||||
}
|
||||
const baba = b.place("baba", 0, 3);
|
||||
const nearRock = b.place("rock", 1, 3);
|
||||
const farRock = b.place("rock", 3, 3);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(baba)).toEqual([1, 3]);
|
||||
expect(b.posOf(nearRock)).toBeNull();
|
||||
expect(b.kindsAt(1, 3)).toEqual(["baba"]);
|
||||
expect(b.posOf(farRock)).toEqual([3, 3]);
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
# Module 9: The turn pipeline
|
||||
|
||||
In Module 8, a key press moved things and nothing else happened. This module makes it a whole turn. Things move, the rules are read again, objects turn into other objects, hazards destroy what they touch, and the level is won or lost. Every one of those steps reads the board the step before it left, so the order is part of the rules. The same board, played in a different order, ends differently. This module writes the order down once, as a list of phases, and runs it.
|
||||
|
||||
Part I had this too. In Module 3, the end of the outermost Phase ran a fixed sequence of steps: the Death Creation Step, then the Death Phase, then the check again. In Module 4 the sequence grew: "After the outermost Phase ends, Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and another Aura Update follows ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). In both games, the order of the steps is a decision written down once, in one place.
|
||||
|
||||
This is the last Baba Is You module. Module 10 joins the two halves of the course.
|
||||
|
||||
You need your Module 8 workspace with all Module 1–8 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.
|
||||
|
||||
The wiki's [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) page opens with this:
|
||||
|
||||
> Within a turn, most properties and verbs take place exactly once. Conditions process when the game checks for what objects have a rule, which is usually right before it uses it.
|
||||
|
||||
Then it lists the turn. Shortened, in the course's words:
|
||||
|
||||
1. Movement (`domovement()`): YOU first, then MOVE, AUTO and CHILL, then NUDGE, FEAR and SHIFT.
|
||||
2. Rule parsing.
|
||||
3. Conversions: REVERT, and "object transformations via IS and WRITE".
|
||||
4. Rule parsing.
|
||||
5. A second movement (`moveblock()`): FOLLOW, BACK, TELE, SHIFT. Then SELECT, the stack limit, and FALL.
|
||||
6. Rule parsing.
|
||||
7. Status (`statusblock()`): directions, TURN, FLOAT. Then FALL again.
|
||||
8. Rule parsing.
|
||||
9. The level block (`levelblock()`).
|
||||
10. `block()`: SINK, BOOM, WEAK, HOT/MELT, DEFEAT, OPEN/SHUT, EAT, MAKE, then BONUS, END and WIN, then out-of-bounds deletion and CRASH.
|
||||
11. Rule parsing.
|
||||
|
||||
On `block()`, the page says: "Deletion occurs after each step that removes objects, but all objects destroyed in the same step are destroyed simultaneously." And the [Rule](https://babaiswiki.fandom.com/wiki/Rule) page says when "rule parsing" actually does anything: "Rules parse when any parsable object changes position or is destroyed, or an object is transformed to text. Between parsings, active rules are simply stored."
|
||||
|
||||
What the hazards do. All quotes are from the wiki's property pages:
|
||||
|
||||
- [SINK](https://babaiswiki.fandom.com/wiki/SINK): "SINK is a property that causes an object to destroy all overlapping same-float objects along with itself when there is at least one overlapping object."
|
||||
- [HOT](https://babaiswiki.fandom.com/wiki/HOT): "HOT is a property that causes the object to destroy any same-float MELT objects it contacts." and "An object that is both HOT and MELT destroys itself."
|
||||
- [DEFEAT](https://babaiswiki.fandom.com/wiki/DEFEAT): "If a DEFEAT object intersects with or is a YOU (or YOU2 or 3D), the YOU object will be destroyed."
|
||||
- [FLOAT](https://babaiswiki.fandom.com/wiki/FLOAT): "EAT and properties BONUS BOOM DEFEAT END HOLD HOT MELT OPEN SHUT SHIFT SINK TELE WEAK WIN require that the interacting objects are both FLOAT or both not FLOAT."
|
||||
|
||||
And winning:
|
||||
|
||||
- [WIN](https://babaiswiki.fandom.com/wiki/WIN): "If an object is both WIN and YOU YOU2 3D, the level is won."
|
||||
- Also from [WIN](https://babaiswiki.fandom.com/wiki/WIN): "WIN is checked very late in the process: any other removal of either object involved will occur first and prevent WIN from WINning."
|
||||
- [DEFEAT](https://babaiswiki.fandom.com/wiki/DEFEAT): "If an object is both WIN and DEFEAT, then DEFEAT takes priority and the YOU object is destroyed."
|
||||
|
||||
Last, conversions. The [IS](https://babaiswiki.fandom.com/wiki/IS) page: "BABA IS BABA prevents BABA from transforming into any object", and "BABA IS NOT BABA causes BABA to vanish in a puff of logic." The [NOT](https://babaiswiki.fandom.com/wiki/NOT) page adds that X IS NOT X "negates X IS X, it enables other X IS Y rules, which can result in transformation in addition to the disappearance." Module 7 put these rules in force. This module carries them out.
|
||||
|
||||
Now put a board together. `BABA IS YOU`, `LAVA IS HOT`, and `BABA IS MELT` written down a column, with its `MELT` lying on a lava tile. Baba steps onto the lava and, in the same step, pushes `MELT` off the end of its sentence. Movement comes first and HOT/MELT comes late, in `block()`, with several "(rule parsing)" points in between. By the time HOT/MELT runs, `BABA IS MELT` is gone. **Baba survives.** No page shows this exact board. The course's research notes mark the outcome as inferred from the documented order, and so does this module's test.
|
||||
|
||||
## Predict
|
||||
|
||||
Write your answers down before you open the discussion, for example in `notes/09.md` in your workspace.
|
||||
|
||||
1. The lava board above. Does Baba survive? Would it if your turn checked hazards before reparsing?
|
||||
2. `BABA IS` is written with an empty tile after it. Baba pushes the word `ROCK` into that tile. Do the Babas become rocks this turn, or next turn?
|
||||
3. `BABA IS BABA` and `BABA IS ROCK` are both on the board. What does a turn do to Baba?
|
||||
4. `KEY IS NOT KEY` and `KEY IS ROCK` are both on the board. What is on the key's tile after a turn?
|
||||
5. `BABA IS YOU`, `BABA IS FLOAT`, `SKULL IS DEFEAT`. Baba steps onto a skull. What happens?
|
||||
6. Baba pushes a rock into water, and `WATER IS SINK`. What is left?
|
||||
7. `FLAG IS WIN AND DEFEAT`. Baba steps onto the flag. Won, lost, or neither?
|
||||
8. In your engine, where does the order of the turn live? If you had to move hazards before conversion, how many places would you edit?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
1. Yes. The rules are parsed right after movement, so `BABA IS MELT` no longer exists when HOT/MELT runs. That is inferred from the order, not stated by a source. A turn that checks hazards with the rules from before the move destroys Baba. The rules didn't change; the order did.
|
||||
|
||||
2. This turn. Conversion comes after the parse that follows movement, so the rule the move completed is already in force. The Babas move first, then become rocks where they stand. (With no `ROCK IS YOU`, nothing is `YOU` any more, and the level is lost.)
|
||||
|
||||
3. Nothing. `BABA IS BABA` blocks every conversion out of Baba. Module 7's resolution pass already took `BABA IS ROCK` out of the active rules.
|
||||
|
||||
4. A rock, and no key. `KEY IS NOT KEY` deletes the key. It also cancels `KEY IS KEY`, so nothing blocks `KEY IS ROCK`, and a rock appears where the key was: "transformation in addition to the disappearance". Module 7's Predict question 3 asked which comes first. In the course they happen in the same step: the key is deleted, and its rock is made as a new object.
|
||||
|
||||
5. Nothing. Baba is FLOAT and the skull isn't, so they are on different float levels, and DEFEAT needs "the interacting objects are both FLOAT or both not FLOAT".
|
||||
|
||||
6. Only Baba. The pushed rock lands on the water, and SINK destroys "all overlapping same-float objects along with itself". Baba was never on the water's tile.
|
||||
|
||||
7. Lost. DEFEAT runs before WIN, and "any other removal of either object involved will occur first and prevent WIN from WINning". Baba is destroyed, so no YOU object is left to win.
|
||||
|
||||
8. If the order is spread across functions that call each other (move, which reparses and then calls convert, which calls hazards), moving a phase means untangling those calls. If it is a list, it is one edit to the list. Question 1 shows why that matters: the game's order is a long list (see "How the real game differs"), and the course builds a short version of it.
|
||||
|
||||
</details>
|
||||
|
||||
## Spec
|
||||
|
||||
From the course repository, copy the module's tests into your workspace:
|
||||
|
||||
```sh
|
||||
node tools/start-module.mjs 09 ~/my-engine
|
||||
```
|
||||
|
||||
### The harness
|
||||
|
||||
Three new functions on `Baba`:
|
||||
|
||||
| TypeScript | Rust |
|
||||
|---|---|
|
||||
| `step(dir): void` | `step(&mut self, dir: Dir)` |
|
||||
| `won(): boolean` | `won(&self) -> bool` |
|
||||
| `lost(): boolean` | `lost(&self) -> bool` |
|
||||
|
||||
- `step(dir)` plays one whole turn, in the course's order: **Movement → Parse → Conversion → Parse → Hazards → Win**, then one last parse, so that `rules()` matches the board. Movement is Module 8's, without its reparse. Module 8's `move(dir)` stays as it was.
|
||||
- `won()` is true once a turn's win check has found a `YOU` object that is `WIN`, or that shares its tile with a `WIN` object on the same float level. It stays true.
|
||||
- `lost()` is true when no object is `YOU`.
|
||||
|
||||
What the phases do:
|
||||
|
||||
- **Conversion.** An object named by `X IS Y`, where `Y` is a noun, becomes a `Y` where it stands, with the same id. `X IS X` blocks it. An object named by `X IS NOT X` is deleted. Every object is decided from the board as movement left it.
|
||||
- **Hazards**, in this order, each one against the objects sharing a tile on the same float level, and each one destroying everything it finds at once, before the next runs:
|
||||
- SINK destroys every object it shares a tile with, and itself.
|
||||
- HOT destroys MELT objects, and an object that is both destroys itself.
|
||||
- DEFEAT destroys YOU objects, and an object that is both destroys itself.
|
||||
- **Win** runs after every hazard.
|
||||
|
||||
New words: `HOT`, `MELT`, `SINK`, `DEFEAT`, `FLOAT`.
|
||||
|
||||
### The tests
|
||||
|
||||
The full statements are in `course/09-turn-pipeline/scenarios.md`.
|
||||
|
||||
- `test_09_lava_saved_by_reparse`: Predict question 1. Baba stands on the lava and survives. The outcome is the course's inference.
|
||||
- `test_09_mid_turn_conversion`: Predict question 2. Both Babas are rocks after the turn, on the tiles they moved to.
|
||||
- `test_09_identity_lock_blocks_conversion`: Predict question 3, with a key under `KEY IS ROCK` to show that conversion works.
|
||||
- `test_09_self_negation_deletes`: Predict question 4.
|
||||
- `test_09_float_separates_hazards`: Predict question 5, with a key that is `YOU` and not `FLOAT` stepping onto a second skull. The key is destroyed.
|
||||
- `test_09_sink_destroys_both`: Predict question 6.
|
||||
- `test_09_you_and_win`: `BABA IS YOU` and `BABA IS WIN`. A turn wins the level; placing the text doesn't.
|
||||
- `test_09_defeat_beats_win`: Predict question 7.
|
||||
|
||||
The first test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_09_lava_saved_by_reparse() {
|
||||
let mut b = new_baba(empty_level(6, 5));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("LAVA", 0, 1), ("IS", 1, 1), ("HOT", 2, 1)]);
|
||||
write(&mut b, &[("BABA", 4, 2), ("IS", 4, 3)]);
|
||||
let melt_text = b.place("text:MELT", 4, 4);
|
||||
let lava = b.place("lava", 4, 4);
|
||||
let baba = b.place("baba", 3, 4);
|
||||
|
||||
assert!(b.has_property(baba, "MELT"));
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(baba), Some((4, 4)));
|
||||
assert_eq!(b.pos_of(melt_text), Some((5, 4)));
|
||||
// Inferred from the order of operations, not stated by a source: Baba survives.
|
||||
assert_eq!(b.pos_of(lava), Some((4, 4)));
|
||||
assert_eq!(b.kinds_at(4, 4), vec!["baba", "lava"]);
|
||||
assert!(!b.rules().contains(&"BABA IS MELT".to_string()));
|
||||
assert!(!b.lost());
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
test("test_09_lava_saved_by_reparse", () => {
|
||||
const b = newBaba(emptyLevel(6, 5));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["LAVA", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["HOT", 2, 1],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 4, 2],
|
||||
["IS", 4, 3],
|
||||
]);
|
||||
const meltText = b.place("text:MELT", 4, 4);
|
||||
const lava = b.place("lava", 4, 4);
|
||||
const baba = b.place("baba", 3, 4);
|
||||
|
||||
expect(b.hasProperty(baba, "MELT")).toBe(true);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(baba)).toEqual([4, 4]);
|
||||
expect(b.posOf(meltText)).toEqual([5, 4]);
|
||||
// Inferred from the order of operations, not stated by a source: Baba survives.
|
||||
expect(b.posOf(lava)).toEqual([4, 4]);
|
||||
expect(b.kindsAt(4, 4)).toEqual(["baba", "lava"]);
|
||||
expect(b.rules()).not.toContain("BABA IS MELT");
|
||||
expect(b.lost()).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
Run `cargo test` or `npm test`. The Module 9 tests fail to compile (Rust) or fail (TypeScript). Modules 1–8 still pass. That is RED.
|
||||
|
||||
## Build
|
||||
|
||||
Make the eight tests pass, and keep Modules 1–8 green. Where code goes:
|
||||
|
||||
- **The turn is a list.** Write the order down once, as a list of phases, and have `step` walk it. Movement doesn't call parsing, and parsing doesn't call conversion. Each phase is called by the turn.
|
||||
- **Parse between phases.** Module 8's `move` reparsed at the end. In the turn, parsing is a phase of its own. It runs after movement and again after conversion, and each phase uses the rules from the parse before it. Hazards and the win check share one set of rules: a hazard that destroys text doesn't change the rules until the turn's last parse.
|
||||
- **Conversion: decide, then change.** Decide what every object becomes from the board as it stands, then apply all of it. Otherwise `BABA IS ROCK` and `ROCK IS BABA` together would turn Baba into a rock and the new rock straight back into Baba. An object keeps its id and its tile and changes its kind. Remember Module 7: `X IS X` locks, and `X IS NOT X` deletes.
|
||||
- **Hazards: destroy together, then the next.** "All objects destroyed in the same step are destroyed simultaneously." Collect a hazard's victims first, then remove them. An object that is HOT and MELT destroys itself, and a second MELT object on its tile goes with it. Remove the first as soon as you find it, and the second no longer has HOT company, so it survives.
|
||||
- **Float levels.** Every hazard and the win check ask the same question: which other objects share this tile, on this float level? Answer it in one place.
|
||||
- **Win last, lost any time.** The win check runs after the hazards, so a destroyed YOU can't win. `lost()` just asks whether anything is YOU now.
|
||||
- **One more question.** In Module 8, each movement property was something the planner ran. Is each hazard a branch in your hazards phase, or something the phase runs? The Expansion Drop brings a new hazard, and you won't be allowed to touch `src/engine/`.
|
||||
|
||||
<details><summary>Hint 1 — nudge</summary>
|
||||
|
||||
Write `step` as a loop over a constant list: `[movement, parse, conversion, parse, hazards, win, parse]`. Then write each phase as a function that does one thing to the board, using the rules it finds there. The tests then mostly check one phase each.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Hint 2 — concept</summary>
|
||||
|
||||
- **Split Module 8's `move`.** Movement without the reparse becomes the first phase, and `move` becomes "movement, then parse", so the Module 8 tests keep passing.
|
||||
- **Conversion is a plan too.** For each object, list the active rules that name it. If one of them is `X IS X`, the object is locked. If one is `X IS NOT X`, it is deleted. Otherwise each `X IS Y` with `Y` a noun is a target. Record the changes, then apply them: remove, or change the kind in place.
|
||||
- **A hazard is a function from the level to a list of ids.** SINK: every SINK object with company on its tile, plus the company. HOT/MELT: every MELT object that is HOT or has HOT company. DEFEAT: every YOU object that is DEFEAT or has DEFEAT company. The hazards phase runs them in order and removes each list before running the next.
|
||||
- **"Company" means same float level.** One function, `sharing(id)`, returns the other objects on the tile whose FLOAT matches this one's. The hazards and the win check all use it.
|
||||
- **Win** is: some YOU object is WIN, or has WIN company.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Hint 3 — pseudo-code</summary>
|
||||
|
||||
```text
|
||||
TURN = [movement, parse, conversion, parse, hazards, win, parse]
|
||||
|
||||
step(dir):
|
||||
for phase in TURN: run phase
|
||||
|
||||
conversion():
|
||||
changes = []
|
||||
for o in objects:
|
||||
rules = active rules whose subject names o
|
||||
if some rule in rules is X IS X: continue // locked
|
||||
deleted = some rule in rules is X IS NOT X
|
||||
into = distinct noun complements of X IS Y in rules, other than o's own kind
|
||||
if deleted or into: changes += (o, deleted, into)
|
||||
for (o, deleted, into) in changes:
|
||||
if deleted: remove o; place each of into on o's tile
|
||||
else: o.kind = into[0]; place each of into[1..] on o's tile
|
||||
|
||||
sharing(id) = other objects on id's tile with has(FLOAT) == has(id, FLOAT)
|
||||
|
||||
hazards():
|
||||
for hazard in content.hazards: // sink, hot_melt, defeat
|
||||
remove every id in hazard(level)
|
||||
|
||||
sink(level) = for s with SINK: if sharing(s) not empty: s and sharing(s)
|
||||
hot_melt(level) = m with MELT where m is HOT or some o in sharing(m) is HOT
|
||||
defeat(level) = y with YOU where y is DEFEAT or some o in sharing(y) is DEFEAT
|
||||
|
||||
win(): if some y with YOU is WIN or has some WIN o in sharing(y): won = true
|
||||
lost(): no object is YOU
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
When your tests pass, compare with the reference engine in `course/09-turn-pipeline/reference/rust/src/` or `course/09-turn-pipeline/reference/ts/src/`. Look at the new `engine/puzzle/turn` (the phases and `TURN`), `engine/puzzle/level` (`step`, `convert`, `sharing`, the hazards phase and the win check), and the new `content/hazards`.
|
||||
|
||||
Decisions in the reference that the tests don't force:
|
||||
|
||||
- **The turn is a constant in the engine.** `TURN` lists the phases and `step` walks it. The order is the engine's because it is the game's rules for a turn, not any one word's.
|
||||
- **The hazards are content.** SINK, HOT/MELT and DEFEAT are three functions in `src/content/`. The engine runs them in the order content lists them, which is the wiki's `block()` order, and removes each one's victims before running the next. This is the same shape as Module 8's movement responses.
|
||||
- **The engine knows `YOU`, `WIN` and `FLOAT`.** `YOU` picks who moves (Module 8). `WIN` is the engine's own end-of-turn check. `FLOAT` is in the engine because it isn't a hazard. It decides which objects share a tile at all, and the win check needs that as much as the hazards do ("a same-float YOU YOU2 3D object", [WIN](https://babaiswiki.fandom.com/wiki/WIN)). If each hazard filtered by FLOAT itself, a new hazard could forget to, and the engine's win check would need the rule anyway. So it lives in one place, `sharing`, and hazards can't get it wrong.
|
||||
- **Several conversions.** "if you write A IS B and A IS C simultaneously in the same turn ... then you get both a B and a C for each A" ([Advanced rulebook](https://babaiswiki.fandom.com/wiki/Advanced_rulebook)). The object becomes the first target and keeps its id; each other target is a new object on its tile.
|
||||
- **`X IS NOT X` deletes in the conversion step**, together with the conversions. The deleted object's other targets still appear, as new objects. No source gives the deletion its own place in the turn; see "How the real game differs".
|
||||
- **`won()` stays true.** In the game, winning ends the level. In the course, `step` keeps playing if you call it again, and `won()` stays true.
|
||||
- **The last parse.** The course's turn ends with a parse, as the game's does, so `rules()` after `step` matches the board. No phase reads it until the next turn.
|
||||
|
||||
> **How the real game differs**
|
||||
> - **The order.** The course's turn is a labelled simplification. Side by side, the course on the left and the game, from [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations), on the right:
|
||||
>
|
||||
> | Course | Game |
|
||||
> |---|---|
|
||||
> | Movement (YOU) | Movement: YOU, then MOVE/AUTO/CHILL, NUDGE, FEAR, SHIFT, with OPEN/SHUT, WEAK/EAT, SWAP, PUSH/PULL and STOP checked on every step |
|
||||
> | Parse | Rule parsing |
|
||||
> | Conversion (IS, and X IS NOT X) | Conversions: REVERT, IS, WRITE ("X IS ALL takes the lowest priority") |
|
||||
> | Parse | Rule parsing |
|
||||
> | | Second movement: FOLLOW, BACK, TELE, SHIFT; then SELECT, the stack limit, FALL |
|
||||
> | | Rule parsing |
|
||||
> | | Status: directions, TURN, FLOAT; then FALL |
|
||||
> | | Rule parsing |
|
||||
> | | Level block |
|
||||
> | Hazards: SINK, HOT/MELT, DEFEAT | `block()`: SINK, BOOM, WEAK, HOT/MELT, DEFEAT, OPEN/SHUT, EAT, MAKE |
|
||||
> | Win | BONUS, END, WIN (the tail of `block()`), then out-of-bounds deletion, CRASH |
|
||||
> | Parse | Rule parsing |
|
||||
>
|
||||
> - **When parsing happens.** The game reparses only "when any parsable object changes position or is destroyed, or an object is transformed to text" ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). The course reparses at every Parse phase. The result is the same; the game does less work.
|
||||
> - **FLOAT updates once per turn.** "Whether an object is FLOATing or not updates once per turn, or at object creation (including transforms)" ([FLOAT](https://babaiswiki.fandom.com/wiki/FLOAT)), in the status block. The course reads FLOAT from the rules in force whenever a hazard asks.
|
||||
> - **Words the course doesn't have.** BOOM, WEAK (the Expansion Drop), EAT, MAKE, BONUS and END in `block()`; SAFE, which protects from SINK ("Overlapping objects are destroyed (unless SAFE)", [SINK](https://babaiswiki.fandom.com/wiki/SINK)) and from MELT ([MELT](https://babaiswiki.fandom.com/wiki/MELT)); HAS, "checked after every standard destruction" ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations)); and the overlap half of OPEN/SHUT ("If an OPEN object is on ... a same-float SHUT object", [OPEN](https://babaiswiki.fandom.com/wiki/OPEN)), which the course checks only during movement.
|
||||
> - **Exceptions to DEFEAT beating WIN.** The research notes record three, from the [WIN](https://babaiswiki.fandom.com/wiki/WIN) page: an object that is YOU, DEFEAT, WIN and SAFE wins; a YOU object respawned by HAS can still win in the same turn; and `LEVEL IS WIN AND DEFEAT` wins. The course has no SAFE, HAS or LEVEL.
|
||||
> - **Conversions.** The course converts only into nouns that name their own things: no `X IS TEXT`, no `X IS ALL`, no REVERT or WRITE. In the game, a stacked noun in `A IS B` parses more than once, which duplicates objects (research notes, from the [Advanced rulebook](https://babaiswiki.fandom.com/wiki/Advanced_rulebook)). The course makes one object per distinct target.
|
||||
> - **Where `X IS NOT X` deletes.** The [NOT](https://babaiswiki.fandom.com/wiki/NOT) page says X IS NOT X "makes an object disappear" and enables "transformation in addition to the disappearance", but no source read gives the deletion its own place in the turn. Putting it in the conversion step is the course's reading.
|
||||
> - **The lava board is inferred.** Baba surviving follows from the documented order. No source shows it.
|
||||
> - **`lost()`** is the course's definition, "no YOU object remains". No source read describes what the game does when nothing is YOU.
|
||||
|
||||
## Name it
|
||||
|
||||
The pattern is a **phase pipeline**: an ordered list of phases, each of which reads the state the previous one left. The list is data, so the order is visible in one place and changes in one place. Each phase does one job and doesn't call the next. The bugs this prevents are ordering bugs: a hazard that runs before the rules are re-read, a win that is checked before a DEFEAT. In a pipeline, each of those is a position in a list that you can read.
|
||||
|
||||
The reference's turn:
|
||||
|
||||
```rust
|
||||
/// The turn, in order.
|
||||
pub const TURN: [Phase; 7] = [
|
||||
Phase::Movement,
|
||||
Phase::Parse,
|
||||
Phase::Conversion,
|
||||
Phase::Parse,
|
||||
Phase::Hazards,
|
||||
Phase::Win,
|
||||
Phase::Parse,
|
||||
];
|
||||
|
||||
/// Play one turn: the phases of `TURN`, in order.
|
||||
pub fn step(&mut self, dir: Dir) {
|
||||
for phase in TURN {
|
||||
match phase {
|
||||
Phase::Movement => self.movement(dir),
|
||||
Phase::Parse => self.reparse(),
|
||||
Phase::Conversion => self.convert(),
|
||||
Phase::Hazards => self.run_hazards(),
|
||||
Phase::Win => self.check_win(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
/** The turn, in order. */
|
||||
export const TURN: readonly Phase[] = ["movement", "parse", "conversion", "parse", "hazards", "win", "parse"];
|
||||
|
||||
/** Play one turn: the phases of `TURN`, in order. */
|
||||
step(dir: Dir): void {
|
||||
for (const phase of TURN) {
|
||||
switch (phase) {
|
||||
case "movement":
|
||||
this.movement(dir);
|
||||
break;
|
||||
case "parse":
|
||||
this.reparse();
|
||||
break;
|
||||
case "conversion":
|
||||
this.convert();
|
||||
break;
|
||||
case "hazards":
|
||||
this.runHazards();
|
||||
break;
|
||||
case "win":
|
||||
this.checkWin();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two levels of list are at work. The engine owns the turn, and content owns what happens inside the hazards phase: a list of hazards, each a function from the level to the objects it destroys. Module 8 had the same split. The engine owned the planner, and content owned the responses.
|
||||
|
||||
baba-is-auto writes its turn the same way, as a fixed run of calls in `Game::MovePlayer` ([`Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp), line 495):
|
||||
|
||||
- `ProcessPlayerMove`, then `ParseRules`, `ProcessDirectionProperties` and `ProcessMoveProperty`.
|
||||
- `ParseRules`, `ProcessTransformations`, and `ParseRules` again.
|
||||
- The hazards: `ProcessSink`, `ProcessWeak`, `ProcessHotMelt`, `ProcessDefeat` and `ProcessOpenShut`.
|
||||
- `CheckPlayState`, which sets won or lost, and a last `ParseRules`.
|
||||
|
||||
Each hazard there is a named function in the engine, and none of them checks FLOAT. Its own [`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Documents/rule-language.md) says its FLOAT "is not applied to the other overlap/effect properties". Compare that with hazards as content, and think about what each costs when a new hazard arrives. As in Module 8, baba-is-auto is an example, not an authority.
|
||||
|
||||
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. The turn names no noun. Conversion turns whatever the rules name into whatever they say.
|
||||
|
||||
## Expansion Drop
|
||||
|
||||
A new word arrives: `WEAK`. From the wiki's [WEAK](https://babaiswiki.fandom.com/wiki/WEAK) page: "If a WEAK object overlaps any same-float object, it is destroyed." It runs among the hazards: "Destruction by WEAK happens after SINK BOOM, but before other overlap checks."
|
||||
|
||||
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either. Your word list changes, and so may the rest of your content.
|
||||
|
||||
1. Commit your workspace and note the commit: `git add -A && git commit -m "module 9 done"`.
|
||||
2. Copy the expansion test for your track from `course/09-turn-pipeline/expansion/tests/rust/test_09x.rs` or `course/09-turn-pipeline/expansion/tests/ts/test_09x.test.ts` into your workspace's `tests/`. Run it and watch it fail.
|
||||
3. Add `WEAK` to your word list, and make it do what it says.
|
||||
4. When the tests pass, run the check from the course repository:
|
||||
|
||||
```sh
|
||||
node tools/expansion-check.mjs ~/my-engine HEAD
|
||||
```
|
||||
|
||||
The test, `test_09x_weak_breaks`, has two WEAK rocks. Baba steps onto one, which breaks; the other, alone on its tile, stays. The full scenario is in `course/09-turn-pipeline/expansion/README.md`.
|
||||
|
||||
<details><summary>Discussion: what this drop measures</summary>
|
||||
|
||||
If each hazard is a function the hazards phase runs, `WEAK` is one word in the list and one function in content, listed after `sink`:
|
||||
|
||||
```rust
|
||||
/// A WEAK object that shares its tile with anything is destroyed.
|
||||
fn weak(level: &Level) -> Vec<ObjectId> {
|
||||
with(level, "WEAK")
|
||||
.into_iter()
|
||||
.filter(|&id| !level.sharing(id).is_empty())
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
/** A WEAK object that shares its tile with anything is destroyed. */
|
||||
function weak(level: Level): ObjectId[] {
|
||||
return withProperty(level, "WEAK").filter((id) => level.sharing(id).length > 0);
|
||||
}
|
||||
```
|
||||
|
||||
That is the reference answer, in `course/09-turn-pipeline/expansion/solution/`: `words.json` with `WEAK` added, and the hazards with `weak` between `sink` and `hot_melt`. Because `sharing` already applies float levels, `weak` gets them for free. Two WEAK objects on one tile both break, with no extra code.
|
||||
|
||||
Shapes that fail this drop:
|
||||
|
||||
- **Hazards as branches in the turn.** If the hazards phase says `if SINK ... if MELT ... if DEFEAT ...`, WEAK is a fourth branch, under `src/engine/`. Make hazards something the phase runs, as a normal commit, then redo the drop.
|
||||
- **Destroying while scanning.** If a hazard removes objects as it finds them, two WEAK rocks on one tile go wrong. The first has company and is removed; then the second is alone, and survives. The WEAK page says "Multiple same-float WEAK objects overlapping each other are all destroyed." Collect, then remove.
|
||||
- **Float level in each hazard.** If every hazard filters by FLOAT itself, WEAK has to remember to. This test doesn't catch it if it forgets. The next FLOAT puzzle would.
|
||||
|
||||
> **How the real game differs**
|
||||
> - WEAK also breaks on collision: "If a WEAK object attempts to move into a solid object and fails (e.g. STOP PULL or blocked PUSH), regardless of float levels, it is destroyed", and "If an object attempts to move into a same-float WEAK solid object that is not PUSHable, the movement is not blocked" ([WEAK](https://babaiswiki.fandom.com/wiki/WEAK)). Those are movement checks, which would be movement responses in the course. The drop leaves them out.
|
||||
> - BOOM runs between SINK and WEAK in the game. The course has no BOOM.
|
||||
|
||||
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 [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations): the whole turn. Read it with this module's turn beside it and mark each line the course leaves out.
|
||||
- The wiki's [SINK](https://babaiswiki.fandom.com/wiki/SINK), [HOT](https://babaiswiki.fandom.com/wiki/HOT), [MELT](https://babaiswiki.fandom.com/wiki/MELT), [DEFEAT](https://babaiswiki.fandom.com/wiki/DEFEAT), [WIN](https://babaiswiki.fandom.com/wiki/WIN) and [FLOAT](https://babaiswiki.fandom.com/wiki/FLOAT) pages: every hazard in this module, and the exceptions it leaves out (SAFE, HAS, LEVEL). Then [WEAK](https://babaiswiki.fandom.com/wiki/WEAK) for the drop.
|
||||
- baba-is-auto, `Game::MovePlayer` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (line 495), and the hazard functions and `CheckPlayState` after it. It is a turn as a fixed list of calls, in C++. It is an example, **not an authority** on the game: its own docs list where it diverges ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Documents/rule-language.md)).
|
||||
@@ -0,0 +1 @@
|
||||
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "09" }
|
||||
@@ -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,19 @@
|
||||
# Module 9 reference engine (Rust)
|
||||
|
||||
The course's own engine as of the end of Module 9. Part I's card engine is unchanged, and all of its tests still pass. The Part II engine in `src/engine/puzzle/` now plays a whole turn: movement, parse, conversion, parse, hazards, win, parse. Module 8'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`, the property `PUSH` of the base rule `TEXT IS PUSH`, the property `YOU`, which picks the objects that move, and from Module 9 the properties `WIN` (the win check) and `FLOAT` (which objects share a tile).
|
||||
- `turn.rs` (new): the turn. `Phase`, `TURN` (the phases in order) and `Hazard` (content's rule for what destroys what).
|
||||
- `level.rs`: a level in play. Module 9 adds `Level::step`, which runs the phases of `TURN`; the conversion phase (`X IS Y` in place, `X IS X` locks, `X IS NOT X` deletes); the hazards phase, which runs content's hazards in order; the win check; `Level::sharing` (the other objects on a tile at the same float level); `won` and `lost`. `Level::new` takes the hazards. `move_you` is now movement, then a reparse.
|
||||
- `grid.rs`: Module 9 adds `Board::set_kind`.
|
||||
- `word.rs`: Module 9 adds `Lexicon::thing_named` (what a conversion into a noun makes).
|
||||
- `movement.rs`, `parse.rs`, `resolve.rs`: unchanged.
|
||||
- `mod.rs`: the module's exports.
|
||||
- The rest of `src/engine/`: Part I, unchanged.
|
||||
- `src/content/hazards.rs` (new) and `src/content/mod.rs`: the hazards, in the order the engine runs them: `SINK`, `HOT`/`MELT`, `DEFEAT`. What destroys what at the end of a turn is content, not engine.
|
||||
- `src/content/movement.rs`: unchanged.
|
||||
- `src/content/words.json`: Module 9 adds `HOT`, `MELT`, `SINK`, `DEFEAT` and `FLOAT`.
|
||||
- `src/harness.rs`: the adapter the course tests call. Module 9 adds `Baba::step`, `Baba::won` and `Baba::lost`.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 09` from the course repository.
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Part II: what destroys what at the end of a turn. The engine runs these
|
||||
//! hazards in this order (the order of the wiki's `block()`: SINK, then HOT/MELT,
|
||||
//! then DEFEAT), and destroys each one's victims together before the next runs.
|
||||
//! A hazard only reaches objects on the same float level: it asks the level what
|
||||
//! shares a tile (`Level::sharing`). A new hazard property is one more function here.
|
||||
|
||||
use crate::engine::puzzle::{Hazard, Level, ObjectId};
|
||||
|
||||
/// The hazards, in the order the engine runs them.
|
||||
pub fn hazards() -> Vec<Hazard> {
|
||||
vec![sink, hot_melt, defeat]
|
||||
}
|
||||
|
||||
/// The objects that have `property`, oldest first.
|
||||
fn with(level: &Level, property: &str) -> Vec<ObjectId> {
|
||||
level
|
||||
.board()
|
||||
.objects()
|
||||
.map(|o| o.id)
|
||||
.filter(|&id| level.has_property(id, property))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A SINK object that shares its tile with anything destroys all of it, and itself.
|
||||
fn sink(level: &Level) -> Vec<ObjectId> {
|
||||
let mut doomed = vec![];
|
||||
for id in with(level, "SINK") {
|
||||
let others = level.sharing(id);
|
||||
if !others.is_empty() {
|
||||
doomed.push(id);
|
||||
doomed.extend(others);
|
||||
}
|
||||
}
|
||||
doomed
|
||||
}
|
||||
|
||||
/// A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one.
|
||||
fn hot_melt(level: &Level) -> Vec<ObjectId> {
|
||||
destroyed_by(level, "MELT", "HOT")
|
||||
}
|
||||
|
||||
/// A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one.
|
||||
fn defeat(level: &Level) -> Vec<ObjectId> {
|
||||
destroyed_by(level, "YOU", "DEFEAT")
|
||||
}
|
||||
|
||||
/// The objects with `victim` that have `killer` too, or share a tile with one that does.
|
||||
fn destroyed_by(level: &Level, victim: &str, killer: &str) -> Vec<ObjectId> {
|
||||
let deadly = |id: ObjectId| level.has_property(id, killer);
|
||||
with(level, victim)
|
||||
.into_iter()
|
||||
.filter(|&id| deadly(id) || level.sharing(id).into_iter().any(deadly))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 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;
|
||||
|
||||
mod hazards;
|
||||
mod movement;
|
||||
pub use hazards::hazards;
|
||||
pub use movement::responses as movement;
|
||||
|
||||
/// 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,44 @@
|
||||
//! Part II: what each property does when an object moves. The engine plans a move
|
||||
//! one step at a time and runs these responses for every step, in this order (the
|
||||
//! order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new
|
||||
//! movement property is one more response here.
|
||||
|
||||
use crate::engine::puzzle::{Planner, Response, Step};
|
||||
|
||||
/// The movement responses, in the order the engine runs them.
|
||||
pub fn responses() -> Vec<Response> {
|
||||
vec![open_shut, push, stop]
|
||||
}
|
||||
|
||||
/// An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one)
|
||||
/// destroys both, and that object doesn't block the step. One partner per step:
|
||||
/// the oldest.
|
||||
fn open_shut(p: &mut Planner, s: Step) -> bool {
|
||||
for (mine, theirs) in [("OPEN", "SHUT"), ("SHUT", "OPEN")] {
|
||||
if !p.has(s.mover, mine) {
|
||||
continue;
|
||||
}
|
||||
let partner = p.staying_at(s.to).into_iter().find(|&o| p.has(o, theirs));
|
||||
if let Some(partner) = partner {
|
||||
p.destroy(partner);
|
||||
p.destroy(s.mover);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Every PUSH object on the tile ahead must step the same way, or the step fails.
|
||||
fn push(p: &mut Planner, s: Step) -> bool {
|
||||
for o in p.staying_at(s.to) {
|
||||
if p.has(o, "PUSH") && !p.try_move(o) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A STOP object still on the tile ahead blocks the step.
|
||||
fn stop(p: &mut Planner, s: Step) -> bool {
|
||||
!p.staying_at(s.to).into_iter().any(|o| p.has(o, "STOP"))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "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" },
|
||||
{ "name": "OPEN", "role": "property" },
|
||||
{ "name": "SHUT", "role": "property" },
|
||||
{ "name": "HOT", "role": "property" },
|
||||
{ "name": "MELT", "role": "property" },
|
||||
{ "name": "SINK", "role": "property" },
|
||||
{ "name": "DEFEAT", "role": "property" },
|
||||
{ "name": "FLOAT", "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,45 @@
|
||||
//! 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.
|
||||
//! Module 8: movement, planned in full and then committed; push chains.
|
||||
|
||||
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,154 @@
|
||||
//! 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 std::fmt;
|
||||
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind as it is placed: `"<noun>"` in lower case, or `"text:<WORD>"`.
|
||||
impl fmt::Display for Kind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Kind::Thing { noun } => write!(f, "{}", noun.to_lowercase()),
|
||||
Kind::Text { word } => write!(f, "{}{word}", Self::TEXT_PREFIX),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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}"))
|
||||
}
|
||||
|
||||
/// The object `id`, or `None` once it has been removed. Panics on an id this
|
||||
/// board never issued.
|
||||
pub fn find(&self, id: ObjectId) -> Option<&Object> {
|
||||
assert!((1..=self.next_id).contains(&id), "no object {id}");
|
||||
self.objects.get(&id)
|
||||
}
|
||||
|
||||
/// Take object `id` off the board for good.
|
||||
pub fn remove(&mut self, id: ObjectId) {
|
||||
self.objects.remove(&id);
|
||||
}
|
||||
|
||||
/// Move object `id` by `(dx, dy)`. Panics if it would leave the board.
|
||||
pub fn shift(&mut self, id: ObjectId, (dx, dy): (i32, i32)) {
|
||||
let (width, height) = (self.width, self.height);
|
||||
let o = self
|
||||
.objects
|
||||
.get_mut(&id)
|
||||
.unwrap_or_else(|| panic!("no object {id}"));
|
||||
let (x, y) = (o.x + dx, o.y + dy);
|
||||
assert!(
|
||||
(0..width).contains(&x) && (0..height).contains(&y),
|
||||
"({x}, {y}) is outside the {width}x{height} level"
|
||||
);
|
||||
(o.x, o.y) = (x, y);
|
||||
}
|
||||
|
||||
/// Make object `id` a `kind`, where it stands. Panics if there is no such object.
|
||||
pub fn set_kind(&mut self, id: ObjectId, kind: Kind) {
|
||||
self.objects
|
||||
.get_mut(&id)
|
||||
.unwrap_or_else(|| panic!("no object {id}"))
|
||||
.kind = kind;
|
||||
}
|
||||
|
||||
/// The objects on tile (x, y), oldest first.
|
||||
pub fn at(&self, x: i32, y: i32) -> impl Iterator<Item = &Object> {
|
||||
self.objects().filter(move |o| (o.x, o.y) == (x, y))
|
||||
}
|
||||
|
||||
/// Every object, oldest first.
|
||||
pub fn objects(&self) -> impl Iterator<Item = &Object> {
|
||||
self.objects.values()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! 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`.
|
||||
//!
|
||||
//! A move (Module 8) is planned for each `YOU` object, then committed; the rules are
|
||||
//! reparsed once, after all of them.
|
||||
//!
|
||||
//! A turn (Module 9) runs the phases of `TURN` in order: movement, parse,
|
||||
//! conversion, parse, content's hazards, the win check, parse.
|
||||
|
||||
use super::grid::{Board, Kind, LevelDef, ObjectId};
|
||||
use super::movement::{Dir, Plan, Planner, Response};
|
||||
use super::parse::{parse, Rule};
|
||||
use super::resolve::resolve;
|
||||
use super::turn::{Hazard, Phase, TURN};
|
||||
use super::word::{Lexicon, WordDef};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Level {
|
||||
board: Board,
|
||||
lexicon: Lexicon,
|
||||
responses: Vec<Response>,
|
||||
hazards: Vec<Hazard>,
|
||||
rules: Vec<Rule>,
|
||||
active: Vec<Rule>,
|
||||
won: bool,
|
||||
}
|
||||
|
||||
/// The property that makes an object move when the player moves.
|
||||
pub const YOU: &str = "YOU";
|
||||
/// The property that wins the level when a `YOU` object is on it, or is it.
|
||||
pub const WIN: &str = "WIN";
|
||||
/// The property that puts an object on the other float level: only objects on
|
||||
/// the same level share a tile for hazards and for winning.
|
||||
pub const FLOAT: &str = "FLOAT";
|
||||
|
||||
impl Level {
|
||||
/// A level from its definition, the words content provides, and content's
|
||||
/// movement responses and hazards, each in the order they are to run.
|
||||
pub fn new(
|
||||
def: &LevelDef,
|
||||
words: &[WordDef],
|
||||
responses: Vec<Response>,
|
||||
hazards: Vec<Hazard>,
|
||||
) -> Level {
|
||||
let mut level = Level {
|
||||
board: Board::new(def.width, def.height),
|
||||
lexicon: Lexicon::new(words),
|
||||
responses,
|
||||
hazards,
|
||||
rules: vec![],
|
||||
active: vec![],
|
||||
won: false,
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
/// The movement responses, in the order they run.
|
||||
pub fn responses(&self) -> &[Response] {
|
||||
&self.responses
|
||||
}
|
||||
|
||||
/// 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. A
|
||||
/// destroyed object has no properties.
|
||||
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
|
||||
let Some(object) = self.board.find(id) else {
|
||||
return false;
|
||||
};
|
||||
let kind = &object.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)
|
||||
}
|
||||
|
||||
/// The other objects on object `id`'s tile that are on its float level (both
|
||||
/// `FLOAT` or both not), oldest first. Hazards and the win check ask this.
|
||||
pub fn sharing(&self, id: ObjectId) -> Vec<ObjectId> {
|
||||
let o = self.board.get(id);
|
||||
let floats = self.has_property(id, FLOAT);
|
||||
self.board
|
||||
.at(o.x, o.y)
|
||||
.filter(|other| other.id != id && self.has_property(other.id, FLOAT) == floats)
|
||||
.map(|other| other.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Has a turn's win check found a `YOU` object on a `WIN` one? Stays true.
|
||||
pub fn won(&self) -> bool {
|
||||
self.won
|
||||
}
|
||||
|
||||
/// Is there no `YOU` object left?
|
||||
pub fn lost(&self) -> bool {
|
||||
!self.board.objects().any(|o| self.has_property(o.id, YOU))
|
||||
}
|
||||
|
||||
/// Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement
|
||||
/// only: no conversion, hazard or win.
|
||||
pub fn move_you(&mut self, dir: Dir) {
|
||||
self.movement(dir);
|
||||
self.reparse();
|
||||
}
|
||||
|
||||
/// Play one turn: the phases of `TURN`, in order.
|
||||
pub fn step(&mut self, dir: Dir) {
|
||||
for phase in TURN {
|
||||
match phase {
|
||||
Phase::Movement => self.movement(dir),
|
||||
Phase::Parse => self.reparse(),
|
||||
Phase::Conversion => self.convert(),
|
||||
Phase::Hazards => self.run_hazards(),
|
||||
Phase::Win => self.check_win(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `YOU` objects move one at a time, the one furthest ahead in `dir` first
|
||||
/// (oldest first on a tie); each one's move is planned in full, then committed,
|
||||
/// before the next is planned. Every move uses the rules in force before the
|
||||
/// first.
|
||||
fn movement(&mut self, dir: Dir) {
|
||||
let (dx, dy) = dir.delta();
|
||||
let mut you: Vec<_> = self
|
||||
.board
|
||||
.objects()
|
||||
.filter(|o| self.has_property(o.id, YOU))
|
||||
.map(|o| (-(o.x * dx + o.y * dy), o.id))
|
||||
.collect();
|
||||
you.sort();
|
||||
for (_, id) in you {
|
||||
if self.board.find(id).is_none() {
|
||||
continue; // destroyed by an earlier move
|
||||
}
|
||||
let mut planner = Planner::new(self, dir);
|
||||
if planner.try_move(id) {
|
||||
let plan = planner.into_plan();
|
||||
self.commit(&plan, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(&mut self, plan: &Plan, dir: Dir) {
|
||||
for &id in &plan.destroyed {
|
||||
self.board.remove(id);
|
||||
}
|
||||
for &id in plan.moving.difference(&plan.destroyed) {
|
||||
self.board.shift(id, dir.delta());
|
||||
}
|
||||
}
|
||||
|
||||
/// Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y`
|
||||
/// where it stands, keeping its id; with several `Y`s it becomes the first,
|
||||
/// and each other `Y` is a new object on its tile. An `X IS X` naming the
|
||||
/// object blocks this. An object named by `X IS NOT X` is deleted, and still
|
||||
/// makes its `Y`s, as new objects: the negation cancelled `X IS X`.
|
||||
///
|
||||
/// Every object is decided from the board as movement left it, before any
|
||||
/// changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys.
|
||||
fn convert(&mut self) {
|
||||
let mut changes = vec![];
|
||||
for o in self.board.objects() {
|
||||
let about_it: Vec<&Rule> = self
|
||||
.active
|
||||
.iter()
|
||||
.filter(|r| self.lexicon.names(&r.subject, &o.kind))
|
||||
.collect();
|
||||
let deleted = about_it.iter().any(|r| r.negated && r.is_identity());
|
||||
let locked = about_it.iter().any(|r| !r.negated && r.is_identity());
|
||||
let mut into: Vec<Kind> = vec![];
|
||||
if !locked {
|
||||
for r in about_it.iter().filter(|r| !r.negated && r.is_conversion()) {
|
||||
match self.lexicon.thing_named(&r.complement) {
|
||||
Some(kind) if kind != o.kind && !into.contains(&kind) => into.push(kind),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if deleted || !into.is_empty() {
|
||||
changes.push((o.id, (o.x, o.y), deleted, into));
|
||||
}
|
||||
}
|
||||
for (id, (x, y), deleted, into) in changes {
|
||||
let mut into = into.into_iter();
|
||||
if deleted {
|
||||
self.board.remove(id);
|
||||
} else if let Some(first) = into.next() {
|
||||
self.board.set_kind(id, first);
|
||||
}
|
||||
for kind in into {
|
||||
self.board.place(kind, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run content's hazards in order. Each one's victims are destroyed together
|
||||
/// before the next runs; no reparse until the turn's last phase.
|
||||
fn run_hazards(&mut self) {
|
||||
for i in 0..self.hazards.len() {
|
||||
let hazard = self.hazards[i];
|
||||
for id in hazard(self) {
|
||||
self.board.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The level is won if any `YOU` object is `WIN`, or shares its tile with a
|
||||
/// `WIN` object on its float level.
|
||||
fn check_win(&mut self) {
|
||||
let wins = |id: ObjectId| self.has_property(id, WIN);
|
||||
let won = self
|
||||
.board
|
||||
.objects()
|
||||
.filter(|o| self.has_property(o.id, YOU))
|
||||
.any(|o| wins(o.id) || self.sharing(o.id).into_iter().any(wins));
|
||||
self.won |= won;
|
||||
}
|
||||
|
||||
fn reparse(&mut self) {
|
||||
self.rules = parse(&self.board, &self.lexicon);
|
||||
self.active = resolve(&self.rules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Module 8: movement. `Level::move_you` plans each `YOU` object's move in full
|
||||
//! (`Planner`), then commits it, then reparses. What a property does when something
|
||||
//! moves is a `Response` that content provides; the engine runs them in order.
|
||||
//!
|
||||
//! Module 9: the turn. `Level::step` runs the phases of `TURN` in order: movement,
|
||||
//! parse, conversion, parse, hazards, win, parse. What a hazard property destroys
|
||||
//! is a `Hazard` that content provides; the engine knows `YOU`, `WIN` and `FLOAT`.
|
||||
|
||||
mod grid;
|
||||
mod level;
|
||||
mod movement;
|
||||
mod parse;
|
||||
mod resolve;
|
||||
mod turn;
|
||||
mod word;
|
||||
|
||||
pub use grid::{Board, Kind, LevelDef, LevelObject, Object, ObjectId};
|
||||
pub use level::{Level, FLOAT, WIN, YOU};
|
||||
pub use movement::{Dir, Plan, Planner, Response, Step};
|
||||
pub use parse::{parse, Rule, BASE_RULES};
|
||||
pub use resolve::resolve;
|
||||
pub use turn::{Hazard, Phase, TURN};
|
||||
pub use word::{lex, Lexicon, Role, Selects, Token, WordDef};
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Movement: validate, then commit.
|
||||
//!
|
||||
//! A move is planned before anything on the board changes. The `Planner` asks
|
||||
//! whether one object can step one tile; to answer, it runs every movement
|
||||
//! response content provides for that step, and a response may ask the planner
|
||||
//! to move other objects too (a push), or to destroy objects. If any response
|
||||
//! says no, the planner forgets everything that step added to the plan, so a
|
||||
//! failed push leaves no trace. Only a plan that succeeded is committed.
|
||||
//!
|
||||
//! The engine knows the board, its edge, and the order of the steps. What each
|
||||
//! property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
|
||||
//! response in content, run in the order content lists them.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::grid::ObjectId;
|
||||
use super::level::Level;
|
||||
|
||||
/// One of the four directions a move can take.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dir {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
/// One tile in this direction, as `(dx, dy)`; `y` grows down.
|
||||
pub fn delta(self) -> (i32, i32) {
|
||||
match self {
|
||||
Dir::Up => (0, -1),
|
||||
Dir::Down => (0, 1),
|
||||
Dir::Left => (-1, 0),
|
||||
Dir::Right => (1, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One object stepping one tile, from `from` to `to`, in direction `dir`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Step {
|
||||
pub mover: ObjectId,
|
||||
pub from: (i32, i32),
|
||||
pub to: (i32, i32),
|
||||
pub dir: Dir,
|
||||
}
|
||||
|
||||
/// A movement response: content's rule for what happens when an object steps.
|
||||
/// It returns `false` to block the step. It may plan other moves or destroy
|
||||
/// objects through the planner.
|
||||
pub type Response = fn(&mut Planner<'_>, Step) -> bool;
|
||||
|
||||
/// What a move will do: the objects that step one tile, and the objects destroyed.
|
||||
/// A destroyed object doesn't step, even if it was pushed.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub moving: BTreeSet<ObjectId>,
|
||||
pub destroyed: BTreeSet<ObjectId>,
|
||||
}
|
||||
|
||||
/// Plans one move on a board it only reads.
|
||||
pub struct Planner<'a> {
|
||||
level: &'a Level,
|
||||
dir: Dir,
|
||||
plan: Plan,
|
||||
}
|
||||
|
||||
impl<'a> Planner<'a> {
|
||||
pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> {
|
||||
Planner {
|
||||
level,
|
||||
dir,
|
||||
plan: Plan::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does object `id` have `property`, under the rules in force before the move?
|
||||
pub fn has(&self, id: ObjectId, property: &str) -> bool {
|
||||
self.level.has_property(id, property)
|
||||
}
|
||||
|
||||
/// The objects that will still be on tile `at` when the plan so far is
|
||||
/// committed: those there now, less those planned to move away or be destroyed.
|
||||
/// Oldest first.
|
||||
pub fn staying_at(&self, (x, y): (i32, i32)) -> Vec<ObjectId> {
|
||||
self.level
|
||||
.board()
|
||||
.at(x, y)
|
||||
.map(|o| o.id)
|
||||
.filter(|id| !self.plan.moving.contains(id) && !self.plan.destroyed.contains(id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Plan object `id`'s destruction.
|
||||
pub fn destroy(&mut self, id: ObjectId) {
|
||||
self.plan.destroyed.insert(id);
|
||||
}
|
||||
|
||||
/// Plan object `id` stepping one tile in the move's direction, with whatever
|
||||
/// the responses add. Returns whether it can. If it can't, the plan is left as
|
||||
/// it was before the call. An object already planned to move, or to be
|
||||
/// destroyed, is out of the way: the answer is yes, and nothing is added.
|
||||
pub fn try_move(&mut self, id: ObjectId) -> bool {
|
||||
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
let level = self.level;
|
||||
let o = level.board().get(id);
|
||||
let (dx, dy) = self.dir.delta();
|
||||
let to = (o.x + dx, o.y + dy);
|
||||
// The edge of the level blocks every step (the course's choice; see the lesson).
|
||||
if !level.board().in_bounds(to.0, to.1) {
|
||||
return false;
|
||||
}
|
||||
let before = self.plan.clone();
|
||||
self.plan.moving.insert(id);
|
||||
let step = Step {
|
||||
mover: id,
|
||||
from: (o.x, o.y),
|
||||
to,
|
||||
dir: self.dir,
|
||||
};
|
||||
let allowed = level.responses().iter().all(|respond| respond(self, step));
|
||||
if !allowed {
|
||||
self.plan = before;
|
||||
}
|
||||
allowed
|
||||
}
|
||||
|
||||
pub fn into_plan(self) -> Plan {
|
||||
self.plan
|
||||
}
|
||||
}
|
||||
@@ -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,44 @@
|
||||
//! The turn (Module 9): one move of the player, as a fixed list of phases.
|
||||
//!
|
||||
//! The course's turn is a labelled simplification of the game's (see the lesson):
|
||||
//! movement, parse, conversion, parse, hazards, win, and a last parse so that
|
||||
//! the rules match the board when the turn ends. Each phase reads the rules from
|
||||
//! the parse before it, so a rule that movement breaks is gone before hazards run.
|
||||
//!
|
||||
//! What a hazard property does (`SINK`, `HOT`/`MELT`, `DEFEAT`, ...) is a `Hazard`
|
||||
//! that content provides, as with movement's responses. The engine runs them in
|
||||
//! content's order and owns only the phases, `YOU`, `WIN` and float levels.
|
||||
|
||||
use super::grid::ObjectId;
|
||||
use super::level::Level;
|
||||
|
||||
/// One phase of the turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Every `YOU` object moves (Module 8), without the reparse.
|
||||
Movement,
|
||||
/// The rules are recompiled from the board.
|
||||
Parse,
|
||||
/// `X IS Y` conversions, and `X IS NOT X` deletions.
|
||||
Conversion,
|
||||
/// Content's hazards, in content's order.
|
||||
Hazards,
|
||||
/// A `YOU` object on a `WIN` one (or `WIN` itself) wins the level.
|
||||
Win,
|
||||
}
|
||||
|
||||
/// The turn, in order.
|
||||
pub const TURN: [Phase; 7] = [
|
||||
Phase::Movement,
|
||||
Phase::Parse,
|
||||
Phase::Conversion,
|
||||
Phase::Parse,
|
||||
Phase::Hazards,
|
||||
Phase::Win,
|
||||
Phase::Parse,
|
||||
];
|
||||
|
||||
/// A hazard: content's rule for what destroys what at the end of a turn. It reads
|
||||
/// the level and returns the objects to destroy; the engine destroys them all at
|
||||
/// once, before the next hazard runs. An id may appear more than once.
|
||||
pub type Hazard = fn(&Level) -> Vec<ObjectId>;
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a conversion into the noun `word` makes (Module 9): a thing of that
|
||||
/// name, if `word` names the things of its own name. `None` for a noun that
|
||||
/// names a group (`TEXT`, or `ALL` if content adds it): the course doesn't
|
||||
/// convert into those.
|
||||
pub fn thing_named(&self, word: &str) -> Option<Kind> {
|
||||
let selects = self.selects.get(word).copied().unwrap_or_default();
|
||||
(selects == Selects::Named).then(|| Kind::Thing {
|
||||
noun: word.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,288 @@
|
||||
//! 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::{Dir, 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(),
|
||||
content::movement(),
|
||||
content::hazards(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- Module 8 ----
|
||||
|
||||
/// Move every `YOU` object one tile in `dir`, pushing what it pushes, then
|
||||
/// reparse the rules. Movement only: no conversion, hazard or win (Module 9).
|
||||
pub fn move_dir(&mut self, dir: Dir) {
|
||||
self.level.move_you(dir);
|
||||
}
|
||||
|
||||
/// Where object `id` is, or `None` once it has been destroyed.
|
||||
pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> {
|
||||
self.level.board().find(id).map(|o| (o.x, o.y))
|
||||
}
|
||||
|
||||
/// The kinds of the objects on tile (x, y), as placed (`"baba"`, `"text:ROCK"`), sorted.
|
||||
pub fn kinds_at(&self, x: i32, y: i32) -> Vec<String> {
|
||||
let mut kinds: Vec<String> = self
|
||||
.level
|
||||
.board()
|
||||
.at(x, y)
|
||||
.map(|o| o.kind.to_string())
|
||||
.collect();
|
||||
kinds.sort();
|
||||
kinds
|
||||
}
|
||||
|
||||
// ---- Module 9 ----
|
||||
|
||||
/// Play one whole turn: movement, parse, conversion, parse, hazards, win
|
||||
/// (and a last parse, so the rules match the board).
|
||||
pub fn step(&mut self, dir: Dir) {
|
||||
self.level.step(dir);
|
||||
}
|
||||
|
||||
/// Has a turn found a `YOU` object on a `WIN` one (or `WIN` itself)? Stays true.
|
||||
pub fn won(&self) -> bool {
|
||||
self.level.won()
|
||||
}
|
||||
|
||||
/// Is no object `YOU`?
|
||||
pub fn lost(&self) -> bool {
|
||||
self.level.lost()
|
||||
}
|
||||
}
|
||||
|
||||
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": "09" }
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,19 @@
|
||||
# Module 9 reference engine (TypeScript)
|
||||
|
||||
The course's own engine as of the end of Module 9. Part I's card engine is unchanged, and all of its tests still pass. The Part II engine in `src/engine/puzzle/` now plays a whole turn: movement, parse, conversion, parse, hazards, win, parse. Module 8'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`, the property `PUSH` of the base rule `TEXT IS PUSH`, the property `YOU`, which picks the objects that move, and from Module 9 the properties `WIN` (the win check) and `FLOAT` (which objects share a tile).
|
||||
- `turn.ts` (new): the turn. `Phase`, `TURN` (the phases in order) and `Hazard` (content's rule for what destroys what).
|
||||
- `level.ts`: a level in play. Module 9 adds `Level.step`, which runs the phases of `TURN`; the conversion phase (`X IS Y` in place, `X IS X` locks, `X IS NOT X` deletes); the hazards phase, which runs content's hazards in order; the win check; `Level.sharing` (the other objects on a tile at the same float level); `won` and `lost`. The `Level` constructor takes the hazards. `moveYou` is now movement, then a reparse.
|
||||
- `grid.ts`: Module 9 adds `Board.setKind`.
|
||||
- `word.ts`: Module 9 adds `Lexicon.thingNamed` (what a conversion into a noun makes).
|
||||
- `movement.ts`, `parse.ts`, `resolve.ts`: unchanged.
|
||||
- `index.ts`: the module's exports.
|
||||
- The rest of `src/engine/`: Part I, unchanged.
|
||||
- `src/content/hazards.ts` (new) and `src/content/index.ts`: the hazards, in the order the engine runs them: `SINK`, `HOT`/`MELT`, `DEFEAT`. What destroys what at the end of a turn is content, not engine.
|
||||
- `src/content/movement.ts`: unchanged.
|
||||
- `src/content/words.json`: Module 9 adds `HOT`, `MELT`, `SINK`, `DEFEAT` and `FLOAT`.
|
||||
- `src/harness.ts`: the adapter the course tests call. Module 9 adds `step`, `won` and `lost` on `Baba`.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 09` from the course repository.
|
||||
+1496
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,48 @@
|
||||
// Part II: what destroys what at the end of a turn. The engine runs these
|
||||
// hazards in this order (the order of the wiki's `block()`: SINK, then HOT/MELT,
|
||||
// then DEFEAT), and destroys each one's victims together before the next runs.
|
||||
// A hazard only reaches objects on the same float level: it asks the level what
|
||||
// shares a tile (`Level.sharing`). A new hazard property is one more function here.
|
||||
|
||||
import type { puzzle } from "../engine";
|
||||
|
||||
type Level = puzzle.Level;
|
||||
type ObjectId = puzzle.ObjectId;
|
||||
|
||||
/** The objects that have `property`, oldest first. */
|
||||
const withProperty = (level: Level, property: string): ObjectId[] =>
|
||||
level.board
|
||||
.objects()
|
||||
.map((o) => o.id)
|
||||
.filter((id) => level.hasProperty(id, property));
|
||||
|
||||
/** A SINK object that shares its tile with anything destroys all of it, and itself. */
|
||||
function sink(level: Level): ObjectId[] {
|
||||
const doomed: ObjectId[] = [];
|
||||
for (const id of withProperty(level, "SINK")) {
|
||||
const others = level.sharing(id);
|
||||
if (others.length > 0) doomed.push(id, ...others);
|
||||
}
|
||||
return doomed;
|
||||
}
|
||||
|
||||
/** A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one. */
|
||||
function hotMelt(level: Level): ObjectId[] {
|
||||
return destroyedBy(level, "MELT", "HOT");
|
||||
}
|
||||
|
||||
/** A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one. */
|
||||
function defeat(level: Level): ObjectId[] {
|
||||
return destroyedBy(level, "YOU", "DEFEAT");
|
||||
}
|
||||
|
||||
/** The objects with `victim` that have `killer` too, or share a tile with one that does. */
|
||||
function destroyedBy(level: Level, victim: string, killer: string): ObjectId[] {
|
||||
const deadly = (id: ObjectId) => level.hasProperty(id, killer);
|
||||
return withProperty(level, victim).filter((id) => deadly(id) || level.sharing(id).some(deadly));
|
||||
}
|
||||
|
||||
/** The hazards, in the order the engine runs them. */
|
||||
export function hazards(): puzzle.Hazard[] {
|
||||
return [sink, hotMelt, defeat];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// <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";
|
||||
|
||||
export { hazards } from "./hazards";
|
||||
export { responses as movement } from "./movement";
|
||||
|
||||
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,45 @@
|
||||
// Part II: what each property does when an object moves. The engine plans a move
|
||||
// one step at a time and runs these responses for every step, in this order (the
|
||||
// order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new
|
||||
// movement property is one more response here.
|
||||
|
||||
import type { puzzle } from "../engine";
|
||||
|
||||
type Planner = puzzle.Planner;
|
||||
type Step = puzzle.Step;
|
||||
|
||||
/**
|
||||
* An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one)
|
||||
* destroys both, and that object doesn't block the step. One partner per step:
|
||||
* the oldest.
|
||||
*/
|
||||
function openShut(p: Planner, s: Step): boolean {
|
||||
for (const [mine, theirs] of [
|
||||
["OPEN", "SHUT"],
|
||||
["SHUT", "OPEN"],
|
||||
]) {
|
||||
if (!p.has(s.mover, mine)) continue;
|
||||
const partner = p.stayingAt(s.to).find((o) => p.has(o, theirs));
|
||||
if (partner !== undefined) {
|
||||
p.destroy(partner);
|
||||
p.destroy(s.mover);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Every PUSH object on the tile ahead must step the same way, or the step fails. */
|
||||
function push(p: Planner, s: Step): boolean {
|
||||
return p.stayingAt(s.to).every((o) => !p.has(o, "PUSH") || p.tryMove(o));
|
||||
}
|
||||
|
||||
/** A STOP object still on the tile ahead blocks the step. */
|
||||
function stop(p: Planner, s: Step): boolean {
|
||||
return !p.stayingAt(s.to).some((o) => p.has(o, "STOP"));
|
||||
}
|
||||
|
||||
/** The movement responses, in the order the engine runs them. */
|
||||
export function responses(): puzzle.Response[] {
|
||||
return [openShut, push, stop];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "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" },
|
||||
{ "name": "OPEN", "role": "property" },
|
||||
{ "name": "SHUT", "role": "property" },
|
||||
{ "name": "HOT", "role": "property" },
|
||||
{ "name": "MELT", "role": "property" },
|
||||
{ "name": "SINK", "role": "property" },
|
||||
{ "name": "DEFEAT", "role": "property" },
|
||||
{ "name": "FLOAT", "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,45 @@
|
||||
// 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.
|
||||
// Module 8: movement, planned in full and then committed; push chains.
|
||||
|
||||
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,108 @@
|
||||
// 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() };
|
||||
},
|
||||
/** The kind as it is placed: `"<noun>"` in lower case, or `"text:<WORD>"`. */
|
||||
format(kind: Kind): string {
|
||||
return kind.type === "text" ? TEXT_PREFIX + kind.word : kind.noun.toLowerCase();
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** The object `id`, or `undefined` once it has been removed. Throws on an id this board never issued. */
|
||||
find(id: ObjectId): Obj | undefined {
|
||||
if (!(id >= 1 && id <= this.nextId)) throw new Error(`no object ${id}`);
|
||||
return this.objectsById.get(id);
|
||||
}
|
||||
|
||||
/** Take object `id` off the board for good. */
|
||||
remove(id: ObjectId): void {
|
||||
this.objectsById.delete(id);
|
||||
}
|
||||
|
||||
/** Move object `id` by `[dx, dy]`. Throws if it would leave the board. */
|
||||
shift(id: ObjectId, [dx, dy]: readonly [number, number]): void {
|
||||
const o = this.get(id);
|
||||
const [x, y] = [o.x + dx, o.y + dy];
|
||||
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
|
||||
o.x = x;
|
||||
o.y = y;
|
||||
}
|
||||
|
||||
/** Make object `id` a `kind`, where it stands. Throws if there is no such object. */
|
||||
setKind(id: ObjectId, kind: Kind): void {
|
||||
this.get(id).kind = kind;
|
||||
}
|
||||
|
||||
/** The objects on tile (x, y), oldest first. */
|
||||
at(x: number, y: number): Obj[] {
|
||||
return this.objects().filter((o) => o.x === x && o.y === y);
|
||||
}
|
||||
|
||||
/** Every object, oldest first. */
|
||||
objects(): Obj[] {
|
||||
return [...this.objectsById.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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.
|
||||
//
|
||||
// Module 8: movement. `Level.moveYou` plans each `YOU` object's move in full
|
||||
// (`Planner`), then commits it, then reparses. What a property does when something
|
||||
// moves is a `Response` that content provides; the engine runs them in order.
|
||||
//
|
||||
// Module 9: the turn. `Level.step` runs the phases of `TURN` in order: movement,
|
||||
// parse, conversion, parse, hazards, win, parse. What a hazard property destroys
|
||||
// is a `Hazard` that content provides; the engine knows `YOU`, `WIN` and `FLOAT`.
|
||||
|
||||
export { Board, Kind, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid";
|
||||
export { FLOAT, Level, WIN, YOU } from "./level";
|
||||
export { delta, Planner, type Dir, type MoveContext, type Plan, type Pos, type Response, type Step } from "./movement";
|
||||
export { BASE_RULES, isConversion, isIdentity, negates, parse, ruleText, type Rule } from "./parse";
|
||||
export { resolve } from "./resolve";
|
||||
export { TURN, type Hazard, type Phase } from "./turn";
|
||||
export { lex, Lexicon, type Role, type Selects, type Token, type WordDef } from "./word";
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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`.
|
||||
//
|
||||
// A move (Module 8) is planned for each `YOU` object, then committed; the rules are
|
||||
// reparsed once, after all of them.
|
||||
//
|
||||
// A turn (Module 9) runs the phases of `TURN` in order: movement, parse,
|
||||
// conversion, parse, content's hazards, the win check, parse.
|
||||
|
||||
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
|
||||
import { delta, Planner, type Dir, type Plan, type Response } from "./movement";
|
||||
import { isConversion, isIdentity, parse, type Rule } from "./parse";
|
||||
import { resolve } from "./resolve";
|
||||
import { TURN, type Hazard } from "./turn";
|
||||
import { Lexicon, type WordDef } from "./word";
|
||||
|
||||
/** The property that makes an object move when the player moves. */
|
||||
export const YOU = "YOU";
|
||||
/** The property that wins the level when a `YOU` object is on it, or is it. */
|
||||
export const WIN = "WIN";
|
||||
/**
|
||||
* The property that puts an object on the other float level: only objects on
|
||||
* the same level share a tile for hazards and for winning.
|
||||
*/
|
||||
export const FLOAT = "FLOAT";
|
||||
|
||||
export class Level {
|
||||
readonly board: Board;
|
||||
private readonly lexicon: Lexicon;
|
||||
private compiled: Rule[] = [];
|
||||
private active: Rule[] = [];
|
||||
private hasWon = false;
|
||||
|
||||
/**
|
||||
* A level from its definition, the words content provides, and content's
|
||||
* movement responses and hazards, each in the order they are to run.
|
||||
*/
|
||||
constructor(
|
||||
def: LevelDef,
|
||||
words: readonly WordDef[],
|
||||
private readonly moveResponses: readonly Response[],
|
||||
private readonly hazards: readonly Hazard[],
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
/** The movement responses, in the order they run. */
|
||||
responses(): readonly Response[] {
|
||||
return this.moveResponses;
|
||||
}
|
||||
|
||||
/** 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. A
|
||||
* destroyed object has no properties.
|
||||
*/
|
||||
hasProperty(id: ObjectId, property: string): boolean {
|
||||
const object = this.board.find(id);
|
||||
if (object === undefined) return false;
|
||||
const kind = object.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The other objects on object `id`'s tile that are on its float level (both
|
||||
* `FLOAT` or both not), oldest first. Hazards and the win check ask this.
|
||||
*/
|
||||
sharing(id: ObjectId): ObjectId[] {
|
||||
const o = this.board.get(id);
|
||||
const floats = this.hasProperty(id, FLOAT);
|
||||
return this.board
|
||||
.at(o.x, o.y)
|
||||
.filter((other) => other.id !== id && this.hasProperty(other.id, FLOAT) === floats)
|
||||
.map((other) => other.id);
|
||||
}
|
||||
|
||||
/** Has a turn's win check found a `YOU` object on a `WIN` one? Stays true. */
|
||||
won(): boolean {
|
||||
return this.hasWon;
|
||||
}
|
||||
|
||||
/** Is there no `YOU` object left? */
|
||||
lost(): boolean {
|
||||
return !this.board.objects().some((o) => this.hasProperty(o.id, YOU));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement
|
||||
* only: no conversion, hazard or win.
|
||||
*/
|
||||
moveYou(dir: Dir): void {
|
||||
this.movement(dir);
|
||||
this.reparse();
|
||||
}
|
||||
|
||||
/** Play one turn: the phases of `TURN`, in order. */
|
||||
step(dir: Dir): void {
|
||||
for (const phase of TURN) {
|
||||
switch (phase) {
|
||||
case "movement":
|
||||
this.movement(dir);
|
||||
break;
|
||||
case "parse":
|
||||
this.reparse();
|
||||
break;
|
||||
case "conversion":
|
||||
this.convert();
|
||||
break;
|
||||
case "hazards":
|
||||
this.runHazards();
|
||||
break;
|
||||
case "win":
|
||||
this.checkWin();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `YOU` objects move one at a time, the one furthest ahead in `dir` first
|
||||
* (oldest first on a tie); each one's move is planned in full, then committed,
|
||||
* before the next is planned. Every move uses the rules in force before the first.
|
||||
*/
|
||||
private movement(dir: Dir): void {
|
||||
const [dx, dy] = delta(dir);
|
||||
const ahead = (o: { x: number; y: number }) => o.x * dx + o.y * dy;
|
||||
const you = this.board
|
||||
.objects()
|
||||
.filter((o) => this.hasProperty(o.id, YOU))
|
||||
.sort((a, b) => ahead(b) - ahead(a) || a.id - b.id)
|
||||
.map((o) => o.id);
|
||||
for (const id of you) {
|
||||
if (this.board.find(id) === undefined) continue; // destroyed by an earlier move
|
||||
const planner = new Planner(this, dir);
|
||||
if (planner.tryMove(id)) this.commit(planner.result(), dir);
|
||||
}
|
||||
}
|
||||
|
||||
private commit(plan: Plan, dir: Dir): void {
|
||||
for (const id of plan.destroyed) this.board.remove(id);
|
||||
for (const id of plan.moving) if (!plan.destroyed.has(id)) this.board.shift(id, delta(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y` where
|
||||
* it stands, keeping its id; with several `Y`s it becomes the first, and each
|
||||
* other `Y` is a new object on its tile. An `X IS X` naming the object blocks
|
||||
* this. An object named by `X IS NOT X` is deleted, and still makes its `Y`s,
|
||||
* as new objects: the negation cancelled `X IS X`.
|
||||
*
|
||||
* Every object is decided from the board as movement left it, before any
|
||||
* changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys.
|
||||
*/
|
||||
private convert(): void {
|
||||
const changes: { id: ObjectId; x: number; y: number; deleted: boolean; into: Kind[] }[] = [];
|
||||
for (const o of this.board.objects()) {
|
||||
const aboutIt = this.active.filter((r) => this.lexicon.names(r.subject, o.kind));
|
||||
const deleted = aboutIt.some((r) => r.negated && isIdentity(r));
|
||||
const locked = aboutIt.some((r) => !r.negated && isIdentity(r));
|
||||
const into: Kind[] = [];
|
||||
if (!locked) {
|
||||
for (const r of aboutIt) {
|
||||
if (r.negated || !isConversion(r)) continue;
|
||||
const kind = this.lexicon.thingNamed(r.complement);
|
||||
if (kind === undefined) continue;
|
||||
const same = (k: Kind) => Kind.format(k) === Kind.format(kind);
|
||||
if (!same(o.kind) && !into.some(same)) into.push(kind);
|
||||
}
|
||||
}
|
||||
if (deleted || into.length > 0) changes.push({ id: o.id, x: o.x, y: o.y, deleted, into });
|
||||
}
|
||||
for (const { id, x, y, deleted, into } of changes) {
|
||||
if (deleted) this.board.remove(id);
|
||||
else this.board.setKind(id, into[0]);
|
||||
for (const kind of deleted ? into : into.slice(1)) this.board.place(kind, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run content's hazards in order. Each one's victims are destroyed together
|
||||
* before the next runs; no reparse until the turn's last phase.
|
||||
*/
|
||||
private runHazards(): void {
|
||||
for (const hazard of this.hazards) {
|
||||
for (const id of hazard(this)) this.board.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The level is won if any `YOU` object is `WIN`, or shares its tile with a
|
||||
* `WIN` object on its float level.
|
||||
*/
|
||||
private checkWin(): void {
|
||||
const wins = (id: ObjectId) => this.hasProperty(id, WIN);
|
||||
const won = this.board
|
||||
.objects()
|
||||
.filter((o) => this.hasProperty(o.id, YOU))
|
||||
.some((o) => wins(o.id) || this.sharing(o.id).some(wins));
|
||||
this.hasWon ||= won;
|
||||
}
|
||||
|
||||
private reparse(): void {
|
||||
this.compiled = parse(this.board, this.lexicon);
|
||||
this.active = resolve(this.compiled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Movement: validate, then commit.
|
||||
//
|
||||
// A move is planned before anything on the board changes. The `Planner` asks
|
||||
// whether one object can step one tile; to answer, it runs every movement
|
||||
// response content provides for that step, and a response may ask the planner
|
||||
// to move other objects too (a push), or to destroy objects. If any response
|
||||
// says no, the planner forgets everything that step added to the plan, so a
|
||||
// failed push leaves no trace. Only a plan that succeeded is committed.
|
||||
//
|
||||
// The engine knows the board, its edge, and the order of the steps. What each
|
||||
// property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
|
||||
// response in content, run in the order content lists them.
|
||||
|
||||
import type { Board, ObjectId } from "./grid";
|
||||
|
||||
/** One of the four directions a move can take. */
|
||||
export type Dir = "up" | "down" | "left" | "right";
|
||||
|
||||
export type Pos = readonly [number, number];
|
||||
|
||||
/** One tile in direction `dir`, as `[dx, dy]`; `y` grows down. */
|
||||
export function delta(dir: Dir): Pos {
|
||||
switch (dir) {
|
||||
case "up":
|
||||
return [0, -1];
|
||||
case "down":
|
||||
return [0, 1];
|
||||
case "left":
|
||||
return [-1, 0];
|
||||
case "right":
|
||||
return [1, 0];
|
||||
}
|
||||
}
|
||||
|
||||
/** One object stepping one tile, from `from` to `to`, in direction `dir`. */
|
||||
export interface Step {
|
||||
mover: ObjectId;
|
||||
from: Pos;
|
||||
to: Pos;
|
||||
dir: Dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* A movement response: content's rule for what happens when an object steps.
|
||||
* It returns `false` to block the step. It may plan other moves or destroy
|
||||
* objects through the planner.
|
||||
*/
|
||||
export type Response = (p: Planner, s: Step) => boolean;
|
||||
|
||||
/**
|
||||
* What a move will do: the objects that step one tile, and the objects destroyed.
|
||||
* A destroyed object doesn't step, even if it was pushed.
|
||||
*/
|
||||
export interface Plan {
|
||||
moving: Set<ObjectId>;
|
||||
destroyed: Set<ObjectId>;
|
||||
}
|
||||
|
||||
/** What the planner reads: the board, the properties in force, the responses. */
|
||||
export interface MoveContext {
|
||||
readonly board: Board;
|
||||
hasProperty(id: ObjectId, property: string): boolean;
|
||||
responses(): readonly Response[];
|
||||
}
|
||||
|
||||
/** Plans one move on a board it only reads. */
|
||||
export class Planner {
|
||||
private plan: Plan = { moving: new Set(), destroyed: new Set() };
|
||||
|
||||
constructor(
|
||||
private readonly level: MoveContext,
|
||||
private readonly dir: Dir,
|
||||
) {}
|
||||
|
||||
/** Does object `id` have `property`, under the rules in force before the move? */
|
||||
has(id: ObjectId, property: string): boolean {
|
||||
return this.level.hasProperty(id, property);
|
||||
}
|
||||
|
||||
/**
|
||||
* The objects that will still be on tile `at` when the plan so far is
|
||||
* committed: those there now, less those planned to move away or be destroyed.
|
||||
* Oldest first.
|
||||
*/
|
||||
stayingAt([x, y]: Pos): ObjectId[] {
|
||||
return this.level.board
|
||||
.at(x, y)
|
||||
.map((o) => o.id)
|
||||
.filter((id) => !this.plan.moving.has(id) && !this.plan.destroyed.has(id));
|
||||
}
|
||||
|
||||
/** Plan object `id`'s destruction. */
|
||||
destroy(id: ObjectId): void {
|
||||
this.plan.destroyed.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan object `id` stepping one tile in the move's direction, with whatever
|
||||
* the responses add. Returns whether it can. If it can't, the plan is left as
|
||||
* it was before the call. An object already planned to move, or to be
|
||||
* destroyed, is out of the way: the answer is yes, and nothing is added.
|
||||
*/
|
||||
tryMove(id: ObjectId): boolean {
|
||||
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
|
||||
const o = this.level.board.get(id);
|
||||
const [dx, dy] = delta(this.dir);
|
||||
const to: Pos = [o.x + dx, o.y + dy];
|
||||
// The edge of the level blocks every step (the course's choice; see the lesson).
|
||||
if (!this.level.board.inBounds(...to)) return false;
|
||||
const before: Plan = { moving: new Set(this.plan.moving), destroyed: new Set(this.plan.destroyed) };
|
||||
this.plan.moving.add(id);
|
||||
const step: Step = { mover: id, from: [o.x, o.y], to, dir: this.dir };
|
||||
const allowed = this.level.responses().every((respond) => respond(this, step));
|
||||
if (!allowed) this.plan = before;
|
||||
return allowed;
|
||||
}
|
||||
|
||||
result(): Plan {
|
||||
return this.plan;
|
||||
}
|
||||
}
|
||||
@@ -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,33 @@
|
||||
// The turn (Module 9): one move of the player, as a fixed list of phases.
|
||||
//
|
||||
// The course's turn is a labelled simplification of the game's (see the lesson):
|
||||
// movement, parse, conversion, parse, hazards, win, and a last parse so that
|
||||
// the rules match the board when the turn ends. Each phase reads the rules from
|
||||
// the parse before it, so a rule that movement breaks is gone before hazards run.
|
||||
//
|
||||
// What a hazard property does (`SINK`, `HOT`/`MELT`, `DEFEAT`, ...) is a `Hazard`
|
||||
// that content provides, as with movement's responses. The engine runs them in
|
||||
// content's order and owns only the phases, `YOU`, `WIN` and float levels.
|
||||
|
||||
import type { ObjectId } from "./grid";
|
||||
import type { Level } from "./level";
|
||||
|
||||
/**
|
||||
* One phase of the turn:
|
||||
* - `"movement"`: every `YOU` object moves (Module 8), without the reparse;
|
||||
* - `"parse"`: the rules are recompiled from the board;
|
||||
* - `"conversion"`: `X IS Y` conversions, and `X IS NOT X` deletions;
|
||||
* - `"hazards"`: content's hazards, in content's order;
|
||||
* - `"win"`: a `YOU` object on a `WIN` one (or `WIN` itself) wins the level.
|
||||
*/
|
||||
export type Phase = "movement" | "parse" | "conversion" | "hazards" | "win";
|
||||
|
||||
/** The turn, in order. */
|
||||
export const TURN: readonly Phase[] = ["movement", "parse", "conversion", "parse", "hazards", "win", "parse"];
|
||||
|
||||
/**
|
||||
* A hazard: content's rule for what destroys what at the end of a turn. It reads
|
||||
* the level and returns the objects to destroy; the engine destroys them all at
|
||||
* once, before the next hazard runs. An id may appear more than once.
|
||||
*/
|
||||
export type Hazard = (level: Level) => ObjectId[];
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a conversion into the noun `word` makes (Module 9): a thing of that
|
||||
* name, if `word` names the things of its own name. `undefined` for a noun that
|
||||
* names a group (`TEXT`, or `ALL` if content adds it): the course doesn't
|
||||
* convert into those.
|
||||
*/
|
||||
thingNamed(word: string): Kind | undefined {
|
||||
return (this.selects.get(word) ?? "named") === "named" ? { type: "thing", noun: word } : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,165 @@
|
||||
// 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, hazards, movement, 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 { Dir, 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;
|
||||
// Module 8
|
||||
move(dir: puzzle.Dir): void; // every YOU object one tile, with what it pushes; then reparse. Movement only.
|
||||
posOf(id: Id): [number, number] | null; // null once destroyed
|
||||
kindsAt(x: number, y: number): string[]; // kinds as placed ("baba", "text:ROCK"), sorted
|
||||
// Module 9
|
||||
step(dir: puzzle.Dir): void; // one whole turn: movement, parse, conversion, parse, hazards, win (then parse)
|
||||
won(): boolean; // a turn found a YOU object on a WIN one (or WIN itself); stays true
|
||||
lost(): boolean; // no object is YOU
|
||||
}
|
||||
|
||||
/** 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(), movement(), hazards());
|
||||
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),
|
||||
move: (dir) => lvl.moveYou(dir),
|
||||
posOf: (id) => {
|
||||
const o = lvl.board.find(id);
|
||||
return o === undefined ? null : [o.x, o.y];
|
||||
},
|
||||
kindsAt: (x, y) =>
|
||||
lvl.board
|
||||
.at(x, y)
|
||||
.map((o) => puzzle.Kind.format(o.kind))
|
||||
.sort(),
|
||||
step: (dir) => lvl.step(dir),
|
||||
won: () => lvl.won(),
|
||||
lost: () => lvl.lost(),
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
# Module 9 scenarios: The turn pipeline
|
||||
|
||||
Each heading is a test name. The Rust test lives in `tests/rust/test_09.rs` and the TypeScript test in `tests/ts/test_09.test.ts`; both state exactly what is written here.
|
||||
|
||||
The level format, `place`, `rules()`, `activeRules()`, `hasProperty`, `posOf`, `kindsAt` and the way a test writes a sentence (`BABA (0,0) IS (1,0) YOU (2,0)` is three text objects) are those of Modules 6 to 8. This module adds three harness functions:
|
||||
|
||||
- `step(dir)` (Rust: `step(Dir::Right)`) plays one whole turn, in the course's simplified order: **Movement → Parse → Conversion → Parse → Hazards → Win**, and a last parse so that `rules()` matches the board. Movement is Module 8's `move` without its reparse.
|
||||
- `won()` is `true` once a turn's win check has found a `YOU` object that is `WIN`, or that shares its tile with a `WIN` object on the same float level. It stays `true`.
|
||||
- `lost()` is `true` when no object is `YOU`.
|
||||
|
||||
What the turn does, in this module:
|
||||
|
||||
- **Conversion.** Every object named by a rule `X IS Y`, where `Y` is a noun, becomes a `Y` where it stands: same id, same tile. `X IS X` blocks it. An object named by `X IS NOT X` is deleted. All objects are converted at once, from the board as it was after movement.
|
||||
- **Hazards**, each against the objects that share a tile on the same float level (both `FLOAT` or both not), and each destroying everything it finds at once before the next runs: `SINK` destroys every object it shares a tile with, and itself; `HOT` destroys `MELT` objects (an object that is both destroys itself); `DEFEAT` destroys `YOU` objects (an object that is both destroys itself).
|
||||
- **Win** runs after every hazard, so a `YOU` object destroyed by `DEFEAT` can't win.
|
||||
- Every phase uses the rules from the parse before it.
|
||||
|
||||
Words used: the nouns `BABA`, `ROCK`, `FLAG`, `LAVA`, `KEY`, `SKULL`, `WATER`; the properties `YOU`, `WIN`, `PUSH`, `HOT`, `MELT`, `SINK`, `DEFEAT`, `FLOAT`; the operators `IS`, `AND`, `NOT`; the base rule `TEXT IS PUSH`.
|
||||
|
||||
### test_09_lava_saved_by_reparse
|
||||
|
||||
- Setup: `newBaba` with an empty level 6 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `LAVA (0,1) IS (1,1) HOT (2,1)`. Place `BABA (4,2) IS (4,3) MELT (4,4)`, reading down column 4. Place an object `lava` at (4,4), under the word `MELT`, and an object `baba` at (3,4).
|
||||
- Assertions 1: `hasProperty(baba, "MELT")` is true.
|
||||
- Action: `step("right")`.
|
||||
- Assertions 2:
|
||||
- `posOf(baba)` is (4,4), on the lava, and `posOf(meltText)` is (5,4): Baba pushed `MELT` out of its sentence as it stepped.
|
||||
- `posOf(lava)` is (4,4), and `kindsAt(4,4)` is `["baba", "lava"]`: Baba survived. The rules were reparsed after movement, so when the hazards ran, `BABA IS MELT` no longer existed.
|
||||
- `rules()` doesn't contain `"BABA IS MELT"`, and `lost()` is false.
|
||||
- That Baba survives is inferred from the wiki's order of operations, not stated by a source (see the lesson).
|
||||
|
||||
### test_09_mid_turn_conversion
|
||||
|
||||
- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `BABA (0,2) IS (1,2)`, with (2,2) empty. Place a text `text:ROCK` at (2,3). Place an object `baba` at (2,4) (the pusher) and an object `baba` at (4,4) (the other Baba).
|
||||
- Action: `step("up")`.
|
||||
- Assertions:
|
||||
- `posOf(rockText)` is (2,2): the pusher pushed it up, and `BABA IS ROCK` now reads along row 2. `rules()` contains `"BABA IS ROCK"`.
|
||||
- `posOf(pusher)` is (2,3) and `posOf(other)` is (4,3): both moved first, then converted where they stood.
|
||||
- `kindsAt(2,3)` is `["rock"]` and `kindsAt(4,3)` is `["rock"]`: the rule the move completed converted both Babas in the same turn.
|
||||
- `kindsAt(2,4)` and `kindsAt(4,4)` are `[]`.
|
||||
|
||||
### test_09_identity_lock_blocks_conversion
|
||||
|
||||
- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `BABA (0,1) IS (1,1) BABA (2,1)`, `BABA (0,2) IS (1,2) ROCK (2,2)` and `KEY (0,3) IS (1,3) ROCK (2,3)`. Place an object `baba` at (0,4) and an object `key` at (3,4).
|
||||
- Action: `step("right")`.
|
||||
- Assertions:
|
||||
- `posOf(baba)` is (1,4) and `kindsAt(1,4)` is `["baba"]`: `BABA IS BABA` blocked `BABA IS ROCK`.
|
||||
- `kindsAt(3,4)` is `["rock"]` and `posOf(key)` is (3,4): `KEY IS ROCK` converted the key, in place, with the same id.
|
||||
- `activeRules()` doesn't contain `"BABA IS ROCK"`; it contains `"BABA IS BABA"` and `"KEY IS ROCK"`.
|
||||
|
||||
### test_09_self_negation_deletes
|
||||
|
||||
- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `KEY (0,1) IS (1,1) NOT (2,1) KEY (3,1)` and `KEY (0,2) IS (1,2) ROCK (2,2)`. Place an object `baba` at (0,4) and an object `key` at (3,4).
|
||||
- Assertions 1: `posOf(key)` is (3,4): placing text doesn't delete anything; the turn does.
|
||||
- Action: `step("right")`.
|
||||
- Assertions 2:
|
||||
- `posOf(key)` is `null`: `KEY IS NOT KEY` deleted the key in the conversion step.
|
||||
- `kindsAt(3,4)` is `["rock"]`: `KEY IS NOT KEY` doesn't lock the key, so `KEY IS ROCK` still applies, and a rock appears where the key was, as a new object.
|
||||
- `posOf(baba)` is (1,4).
|
||||
|
||||
### test_09_float_separates_hazards
|
||||
|
||||
- Setup: `newBaba` with an empty level 4 wide and 6 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `KEY (0,1) IS (1,1) YOU (2,1)`, `SKULL (0,2) IS (1,2) DEFEAT (2,2)` and `BABA (0,3) IS (1,3) FLOAT (2,3)`. Place an object `baba` at (0,4) and an object `skull` at (1,4); an object `key` at (0,5) and an object `skull` at (1,5).
|
||||
- Action: `step("right")`.
|
||||
- Assertions:
|
||||
- `posOf(baba)` is (1,4) and `kindsAt(1,4)` is `["baba", "skull"]`: Baba is `FLOAT` and the skull isn't, so they are on different float levels and `DEFEAT` doesn't reach Baba.
|
||||
- `posOf(key)` is `null` and `kindsAt(1,5)` is `["skull"]`: the key is on the skull's float level and is destroyed.
|
||||
- `lost()` is false: Baba is still `YOU`.
|
||||
|
||||
### test_09_sink_destroys_both
|
||||
|
||||
- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `ROCK (0,1) IS (1,1) PUSH (2,1)` and `WATER (0,2) IS (1,2) SINK (2,2)`. Place an object `baba` at (0,4), an object `rock` at (1,4) and an object `water` at (2,4).
|
||||
- Action: `step("right")`.
|
||||
- Assertions:
|
||||
- `posOf(rock)` and `posOf(water)` are both `null`: the rock was pushed onto the water, and `SINK` destroyed both.
|
||||
- `kindsAt(2,4)` is `[]`.
|
||||
- `posOf(baba)` is (1,4): Baba didn't share the water's tile and survives.
|
||||
|
||||
### test_09_you_and_win
|
||||
|
||||
- Setup: `newBaba` with an empty level 4 wide and 4 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `BABA (0,1) IS (1,1) WIN (2,1)`. Place an object `baba` at (0,3).
|
||||
- Assertions 1: `won()` is false: the win is checked by the turn, not when text is placed.
|
||||
- Action: `step("right")`.
|
||||
- Assertions 2: `won()` is true: an object that is both `YOU` and `WIN` wins. `lost()` is false.
|
||||
|
||||
### test_09_defeat_beats_win
|
||||
|
||||
- Setup: `newBaba` with an empty level 5 wide and 4 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `FLAG (0,1) IS (1,1) WIN (2,1) AND (3,1) DEFEAT (4,1)`. Place an object `baba` at (0,3) and an object `flag` at (1,3).
|
||||
- Action: `step("right")`.
|
||||
- Assertions:
|
||||
- `posOf(baba)` is `null`: Baba stepped onto the flag, which is `DEFEAT`.
|
||||
- `won()` is false: `DEFEAT` runs before the win check, so no `YOU` object is left to win.
|
||||
- `lost()` is true, and `posOf(flag)` is (1,3).
|
||||
@@ -0,0 +1,184 @@
|
||||
// Module 9: The turn pipeline. 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_09_lava_saved_by_reparse() {
|
||||
let mut b = new_baba(empty_level(6, 5));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("LAVA", 0, 1), ("IS", 1, 1), ("HOT", 2, 1)]);
|
||||
write(&mut b, &[("BABA", 4, 2), ("IS", 4, 3)]);
|
||||
let melt_text = b.place("text:MELT", 4, 4);
|
||||
let lava = b.place("lava", 4, 4);
|
||||
let baba = b.place("baba", 3, 4);
|
||||
|
||||
assert!(b.has_property(baba, "MELT"));
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(baba), Some((4, 4)));
|
||||
assert_eq!(b.pos_of(melt_text), Some((5, 4)));
|
||||
// Inferred from the order of operations, not stated by a source: Baba survives.
|
||||
assert_eq!(b.pos_of(lava), Some((4, 4)));
|
||||
assert_eq!(b.kinds_at(4, 4), vec!["baba", "lava"]);
|
||||
assert!(!b.rules().contains(&"BABA IS MELT".to_string()));
|
||||
assert!(!b.lost());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_mid_turn_conversion() {
|
||||
let mut b = new_baba(empty_level(5, 5));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("BABA", 0, 2), ("IS", 1, 2)]);
|
||||
let rock_text = b.place("text:ROCK", 2, 3);
|
||||
let pusher = b.place("baba", 2, 4);
|
||||
let other = b.place("baba", 4, 4);
|
||||
|
||||
b.step(Dir::Up);
|
||||
|
||||
assert_eq!(b.pos_of(rock_text), Some((2, 2)));
|
||||
assert!(b.rules().contains(&"BABA IS ROCK".to_string()));
|
||||
assert_eq!(b.pos_of(pusher), Some((2, 3)));
|
||||
assert_eq!(b.pos_of(other), Some((4, 3)));
|
||||
assert_eq!(b.kinds_at(2, 3), vec!["rock"]);
|
||||
assert_eq!(b.kinds_at(4, 3), vec!["rock"]);
|
||||
assert_eq!(b.kinds_at(2, 4), Vec::<String>::new());
|
||||
assert_eq!(b.kinds_at(4, 4), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_identity_lock_blocks_conversion() {
|
||||
let mut b = new_baba(empty_level(5, 5));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("BABA", 0, 1), ("IS", 1, 1), ("BABA", 2, 1)]);
|
||||
write(&mut b, &[("BABA", 0, 2), ("IS", 1, 2), ("ROCK", 2, 2)]);
|
||||
write(&mut b, &[("KEY", 0, 3), ("IS", 1, 3), ("ROCK", 2, 3)]);
|
||||
let baba = b.place("baba", 0, 4);
|
||||
let key = b.place("key", 3, 4);
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(baba), Some((1, 4)));
|
||||
assert_eq!(b.kinds_at(1, 4), vec!["baba"]);
|
||||
assert_eq!(b.kinds_at(3, 4), vec!["rock"]);
|
||||
assert_eq!(b.pos_of(key), Some((3, 4)));
|
||||
let active = b.active_rules();
|
||||
assert!(!active.contains(&"BABA IS ROCK".to_string()));
|
||||
assert!(active.contains(&"BABA IS BABA".to_string()));
|
||||
assert!(active.contains(&"KEY IS ROCK".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_self_negation_deletes() {
|
||||
let mut b = new_baba(empty_level(5, 5));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(
|
||||
&mut b,
|
||||
&[("KEY", 0, 1), ("IS", 1, 1), ("NOT", 2, 1), ("KEY", 3, 1)],
|
||||
);
|
||||
write(&mut b, &[("KEY", 0, 2), ("IS", 1, 2), ("ROCK", 2, 2)]);
|
||||
let baba = b.place("baba", 0, 4);
|
||||
let key = b.place("key", 3, 4);
|
||||
|
||||
assert_eq!(b.pos_of(key), Some((3, 4)));
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(key), None);
|
||||
assert_eq!(b.kinds_at(3, 4), vec!["rock"]);
|
||||
assert_eq!(b.pos_of(baba), Some((1, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_float_separates_hazards() {
|
||||
let mut b = new_baba(empty_level(4, 6));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("KEY", 0, 1), ("IS", 1, 1), ("YOU", 2, 1)]);
|
||||
write(&mut b, &[("SKULL", 0, 2), ("IS", 1, 2), ("DEFEAT", 2, 2)]);
|
||||
write(&mut b, &[("BABA", 0, 3), ("IS", 1, 3), ("FLOAT", 2, 3)]);
|
||||
let baba = b.place("baba", 0, 4);
|
||||
b.place("skull", 1, 4);
|
||||
let key = b.place("key", 0, 5);
|
||||
b.place("skull", 1, 5);
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(baba), Some((1, 4)));
|
||||
assert_eq!(b.kinds_at(1, 4), vec!["baba", "skull"]);
|
||||
assert_eq!(b.pos_of(key), None);
|
||||
assert_eq!(b.kinds_at(1, 5), vec!["skull"]);
|
||||
assert!(!b.lost());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_sink_destroys_both() {
|
||||
let mut b = new_baba(empty_level(5, 5));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("ROCK", 0, 1), ("IS", 1, 1), ("PUSH", 2, 1)]);
|
||||
write(&mut b, &[("WATER", 0, 2), ("IS", 1, 2), ("SINK", 2, 2)]);
|
||||
let baba = b.place("baba", 0, 4);
|
||||
let rock = b.place("rock", 1, 4);
|
||||
let water = b.place("water", 2, 4);
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(rock), None);
|
||||
assert_eq!(b.pos_of(water), None);
|
||||
assert_eq!(b.kinds_at(2, 4), Vec::<String>::new());
|
||||
assert_eq!(b.pos_of(baba), Some((1, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_you_and_win() {
|
||||
let mut b = new_baba(empty_level(4, 4));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(&mut b, &[("BABA", 0, 1), ("IS", 1, 1), ("WIN", 2, 1)]);
|
||||
b.place("baba", 0, 3);
|
||||
|
||||
assert!(!b.won());
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert!(b.won());
|
||||
assert!(!b.lost());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_09_defeat_beats_win() {
|
||||
let mut b = new_baba(empty_level(5, 4));
|
||||
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
|
||||
write(
|
||||
&mut b,
|
||||
&[
|
||||
("FLAG", 0, 1),
|
||||
("IS", 1, 1),
|
||||
("WIN", 2, 1),
|
||||
("AND", 3, 1),
|
||||
("DEFEAT", 4, 1),
|
||||
],
|
||||
);
|
||||
let baba = b.place("baba", 0, 3);
|
||||
let flag = b.place("flag", 1, 3);
|
||||
|
||||
b.step(Dir::Right);
|
||||
|
||||
assert_eq!(b.pos_of(baba), None);
|
||||
assert!(!b.won());
|
||||
assert!(b.lost());
|
||||
assert_eq!(b.pos_of(flag), Some((1, 3)));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Module 9: The turn pipeline. 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_09_lava_saved_by_reparse", () => {
|
||||
const b = newBaba(emptyLevel(6, 5));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["LAVA", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["HOT", 2, 1],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 4, 2],
|
||||
["IS", 4, 3],
|
||||
]);
|
||||
const meltText = b.place("text:MELT", 4, 4);
|
||||
const lava = b.place("lava", 4, 4);
|
||||
const baba = b.place("baba", 3, 4);
|
||||
|
||||
expect(b.hasProperty(baba, "MELT")).toBe(true);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(baba)).toEqual([4, 4]);
|
||||
expect(b.posOf(meltText)).toEqual([5, 4]);
|
||||
// Inferred from the order of operations, not stated by a source: Baba survives.
|
||||
expect(b.posOf(lava)).toEqual([4, 4]);
|
||||
expect(b.kindsAt(4, 4)).toEqual(["baba", "lava"]);
|
||||
expect(b.rules()).not.toContain("BABA IS MELT");
|
||||
expect(b.lost()).toBe(false);
|
||||
});
|
||||
|
||||
test("test_09_mid_turn_conversion", () => {
|
||||
const b = newBaba(emptyLevel(5, 5));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 0, 2],
|
||||
["IS", 1, 2],
|
||||
]);
|
||||
const rockText = b.place("text:ROCK", 2, 3);
|
||||
const pusher = b.place("baba", 2, 4);
|
||||
const other = b.place("baba", 4, 4);
|
||||
|
||||
b.step("up");
|
||||
|
||||
expect(b.posOf(rockText)).toEqual([2, 2]);
|
||||
expect(b.rules()).toContain("BABA IS ROCK");
|
||||
expect(b.posOf(pusher)).toEqual([2, 3]);
|
||||
expect(b.posOf(other)).toEqual([4, 3]);
|
||||
expect(b.kindsAt(2, 3)).toEqual(["rock"]);
|
||||
expect(b.kindsAt(4, 3)).toEqual(["rock"]);
|
||||
expect(b.kindsAt(2, 4)).toEqual([]);
|
||||
expect(b.kindsAt(4, 4)).toEqual([]);
|
||||
});
|
||||
|
||||
test("test_09_identity_lock_blocks_conversion", () => {
|
||||
const b = newBaba(emptyLevel(5, 5));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["BABA", 2, 1],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 0, 2],
|
||||
["IS", 1, 2],
|
||||
["ROCK", 2, 2],
|
||||
]);
|
||||
write(b, [
|
||||
["KEY", 0, 3],
|
||||
["IS", 1, 3],
|
||||
["ROCK", 2, 3],
|
||||
]);
|
||||
const baba = b.place("baba", 0, 4);
|
||||
const key = b.place("key", 3, 4);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(baba)).toEqual([1, 4]);
|
||||
expect(b.kindsAt(1, 4)).toEqual(["baba"]);
|
||||
expect(b.kindsAt(3, 4)).toEqual(["rock"]);
|
||||
expect(b.posOf(key)).toEqual([3, 4]);
|
||||
const active = b.activeRules();
|
||||
expect(active).not.toContain("BABA IS ROCK");
|
||||
expect(active).toContain("BABA IS BABA");
|
||||
expect(active).toContain("KEY IS ROCK");
|
||||
});
|
||||
|
||||
test("test_09_self_negation_deletes", () => {
|
||||
const b = newBaba(emptyLevel(5, 5));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["KEY", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["NOT", 2, 1],
|
||||
["KEY", 3, 1],
|
||||
]);
|
||||
write(b, [
|
||||
["KEY", 0, 2],
|
||||
["IS", 1, 2],
|
||||
["ROCK", 2, 2],
|
||||
]);
|
||||
const baba = b.place("baba", 0, 4);
|
||||
const key = b.place("key", 3, 4);
|
||||
|
||||
expect(b.posOf(key)).toEqual([3, 4]);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(key)).toBeNull();
|
||||
expect(b.kindsAt(3, 4)).toEqual(["rock"]);
|
||||
expect(b.posOf(baba)).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
test("test_09_float_separates_hazards", () => {
|
||||
const b = newBaba(emptyLevel(4, 6));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["KEY", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["YOU", 2, 1],
|
||||
]);
|
||||
write(b, [
|
||||
["SKULL", 0, 2],
|
||||
["IS", 1, 2],
|
||||
["DEFEAT", 2, 2],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 0, 3],
|
||||
["IS", 1, 3],
|
||||
["FLOAT", 2, 3],
|
||||
]);
|
||||
const baba = b.place("baba", 0, 4);
|
||||
b.place("skull", 1, 4);
|
||||
const key = b.place("key", 0, 5);
|
||||
b.place("skull", 1, 5);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(baba)).toEqual([1, 4]);
|
||||
expect(b.kindsAt(1, 4)).toEqual(["baba", "skull"]);
|
||||
expect(b.posOf(key)).toBeNull();
|
||||
expect(b.kindsAt(1, 5)).toEqual(["skull"]);
|
||||
expect(b.lost()).toBe(false);
|
||||
});
|
||||
|
||||
test("test_09_sink_destroys_both", () => {
|
||||
const b = newBaba(emptyLevel(5, 5));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["ROCK", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["PUSH", 2, 1],
|
||||
]);
|
||||
write(b, [
|
||||
["WATER", 0, 2],
|
||||
["IS", 1, 2],
|
||||
["SINK", 2, 2],
|
||||
]);
|
||||
const baba = b.place("baba", 0, 4);
|
||||
const rock = b.place("rock", 1, 4);
|
||||
const water = b.place("water", 2, 4);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(rock)).toBeNull();
|
||||
expect(b.posOf(water)).toBeNull();
|
||||
expect(b.kindsAt(2, 4)).toEqual([]);
|
||||
expect(b.posOf(baba)).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
test("test_09_you_and_win", () => {
|
||||
const b = newBaba(emptyLevel(4, 4));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["BABA", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["WIN", 2, 1],
|
||||
]);
|
||||
b.place("baba", 0, 3);
|
||||
|
||||
expect(b.won()).toBe(false);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.won()).toBe(true);
|
||||
expect(b.lost()).toBe(false);
|
||||
});
|
||||
|
||||
test("test_09_defeat_beats_win", () => {
|
||||
const b = newBaba(emptyLevel(5, 4));
|
||||
write(b, [
|
||||
["BABA", 0, 0],
|
||||
["IS", 1, 0],
|
||||
["YOU", 2, 0],
|
||||
]);
|
||||
write(b, [
|
||||
["FLAG", 0, 1],
|
||||
["IS", 1, 1],
|
||||
["WIN", 2, 1],
|
||||
["AND", 3, 1],
|
||||
["DEFEAT", 4, 1],
|
||||
]);
|
||||
const baba = b.place("baba", 0, 3);
|
||||
const flag = b.place("flag", 1, 3);
|
||||
|
||||
b.step("right");
|
||||
|
||||
expect(b.posOf(baba)).toBeNull();
|
||||
expect(b.won()).toBe(false);
|
||||
expect(b.lost()).toBe(true);
|
||||
expect(b.posOf(flag)).toEqual([1, 3]);
|
||||
});
|
||||
@@ -127,6 +127,8 @@ pub enum Dir {
|
||||
}
|
||||
```
|
||||
|
||||
From M9, `step(dir)` plays one whole turn, in the course's simplified order: movement (M8's, without its reparse), parse, conversion, parse, hazards, win, and a last parse so that `rules()` matches the board. Conversion: every object named by an active `X IS Y` (`Y` a noun naming its own things) becomes a `Y` in place, keeping its id; with several `Y`s it becomes the first and each other `Y` is a new object on its tile; `X IS X` blocks it; an object named by `X IS NOT X` is deleted (its other `Y`s still appear, as new objects). All conversions are decided from the board as movement left it. Hazards run in content's order (the reference: `SINK`, `HOT`/`MELT`, `DEFEAT`); each sees only the objects that share a tile on the same float level (both `FLOAT` or both not), and destroys everything it finds at once before the next runs. Hazards and the win check use the rules from the parse after conversion. `won()` becomes `true` when the win check finds a `YOU` object that is `WIN`, or shares its tile with a same-float `WIN` object, and stays `true`. `lost()` is `true` whenever no object is `YOU`. `move(dir)` keeps its M8 meaning.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -27,6 +27,7 @@ Researched 2026-10-28. Every behavioral claim below cites a page that was actual
|
||||
- `FLOAT` row: "Partial | Separates `OPEN`/`SHUT` interactions by float layer. It is not applied to the other overlap/effect properties." (This differs from the official game. See g.)
|
||||
- ARCHITECTURE.md: "Parse horizontal and vertical `NOUN IS NOUN/PROPERTY` rules from text tiles." / "Text tiles are movable blockers during movement checks, even when they do not have an explicit `PUSH` rule." / "Rules are rebuilt after every move, so moving text can immediately change the active rule set."
|
||||
- Also loaded (2026-10-29, for Module 8): `Sources/baba-is-auto/Games/Game.cpp` at commit 2305a3d (raw). Movement is two-phase. `Game::CanMove` (line 1756) checks the move recursively before anything changes: "// Check boundary" returns false off the map, then STOP, then whether the pushed objects can move. `Game::ProcessMove` (line 1865) and `Game::ProcessPush` (line 1910) then move the objects. PUSH, STOP, text and OPEN/SHUT are branches inside `CanMove`.
|
||||
- Also loaded (2026-10-29, for Module 9): the same `Game.cpp` at commit 2305a3d (raw). `Game::MovePlayer` (line 495) is the whole turn as a fixed list of calls: `ProcessPlayerMove(dir)`, `ParseRules()`, `ProcessDirectionProperties()`, `ProcessMoveProperty()`, `ParseRules()`, `ProcessTransformations()`, `ParseRules()`, then `ProcessSink()`, `ProcessWeak()`, `ProcessHotMelt()`, `ProcessDefeat()`, `ProcessOpenShut()`, then `CheckPlayState()` and a last `ParseRules()`. Each hazard function collects ids over the whole map and removes them after the scan. `CheckPlayState` (line 2160) sets `PlayState::WON` when a YOU instance stands on a tile with a WIN property, and `PlayState::LOST` when no YOU instance is found. None of the hazard functions checks FLOAT, consistent with its `rule-language.md` FLOAT row.
|
||||
- **Course use:** a good example of how to represent rules as data and pin behavior with tests. Do **not** use it as an authority on official game semantics. It does not implement IS NOT for properties or nouns, it hard-codes text as pushable, and its FLOAT is partial.
|
||||
|
||||
### 2. Game Developer: "Designing Baba Is You's delightfully innovative rule-writing system"
|
||||
@@ -98,6 +99,7 @@ All pages were loaded as wikitext via `https://babaiswiki.fandom.com/api.php?act
|
||||
| SINK | loaded | "SINK is a property that causes an object to destroy all overlapping same-float objects along with itself when there is at least one overlapping object." / "A stack of two or more SINK objects will mutually SINK each other." / "SINKing order: Overlapping objects are destroyed (unless SAFE) in priority order, then the SINK object itself." / "SINK has no movement check, only an overlap check." |
|
||||
| DEFEAT | loaded | "If a DEFEAT object intersects with or is a YOU (or YOU2 or 3D), the YOU object will be destroyed." / "If an object is both WIN and DEFEAT, then DEFEAT takes priority and the YOU object is destroyed." / "if a square contains both a WIN object and a separate DEFEAT object, then DEFEAT takes priority." |
|
||||
| WIN | loaded | "WIN is a property that causes the level to be won if a same-float YOU YOU2 3D object overlaps the object." / "If an object is both WIN and YOU YOU2 3D, the level is won." / "WIN is checked very late in the process: any other removal of either object involved will occur first and prevent WIN from WINning." |
|
||||
| WEAK (loaded 2026-10-29, for Module 9) | loaded | "WEAK is a property that makes an object be destroyed if it overlaps any same-float object or collides with any object." / "If a WEAK object overlaps any same-float object, it is destroyed. ** Multiple same-float WEAK objects overlapping each other are all destroyed. ** Destruction by WEAK happens after SINK BOOM, but before other overlap checks." / "If a WEAK object attempts to move into a solid object and fails (e.g. STOP PULL or blocked PUSH), regardless of float levels, it is destroyed." / "If an object attempts to move into a same-float WEAK solid object that is not PUSHable, the movement is not blocked." |
|
||||
| FLOAT | loaded | "EAT and properties BONUS BOOM DEFEAT END HOLD HOT MELT OPEN SHUT SHIFT SINK TELE WEAK WIN require that the interacting objects are both FLOAT or both not FLOAT." / "FLOAT has no effect on collision-check properties such as STOP PUSH PULL SWAP, except OPEN SHUT." / "Whether an object is FLOATing or not updates once per turn, or at object creation (including transforms)." |
|
||||
| Float levels | loaded | "It does not affect ON or collision checks from PULL PUSH STOP SWAP." |
|
||||
| NOT | loaded | "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." / "ROCK IS NOT GRASS disables ROCK IS GRASS." / "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." / "NOT negates itself before negating anything else, so ROCK IS NOT NOT PUSH makes ROCK IS PUSH" |
|
||||
@@ -160,6 +162,7 @@ Inference status: this follows directly from the documented order. The wiki give
|
||||
- It removes X: "makes an object disappear".
|
||||
- It negates X IS X: "As it negates X IS X, it enables other X IS Y rules, which can result in transformation in addition to the disappearance."
|
||||
- **X IS NOT Y** blocks only the specific conversion X IS Y.
|
||||
- **When X IS NOT X removes X (reading, added 2026-10-29 for Module 9).** No source read gives X IS NOT X its own place in the turn. The Order of Operations page lists "object transformations via IS" in the conversion step, and the NOT page ties the disappearance to the transformation it enables ("which can result in transformation in addition to the disappearance"). The course deletes X in its conversion step, with the conversions.
|
||||
|
||||
### d. TEXT IS PUSH
|
||||
- **Yes, it is a built-in base rule.** Rule page: "The following rules are enabled by default, even though there isn't any text saying so. TEXT IS PUSH, LEVEL IS STOP, CURSOR IS SELECT". The PUSH and TEXT pages say the same.
|
||||
|
||||
Reference in New Issue
Block a user