diff --git a/course/05-assemble/scenarios.md b/course/05-assemble/scenarios.md index a84685f..e9e5c1c 100644 --- a/course/05-assemble/scenarios.md +++ b/course/05-assemble/scenarios.md @@ -85,3 +85,28 @@ The harness adds `registerCards(defs)`, `assemble(base, donor, half)`, `cost(id) - Assertions: - `hand(0)` has 3 cards. - The log contains exactly three `Triggered{source: H, trigger: "deathrattle"}`. Each `additive` multiplier with `times` 2 adds one activation, so two of them give 1 + 1 + 1. (Two Baron Rivendares, stacking `max`, give 2: see Module 4.) A new card with a new stacking behaviour, and no new code. + +### test_05_summoned_multiplier_counts_for_later_entries + +Two games with the same cards, played in a different order. Each registers a one-off card with `registerCards`, written as data in the test: `Test Herald`, a 1/1 minion, "Deathrattle: Summon a Baron Rivendare." (`split` 1; a `deathrattle` trigger clause, then a `summon` effect clause for `Baron Rivendare`, count 1). + +- Setup A: `newGame` with a deck of 5 `Test Filler` for player 0. Register `Test Herald`. Summon `Test Herald`, then `Loot Hoarder` (id H). Call `clearLog()`. +- Action A: `cast("Twisting Nether")`. +- Assertions A: + - The log contains exactly two `Triggered` events with source H, and `hand(0)` has 2 cards. Both minions leave play in one Death Creation Step and both Deathrattles are in one Queue, in order of play. The Herald's resolves first and summons Baron Rivendare; a summon runs an Aura Update, so when the Hoarder's entry starts, the multiplier is in force. + - `board(0)` holds one minion, named `"Baron Rivendare"`. +- Setup B: a new game, the same deck. Register `Test Herald`. Summon `Loot Hoarder` (id H), then `Test Herald`. Call `clearLog()`. +- Action B: `cast("Twisting Nether")`. +- Assertions B: + - The log contains exactly one `Triggered` event with source H, and `hand(0)` has 1 card. The Hoarder is older, so its Deathrattle starts before the Herald's has summoned anything. The count is read once, when a queue entry starts (Module 4). + - `board(0)` holds one minion, named `"Baron Rivendare"`. + +### test_05_own_aura_misses_own_battlecry + +The test registers a one-off card with `registerCards`, written as data in the test: `Test Braggart`, a 1/1 minion, "Battlecry: Draw a card. Your Battlecries trigger twice." (`split` 2; a `battlecry` trigger clause, a `draw` effect clause with count 1, then an aura clause: trigger multiplier, `battlecry`, 2 times, stacking `max`). + +- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Register `Test Braggart`. Add two to player 0's hand (ids B1, then B2). Call `clearLog()`. +- Action 1: `play(B1)`. +- Assertions 1: the log's `Triggered` events are exactly one, `Triggered{source: B1, trigger: "battlecry"}`, and `hand(0)` has 2 cards (B2 and the card drawn). Playing is not summoning: no Aura Update runs when B1 enters the board, so its own multiplier is not in the snapshot when its Battlecry resolves. An engine that scans the board every time it counts activations triggers the Battlecry twice here. +- Action 2: `clearLog()`, then `play(B2)`. +- Assertions 2: the log's `Triggered` events are exactly two, both `Triggered{source: B2, trigger: "battlecry"}`, and `hand(0)` has 3 cards. B1's aura joined the snapshot at the end of the first play's outermost Phase. diff --git a/course/05-assemble/tests/rust/test_05.rs b/course/05-assemble/tests/rust/test_05.rs index a047bcd..4f2ab19 100644 --- a/course/05-assemble/tests/rust/test_05.rs +++ b/course/05-assemble/tests/rust/test_05.rs @@ -306,3 +306,89 @@ fn test_05_additive_multipliers_stack() { .count(); assert_eq!(activations, 3); } + +/// A one-off card, written as data in the test. +fn card(json: &str) -> CardDef { + serde_json::from_str(json).unwrap() +} + +/// The `Triggered` events of a log, in order. +fn triggers(log: &[GameEvent]) -> Vec { + log.iter() + .filter(|e| matches!(e, GameEvent::Triggered { .. })) + .cloned() + .collect() +} + +/// "Deathrattle: Summon a Baron Rivendare." +const TEST_HERALD: &str = r#"{ "name": "Test Herald", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Baron Rivendare", "count": 1 } }] }"#; + +/// "Battlecry: Draw a card. Your Battlecries trigger twice." +const TEST_BRAGGART: &str = r#"{ "name": "Test Braggart", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "draw", "count": 1 } }, { "kind": "aura", "modifier": { "type": "triggerMultiplier", "trigger": "battlecry", "times": 2, "stacking": "max" } }] }"#; + +#[test] +fn test_05_summoned_multiplier_counts_for_later_entries() { + // Herald is older: its Deathrattle resolves first and summons Baron before the Hoarder's starts. + let mut a = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + a.register_cards(vec![card(TEST_HERALD)]); + a.summon("Test Herald", 0); + let hoarder_a = a.summon("Loot Hoarder", 0); + a.clear_log(); + + a.cast("Twisting Nether", 0, None); + + let of_hoarder = |log: &[GameEvent], h: Id| { + log.iter() + .filter(|e| matches!(e, GameEvent::Triggered { source, .. } if *source == h)) + .count() + }; + assert_eq!(of_hoarder(&a.log(), hoarder_a), 2); + assert_eq!(a.hand(0).len(), 2); + let names: Vec = a.board(0).iter().map(|&id| a.name_of(id)).collect(); + assert_eq!(names, vec!["Baron Rivendare"]); + + // The Hoarder is older: its Deathrattle has started, and finished, before any Baron exists. + let mut b = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + b.register_cards(vec![card(TEST_HERALD)]); + let hoarder_b = b.summon("Loot Hoarder", 0); + b.summon("Test Herald", 0); + b.clear_log(); + + b.cast("Twisting Nether", 0, None); + + assert_eq!(of_hoarder(&b.log(), hoarder_b), 1); + assert_eq!(b.hand(0).len(), 1); + let names: Vec = b.board(0).iter().map(|&id| b.name_of(id)).collect(); + assert_eq!(names, vec!["Baron Rivendare"]); +} + +#[test] +fn test_05_own_aura_misses_own_battlecry() { + let mut g = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + g.register_cards(vec![card(TEST_BRAGGART)]); + let first = g.add_to_hand("Test Braggart", 0); + let second = g.add_to_hand("Test Braggart", 0); + g.clear_log(); + + g.play(first); + + // Playing is not summoning: the Braggart's own aura joins at the end of the Phase. + assert_eq!(triggers(&g.log()), vec![triggered(first, "battlecry")]); + assert_eq!(g.hand(0).len(), 2); + + g.clear_log(); + g.play(second); + + // The first Braggart was already in play: the second one's Battlecry triggers twice. + assert_eq!( + triggers(&g.log()), + vec![triggered(second, "battlecry"), triggered(second, "battlecry")] + ); + assert_eq!(g.hand(0).len(), 3); +} diff --git a/course/05-assemble/tests/ts/test_05.test.ts b/course/05-assemble/tests/ts/test_05.test.ts index 928127e..4f4fcc9 100644 --- a/course/05-assemble/tests/ts/test_05.test.ts +++ b/course/05-assemble/tests/ts/test_05.test.ts @@ -200,3 +200,63 @@ test("test_05_additive_multipliers_stack", () => { .filter((e) => e.type === "Triggered" && e.source === h && e.trigger === "deathrattle"); expect(activations).toHaveLength(3); }); + +/** "Deathrattle: Summon a Baron Rivendare." A one-off card, written as data in the test. */ +const TEST_HERALD = { "name": "Test Herald", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Baron Rivendare", "count": 1 } }] } as CardDef; + +test("test_05_summoned_multiplier_counts_for_later_entries", () => { + // Herald is older: its Deathrattle resolves first and summons Baron before the Hoarder's starts. + const a = newGame({ decks: [fillers(5), []] }); + a.registerCards([TEST_HERALD]); + a.summon("Test Herald", 0); + const hoarderA = a.summon("Loot Hoarder", 0); + a.clearLog(); + + a.cast("Twisting Nether", 0); + + expect(a.log().filter((e) => e.type === "Triggered" && e.source === hoarderA)).toHaveLength(2); + expect(a.hand(0)).toHaveLength(2); + expect(a.board(0).map((id) => a.nameOf(id))).toEqual(["Baron Rivendare"]); + + // The Hoarder is older: its Deathrattle has started, and finished, before any Baron exists. + const b = newGame({ decks: [fillers(5), []] }); + b.registerCards([TEST_HERALD]); + const hoarderB = b.summon("Loot Hoarder", 0); + b.summon("Test Herald", 0); + b.clearLog(); + + b.cast("Twisting Nether", 0); + + expect(b.log().filter((e) => e.type === "Triggered" && e.source === hoarderB)).toHaveLength(1); + expect(b.hand(0)).toHaveLength(1); + expect(b.board(0).map((id) => b.nameOf(id))).toEqual(["Baron Rivendare"]); +}); + +/** "Battlecry: Draw a card. Your Battlecries trigger twice." A one-off card, written as data in the test. */ +const TEST_BRAGGART = { "name": "Test Braggart", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "draw", "count": 1 } }, { "kind": "aura", "modifier": { "type": "triggerMultiplier", "trigger": "battlecry", "times": 2, "stacking": "max" } }] } as CardDef; + +test("test_05_own_aura_misses_own_battlecry", () => { + const g = newGame({ decks: [fillers(5), []] }); + g.registerCards([TEST_BRAGGART]); + const first = g.addToHand("Test Braggart", 0); + const second = g.addToHand("Test Braggart", 0); + g.clearLog(); + + g.play(first); + + // Playing is not summoning: the Braggart's own aura joins at the end of the Phase. + expect(g.log().filter((e) => e.type === "Triggered")).toEqual([ + { type: "Triggered", source: first, trigger: "battlecry" }, + ]); + expect(g.hand(0)).toHaveLength(2); + + g.clearLog(); + g.play(second); + + // The first Braggart was already in play: the second one's Battlecry triggers twice. + expect(g.log().filter((e) => e.type === "Triggered")).toEqual([ + { type: "Triggered", source: second, trigger: "battlecry" }, + { type: "Triggered", source: second, trigger: "battlecry" }, + ]); + expect(g.hand(0)).toHaveLength(3); +}); diff --git a/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs b/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs index fb71ef5..565e4e4 100644 --- a/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs +++ b/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs @@ -61,7 +61,7 @@ pub struct Plan { /// Plans one move on a board it only reads. `stepped` holds the objects that have /// already stepped in this movement phase, in an earlier committed plan: an object -/// steps at most once per phase, so none of them can step again. +/// steps at most once per phase, so none of them steps again. pub struct Planner<'a> { level: &'a Level, dir: Dir, @@ -105,14 +105,15 @@ impl<'a> Planner<'a> { /// 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. An - /// object that has already stepped in this movement phase can't step again: the - /// answer is no. + /// object that has already stepped in this movement phase doesn't step again, and + /// doesn't hold the mover back either: the answer is yes, and nothing is added, so + /// objects that began the phase stacked on one tile end it stacked. pub fn try_move(&mut self, id: ObjectId) -> bool { if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { return true; } if self.stepped.contains(&id) { - return false; + return true; } let level = self.level; let o = level.board().get(id); diff --git a/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts b/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts index 9c0e51a..c3fd8d4 100644 --- a/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts +++ b/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts @@ -66,7 +66,7 @@ export interface MoveContext { /** * Plans one move on a board it only reads. `stepped` holds the objects that have * already stepped in this movement phase, in an earlier committed plan: an object - * steps at most once per phase, so none of them can step again. + * steps at most once per phase, so none of them steps again. */ export class Planner { private plan: Plan = { moving: new Set(), destroyed: new Set() }; @@ -104,12 +104,13 @@ export class Planner { * 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. An - * object that has already stepped in this movement phase can't step again: the - * answer is no. + * object that has already stepped in this movement phase doesn't step again, and + * doesn't hold the mover back either: the answer is yes, and nothing is added, so + * objects that began the phase stacked on one tile end it stacked. */ tryMove(id: ObjectId): boolean { if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; - if (this.stepped.has(id)) return false; + if (this.stepped.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]; diff --git a/course/08-push-chains/scenarios.md b/course/08-push-chains/scenarios.md index 4cc30a5..8b8e2e1 100644 --- a/course/08-push-chains/scenarios.md +++ b/course/08-push-chains/scenarios.md @@ -62,3 +62,11 @@ Words used: the nouns `BABA`, `ROCK`, `WALL`, `KEY`, `DOOR`; the properties `YOU - Assertions 1: `posOf(baba)` is (1,2) and `posOf(flagText)` is (2,2). The text would have to leave the level, so the chain can't move. - Action 2: `move("down")`. - Assertions 2: `posOf(baba)` is still (1,2): Baba is on the bottom edge. Nothing was destroyed: `posOf(flagText)` is still (2,2). + +### test_08_stacked_you_moves_together + +- Setup: `newBaba` with an empty level 5 wide and 3 high. Place `BABA (0,0) IS (1,0) YOU (2,0) AND (3,0) PUSH (4,0)`. Place two objects `baba` on (1,2) (the first and the second) and one object `baba` at (2,2) (the one ahead). +- Action: `move("right")`. +- Assertions: + - `posOf(ahead)` is (3,2), and `posOf(first)` and `posOf(second)` are both (2,2). + - Every Baba moved exactly one tile. An object steps at most once per move: the Baba ahead isn't pushed a second tile by the ones behind it, and the second Baba of the stack follows the first onto its tile instead of pushing it on or being left behind. diff --git a/course/08-push-chains/tests/rust/test_08.rs b/course/08-push-chains/tests/rust/test_08.rs index 87ec7a1..24edfb4 100644 --- a/course/08-push-chains/tests/rust/test_08.rs +++ b/course/08-push-chains/tests/rust/test_08.rs @@ -137,3 +137,27 @@ fn test_08_level_edge_is_stop() { assert_eq!(b.pos_of(baba), Some((1, 2))); assert_eq!(b.pos_of(flag_text), Some((2, 2))); } + +#[test] +fn test_08_stacked_you_moves_together() { + let mut b = new_baba(empty_level(5, 3)); + write( + &mut b, + &[ + ("BABA", 0, 0), + ("IS", 1, 0), + ("YOU", 2, 0), + ("AND", 3, 0), + ("PUSH", 4, 0), + ], + ); + let first = b.place("baba", 1, 2); + let second = b.place("baba", 1, 2); + let ahead = b.place("baba", 2, 2); + + b.move_dir(Dir::Right); + + assert_eq!(b.pos_of(ahead), Some((3, 2))); + assert_eq!(b.pos_of(first), Some((2, 2))); + assert_eq!(b.pos_of(second), Some((2, 2))); +} diff --git a/course/08-push-chains/tests/ts/test_08.test.ts b/course/08-push-chains/tests/ts/test_08.test.ts index a1342cf..cb1cdc2 100644 --- a/course/08-push-chains/tests/ts/test_08.test.ts +++ b/course/08-push-chains/tests/ts/test_08.test.ts @@ -143,3 +143,23 @@ test("test_08_level_edge_is_stop", () => { expect(b.posOf(baba)).toEqual([1, 2]); expect(b.posOf(flagText)).toEqual([2, 2]); }); + +test("test_08_stacked_you_moves_together", () => { + const b = newBaba(emptyLevel(5, 3)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ["AND", 3, 0], + ["PUSH", 4, 0], + ]); + const first = b.place("baba", 1, 2); + const second = b.place("baba", 1, 2); + const ahead = b.place("baba", 2, 2); + + b.move("right"); + + expect(b.posOf(ahead)).toEqual([3, 2]); + expect(b.posOf(first)).toEqual([2, 2]); + expect(b.posOf(second)).toEqual([2, 2]); +}); diff --git a/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs b/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs index fb71ef5..565e4e4 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs @@ -61,7 +61,7 @@ pub struct Plan { /// Plans one move on a board it only reads. `stepped` holds the objects that have /// already stepped in this movement phase, in an earlier committed plan: an object -/// steps at most once per phase, so none of them can step again. +/// steps at most once per phase, so none of them steps again. pub struct Planner<'a> { level: &'a Level, dir: Dir, @@ -105,14 +105,15 @@ impl<'a> Planner<'a> { /// 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. An - /// object that has already stepped in this movement phase can't step again: the - /// answer is no. + /// object that has already stepped in this movement phase doesn't step again, and + /// doesn't hold the mover back either: the answer is yes, and nothing is added, so + /// objects that began the phase stacked on one tile end it stacked. pub fn try_move(&mut self, id: ObjectId) -> bool { if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { return true; } if self.stepped.contains(&id) { - return false; + return true; } let level = self.level; let o = level.board().get(id); diff --git a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts index 9c0e51a..c3fd8d4 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts @@ -66,7 +66,7 @@ export interface MoveContext { /** * Plans one move on a board it only reads. `stepped` holds the objects that have * already stepped in this movement phase, in an earlier committed plan: an object - * steps at most once per phase, so none of them can step again. + * steps at most once per phase, so none of them steps again. */ export class Planner { private plan: Plan = { moving: new Set(), destroyed: new Set() }; @@ -104,12 +104,13 @@ export class Planner { * 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. An - * object that has already stepped in this movement phase can't step again: the - * answer is no. + * object that has already stepped in this movement phase doesn't step again, and + * doesn't hold the mover back either: the answer is yes, and nothing is added, so + * objects that began the phase stacked on one tile end it stacked. */ tryMove(id: ObjectId): boolean { if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; - if (this.stepped.has(id)) return false; + if (this.stepped.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]; diff --git a/course/09-turn-pipeline/scenarios.md b/course/09-turn-pipeline/scenarios.md index 106891d..b163321 100644 --- a/course/09-turn-pipeline/scenarios.md +++ b/course/09-turn-pipeline/scenarios.md @@ -101,3 +101,40 @@ This test needs the Module 7 Expansion Drop: the word `ALL` in the lexicon. - `kindsAt(3,4)` is `["rock"]`: the key is named by `ALL`, so `ALL IS ROCK` converted it. - `posOf(baba)` is (1,4) and `kindsAt(1,4)` is `["baba"]`: Baba is named by both `ALL` and `BABA`, and `BABA IS NOT ROCK` keeps it from becoming a rock. A negated conversion is settled per object, as `hasProperty` settles a negated property. - `activeRules()` contains both `ALL IS ROCK` and `BABA IS NOT ROCK`: the two rules have different subjects, so neither cancels the other as written. + +### test_09_conversion_reparses_before_hazards + +- Setup: `newBaba` with an empty level 5 wide and 6 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `LAVA (0,1) IS (1,1) HOT (2,1)`, `BABA (0,2) IS (1,2) MELT (2,2)` and `TEXT (0,3) IS (1,3) ROCK (2,3)`. Place an object `baba` at (0,5) and an object `lava` at (1,5). +- Action: `step("right")`. +- Assertions: + - `kindsAt(0,0)` is `["rock"]`: `TEXT IS ROCK` names every text object, its own three words included, and conversion turned them all into rocks. + - `rules()` is exactly `["TEXT IS PUSH"]`: with no text left, only the base rule remains. + - `posOf(baba)` is (1,5) and `kindsAt(1,5)` is `["baba", "lava"]`. Baba stands on the lava and is not destroyed: the parse after conversion removed `BABA IS MELT` before the hazards ran. A turn without that parse melts Baba. + - `lost()` is true: nothing is `YOU` any more. + +### test_09_conversion_before_hazards + +- Setup: `newBaba` with an empty level 5 wide and 6 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `LAVA (0,1) IS (1,1) HOT (2,1)`, `KEY (0,2) IS (1,2) MELT (2,2)` and `KEY (0,3) IS (1,3) ROCK (2,3)`. Place an object `baba` at (0,5), and an object `lava` and an object `key` (the key) both at (3,5). +- Action: `step("right")`. +- Assertions: `posOf(key)` is (3,5) and `kindsAt(3,5)` is `["lava", "rock"]`. The key became a rock in the conversion phase, keeping its id, and a rock is not `MELT` when the hazards run. A turn that ran the hazards before conversion would melt the key first. + +### test_09_last_parse_matches_board + +- Setup: `newBaba` with an empty level 6 wide and 6 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `FLAG (0,2) IS (1,2) WIN (2,2)` (the `WIN` text is the win text), and an object `water` at (2,2), under the word `WIN`. Place `WATER (3,4) IS (4,4)` and the text `SINK` at (5,3), one tile above the end of that sentence. Place an object `baba` at (5,2). +- Before: `rules()` contains `"FLAG IS WIN"`. +- Action: `step("down")`. +- Assertions: + - `posOf(winText)` and `posOf(water)` are both `null`. Baba pushed `SINK` down into `WATER IS SINK`; the parse after movement put the rule in force; the hazards phase then destroyed the water and the word sharing its tile. + - `rules()` is exactly `["BABA IS YOU", "TEXT IS PUSH", "WATER IS SINK"]`. `FLAG IS WIN` is gone because the turn ends with a parse: a hazard destroyed one of its words after the last parse that a phase read. + +### test_09_hazard_order_melt_before_defeat + +- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `LAVA (0,1) IS (1,1) HOT (2,1)` and `SKULL (0,2) IS (1,2) DEFEAT (2,2) AND (3,2) MELT (4,2)`. Place an object `baba` at (0,4), and an object `lava` and an object `skull` both at (1,4). +- Action: `step("right")`. +- Assertions: `posOf(skull)` is `null`, `posOf(baba)` is (1,4) and `lost()` is false. HOT/MELT runs before DEFEAT, and its victims are removed before the next hazard runs, so the skull has melted by the time DEFEAT looks for something to destroy Baba. The outcome is inferred from the order of the wiki's `block()`; no source shows this board. + +### test_09_hazard_order_sink_before_melt + +- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `WATER (0,1) IS (1,1) SINK (2,1)` and `KEY (0,2) IS (1,2) HOT (2,2) AND (3,2) MELT (4,2)`. Place an object `baba` at (0,4), and an object `water` and an object `key` both at (4,4). +- Action: `step("right")`. +- Assertions: `posOf(key)` and `posOf(water)` are both `null`. SINK runs first, while the key is still on the water's tile, and destroys both. In the other order the key, being HOT and MELT, would destroy itself first and leave the water alone on its tile, where SINK does nothing. The outcome is inferred from the order of the wiki's `block()`; no source shows this board. diff --git a/course/09-turn-pipeline/tests/rust/test_09.rs b/course/09-turn-pipeline/tests/rust/test_09.rs index 16badff..12bcec7 100644 --- a/course/09-turn-pipeline/tests/rust/test_09.rs +++ b/course/09-turn-pipeline/tests/rust/test_09.rs @@ -204,3 +204,117 @@ fn test_09_negated_conversion_beats_group() { assert!(active.contains(&"ALL IS ROCK".to_string())); assert!(active.contains(&"BABA IS NOT ROCK".to_string())); } + +#[test] +fn test_09_conversion_reparses_before_hazards() { + let mut b = new_baba(empty_level(5, 6)); + write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]); + write(&mut b, &[("LAVA", 0, 1), ("IS", 1, 1), ("HOT", 2, 1)]); + write(&mut b, &[("BABA", 0, 2), ("IS", 1, 2), ("MELT", 2, 2)]); + write(&mut b, &[("TEXT", 0, 3), ("IS", 1, 3), ("ROCK", 2, 3)]); + let baba = b.place("baba", 0, 5); + b.place("lava", 1, 5); + + b.step(Dir::Right); + + // Conversion turned every word into a rock, so no rule makes Baba MELT when the hazards run. + assert_eq!(b.kinds_at(0, 0), vec!["rock"]); + assert_eq!(b.rules(), vec!["TEXT IS PUSH"]); + assert_eq!(b.pos_of(baba), Some((1, 5))); + assert_eq!(b.kinds_at(1, 5), vec!["baba", "lava"]); + assert!(b.lost()); +} + +#[test] +fn test_09_conversion_before_hazards() { + let mut b = new_baba(empty_level(5, 6)); + 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, &[("KEY", 0, 2), ("IS", 1, 2), ("MELT", 2, 2)]); + write(&mut b, &[("KEY", 0, 3), ("IS", 1, 3), ("ROCK", 2, 3)]); + b.place("baba", 0, 5); + b.place("lava", 3, 5); + let key = b.place("key", 3, 5); + + b.step(Dir::Right); + + // The key became a rock before the hazards ran, and a rock is not MELT. + assert_eq!(b.pos_of(key), Some((3, 5))); + assert_eq!(b.kinds_at(3, 5), vec!["lava", "rock"]); +} + +#[test] +fn test_09_last_parse_matches_board() { + let mut b = new_baba(empty_level(6, 6)); + write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]); + write(&mut b, &[("FLAG", 0, 2), ("IS", 1, 2)]); + let win_text = b.place("text:WIN", 2, 2); + let water = b.place("water", 2, 2); + write(&mut b, &[("WATER", 3, 4), ("IS", 4, 4), ("SINK", 5, 3)]); + b.place("baba", 5, 2); + + assert!(b.rules().contains(&"FLAG IS WIN".to_string())); + + b.step(Dir::Down); + + // The push completed WATER IS SINK; the water then sank the word WIN it lay under. + assert_eq!(b.pos_of(win_text), None); + assert_eq!(b.pos_of(water), None); + assert_eq!( + b.rules(), + vec!["BABA IS YOU", "TEXT IS PUSH", "WATER IS SINK"] + ); +} + +#[test] +fn test_09_hazard_order_melt_before_defeat() { + let mut b = new_baba(empty_level(5, 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, + &[ + ("SKULL", 0, 2), + ("IS", 1, 2), + ("DEFEAT", 2, 2), + ("AND", 3, 2), + ("MELT", 4, 2), + ], + ); + let baba = b.place("baba", 0, 4); + b.place("lava", 1, 4); + let skull = b.place("skull", 1, 4); + + b.step(Dir::Right); + + // Inferred from the order of block(): HOT/MELT removes the skull before DEFEAT is checked. + assert_eq!(b.pos_of(skull), None); + assert_eq!(b.pos_of(baba), Some((1, 4))); + assert!(!b.lost()); +} + +#[test] +fn test_09_hazard_order_sink_before_melt() { + let mut b = new_baba(empty_level(5, 5)); + write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]); + write(&mut b, &[("WATER", 0, 1), ("IS", 1, 1), ("SINK", 2, 1)]); + write( + &mut b, + &[ + ("KEY", 0, 2), + ("IS", 1, 2), + ("HOT", 2, 2), + ("AND", 3, 2), + ("MELT", 4, 2), + ], + ); + b.place("baba", 0, 4); + let water = b.place("water", 4, 4); + let key = b.place("key", 4, 4); + + b.step(Dir::Right); + + // Inferred from the order of block(): SINK runs while the key is still there to sink with. + assert_eq!(b.pos_of(key), None); + assert_eq!(b.pos_of(water), None); +} diff --git a/course/09-turn-pipeline/tests/ts/test_09.test.ts b/course/09-turn-pipeline/tests/ts/test_09.test.ts index 6c82d86..c88612f 100644 --- a/course/09-turn-pipeline/tests/ts/test_09.test.ts +++ b/course/09-turn-pipeline/tests/ts/test_09.test.ts @@ -278,3 +278,162 @@ test("test_09_negated_conversion_beats_group", () => { expect(b.activeRules()).toContain("ALL IS ROCK"); expect(b.activeRules()).toContain("BABA IS NOT ROCK"); }); + +test("test_09_conversion_reparses_before_hazards", () => { + const b = newBaba(emptyLevel(5, 6)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + write(b, [ + ["LAVA", 0, 1], + ["IS", 1, 1], + ["HOT", 2, 1], + ]); + write(b, [ + ["BABA", 0, 2], + ["IS", 1, 2], + ["MELT", 2, 2], + ]); + write(b, [ + ["TEXT", 0, 3], + ["IS", 1, 3], + ["ROCK", 2, 3], + ]); + const baba = b.place("baba", 0, 5); + b.place("lava", 1, 5); + + b.step("right"); + + // Conversion turned every word into a rock, so no rule makes Baba MELT when the hazards run. + expect(b.kindsAt(0, 0)).toEqual(["rock"]); + expect(b.rules()).toEqual(["TEXT IS PUSH"]); + expect(b.posOf(baba)).toEqual([1, 5]); + expect(b.kindsAt(1, 5)).toEqual(["baba", "lava"]); + expect(b.lost()).toBe(true); +}); + +test("test_09_conversion_before_hazards", () => { + const b = newBaba(emptyLevel(5, 6)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + write(b, [ + ["LAVA", 0, 1], + ["IS", 1, 1], + ["HOT", 2, 1], + ]); + write(b, [ + ["KEY", 0, 2], + ["IS", 1, 2], + ["MELT", 2, 2], + ]); + write(b, [ + ["KEY", 0, 3], + ["IS", 1, 3], + ["ROCK", 2, 3], + ]); + b.place("baba", 0, 5); + b.place("lava", 3, 5); + const key = b.place("key", 3, 5); + + b.step("right"); + + // The key became a rock before the hazards ran, and a rock is not MELT. + expect(b.posOf(key)).toEqual([3, 5]); + expect(b.kindsAt(3, 5)).toEqual(["lava", "rock"]); +}); + +test("test_09_last_parse_matches_board", () => { + const b = newBaba(emptyLevel(6, 6)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + write(b, [ + ["FLAG", 0, 2], + ["IS", 1, 2], + ]); + const winText = b.place("text:WIN", 2, 2); + const water = b.place("water", 2, 2); + write(b, [ + ["WATER", 3, 4], + ["IS", 4, 4], + ["SINK", 5, 3], + ]); + b.place("baba", 5, 2); + + expect(b.rules()).toContain("FLAG IS WIN"); + + b.step("down"); + + // The push completed WATER IS SINK; the water then sank the word WIN it lay under. + expect(b.posOf(winText)).toBeNull(); + expect(b.posOf(water)).toBeNull(); + expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH", "WATER IS SINK"]); +}); + +test("test_09_hazard_order_melt_before_defeat", () => { + const b = newBaba(emptyLevel(5, 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, [ + ["SKULL", 0, 2], + ["IS", 1, 2], + ["DEFEAT", 2, 2], + ["AND", 3, 2], + ["MELT", 4, 2], + ]); + const baba = b.place("baba", 0, 4); + b.place("lava", 1, 4); + const skull = b.place("skull", 1, 4); + + b.step("right"); + + // Inferred from the order of block(): HOT/MELT removes the skull before DEFEAT is checked. + expect(b.posOf(skull)).toBeNull(); + expect(b.posOf(baba)).toEqual([1, 4]); + expect(b.lost()).toBe(false); +}); + +test("test_09_hazard_order_sink_before_melt", () => { + const b = newBaba(emptyLevel(5, 5)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + write(b, [ + ["WATER", 0, 1], + ["IS", 1, 1], + ["SINK", 2, 1], + ]); + write(b, [ + ["KEY", 0, 2], + ["IS", 1, 2], + ["HOT", 2, 2], + ["AND", 3, 2], + ["MELT", 4, 2], + ]); + b.place("baba", 0, 4); + const water = b.place("water", 4, 4); + const key = b.place("key", 4, 4); + + b.step("right"); + + // Inferred from the order of block(): SINK runs while the key is still there to sink with. + expect(b.posOf(key)).toBeNull(); + expect(b.posOf(water)).toBeNull(); +}); diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs index fb71ef5..565e4e4 100644 --- a/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs @@ -61,7 +61,7 @@ pub struct Plan { /// Plans one move on a board it only reads. `stepped` holds the objects that have /// already stepped in this movement phase, in an earlier committed plan: an object -/// steps at most once per phase, so none of them can step again. +/// steps at most once per phase, so none of them steps again. pub struct Planner<'a> { level: &'a Level, dir: Dir, @@ -105,14 +105,15 @@ impl<'a> Planner<'a> { /// 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. An - /// object that has already stepped in this movement phase can't step again: the - /// answer is no. + /// object that has already stepped in this movement phase doesn't step again, and + /// doesn't hold the mover back either: the answer is yes, and nothing is added, so + /// objects that began the phase stacked on one tile end it stacked. pub fn try_move(&mut self, id: ObjectId) -> bool { if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { return true; } if self.stepped.contains(&id) { - return false; + return true; } let level = self.level; let o = level.board().get(id); diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts index 9c0e51a..c3fd8d4 100644 --- a/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts @@ -66,7 +66,7 @@ export interface MoveContext { /** * Plans one move on a board it only reads. `stepped` holds the objects that have * already stepped in this movement phase, in an earlier committed plan: an object - * steps at most once per phase, so none of them can step again. + * steps at most once per phase, so none of them steps again. */ export class Planner { private plan: Plan = { moving: new Set(), destroyed: new Set() }; @@ -104,12 +104,13 @@ export class Planner { * 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. An - * object that has already stepped in this movement phase can't step again: the - * answer is no. + * object that has already stepped in this movement phase doesn't step again, and + * doesn't hold the mover back either: the answer is yes, and nothing is added, so + * objects that began the phase stacked on one tile end it stacked. */ tryMove(id: ObjectId): boolean { if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; - if (this.stepped.has(id)) return false; + if (this.stepped.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];