diff --git a/course/03-death-step/reference/rust/src/engine/death.rs b/course/03-death-step/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/03-death-step/reference/rust/src/engine/death.rs +++ b/course/03-death-step/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/03-death-step/reference/rust/src/engine/event.rs b/course/03-death-step/reference/rust/src/engine/event.rs index 9c8ccd2..73a9ed7 100644 --- a/course/03-death-step/reference/rust/src/engine/event.rs +++ b/course/03-death-step/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/03-death-step/reference/rust/src/engine/queue.rs b/course/03-death-step/reference/rust/src/engine/queue.rs index 356520b..34aefa1 100644 --- a/course/03-death-step/reference/rust/src/engine/queue.rs +++ b/course/03-death-step/reference/rust/src/engine/queue.rs @@ -59,8 +59,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -167,7 +167,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let ctx = Ctx { @@ -185,6 +187,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/03-death-step/reference/rust/src/engine/world.rs b/course/03-death-step/reference/rust/src/engine/world.rs index cd72c4c..e3fbf15 100644 --- a/course/03-death-step/reference/rust/src/engine/world.rs +++ b/course/03-death-step/reference/rust/src/engine/world.rs @@ -276,10 +276,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/03-death-step/reference/ts/src/engine/death.ts b/course/03-death-step/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/03-death-step/reference/ts/src/engine/death.ts +++ b/course/03-death-step/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/03-death-step/reference/ts/src/engine/event.ts b/course/03-death-step/reference/ts/src/engine/event.ts index ce049e4..7841036 100644 --- a/course/03-death-step/reference/ts/src/engine/event.ts +++ b/course/03-death-step/reference/ts/src/engine/event.ts @@ -14,7 +14,9 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number }; diff --git a/course/03-death-step/reference/ts/src/engine/queue.ts b/course/03-death-step/reference/ts/src/engine/queue.ts index 6452003..4462889 100644 --- a/course/03-death-step/reference/ts/src/engine/queue.ts +++ b/course/03-death-step/reference/ts/src/engine/queue.ts @@ -38,8 +38,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -129,13 +129,27 @@ function react(world: World, events: Event[]): void { // The Queue is now locked: nothing that happens below adds to it. for (const [source, trigger, event] of queue) { world.record({ type: "Triggered", source, on: trigger.on }); - const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); // Depth-first: each action, and every reaction it causes, finishes before the next. for (const action of actions) perform(world, action); } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/03-death-step/reference/ts/src/engine/world.ts b/course/03-death-step/reference/ts/src/engine/world.ts index af8fdae..bd06655 100644 --- a/course/03-death-step/reference/ts/src/engine/world.ts +++ b/course/03-death-step/reference/ts/src/engine/world.ts @@ -208,10 +208,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/03-death-step/scenarios.md b/course/03-death-step/scenarios.md index b1ef8b8..5bdc69f 100644 --- a/course/03-death-step/scenarios.md +++ b/course/03-death-step/scenarios.md @@ -54,3 +54,25 @@ A minion is removed from play only in the Death Creation Step, which runs when t - `health(G)` is 3: G was at full Health when it was removed. - `zoneOf(G)` is graveyard. - `nameOf(D)` is `"Damaged Golem"`. + +### test_03_summon_position_after_simultaneous_deaths + +- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Loot Hoarder` (id H), then `Harvest Golem` (id G), then `Test Reactor` (id R). `dealDamage(G, 2)`, which leaves G at 1 Health. +- Action: player 0 casts `Whirlwind`. +- Assertions: + - `hand(0)` has 1 card; call it C. The log has exactly one `Summoned` event; call its id D. + - The log is exactly, in this order: `SpellCast{card: "Whirlwind"}`, `Damaged{target: H, amount: 1}`, `Damaged{target: G, amount: 1}`, `Damaged{target: R, amount: 1}`, `Destroyed{id: H}`, `Destroyed{id: G}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C}`, `Triggered{source: G, trigger: "deathrattle"}`, `Summoned{id: D}`. H and G leave play in the same Death Creation Step. + - `nameOf(D)` is `"Damaged Golem"`. + - `board(0)` is `[D, R]`: the Damaged Golem stands where G stood, left of R. G was second from the left when the step began, but H, to its left, left play in the same step, so that board index no longer points at G's place. + - `zoneOf(H)` and `zoneOf(G)` are graveyard. + +### test_03_simultaneous_summons_keep_board_order + +- Setup: an empty game. Summon `Harvest Golem` (id A), then another `Harvest Golem` (id B), then `Test Reactor` (id R). `dealDamage(A, 2)` and `dealDamage(B, 2)`, which leave both at 1 Health. +- Action: player 0 casts `Whirlwind`. +- Assertions: + - The log has exactly two `Summoned` events; call their ids DA and DB, in log order. + - The log is exactly, in this order: `SpellCast{card: "Whirlwind"}`, `Damaged{target: A, amount: 1}`, `Damaged{target: B, amount: 1}`, `Damaged{target: R, amount: 1}`, `Destroyed{id: A}`, `Destroyed{id: B}`, `DeathPhase{round: 1}`, `Triggered{source: A, trigger: "deathrattle"}`, `Summoned{id: DA}`, `Triggered{source: B, trigger: "deathrattle"}`, `Summoned{id: DB}`. A's Deathrattle resolves first, by order of play, so DA is A's Damaged Golem and DB is B's. + - `nameOf(DA)` and `nameOf(DB)` are `"Damaged Golem"`. + - `board(0)` is `[DA, DB, R]`: the two Damaged Golems stand in the order A and B stood, both left of R. + - `zoneOf(A)` and `zoneOf(B)` are graveyard. diff --git a/course/03-death-step/tests/rust/test_03.rs b/course/03-death-step/tests/rust/test_03.rs index eca2f6a..4d3f96f 100644 --- a/course/03-death-step/tests/rust/test_03.rs +++ b/course/03-death-step/tests/rust/test_03.rs @@ -22,6 +22,16 @@ fn whirlwind() -> GameEvent { } } +/// The ids of the `Summoned` events in `log`, in order. +fn summoned_ids(log: &[GameEvent]) -> Vec { + log.iter() + .filter_map(|e| match e { + GameEvent::Summoned { id } => Some(*id), + _ => None, + }) + .collect() +} + #[test] fn test_03_removed_only_after_sequence() { let mut g = new_game(GameOpts { @@ -155,3 +165,82 @@ fn test_03_destroy_marks_pending() { assert_eq!(g.zone_of(golem), Zone::Graveyard); assert_eq!(g.name_of(d), "Damaged Golem"); } + +#[test] +fn test_03_summon_position_after_simultaneous_deaths() { + let mut g = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + let h = g.summon("Loot Hoarder", 0); + let golem = g.summon("Harvest Golem", 0); + let r = g.summon("Test Reactor", 0); + g.deal_damage(golem, 2); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + + let hand = g.hand(0); + assert_eq!(hand.len(), 1); + let c = hand[0]; + let summoned = summoned_ids(&g.log()); + assert_eq!(summoned.len(), 1); + let d = summoned[0]; + assert_eq!( + g.log(), + vec![ + whirlwind(), + damaged(h, 1), + damaged(golem, 1), + damaged(r, 1), + GameEvent::Destroyed { id: h }, + GameEvent::Destroyed { id: golem }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c }, + triggered(golem, "deathrattle"), + GameEvent::Summoned { id: d }, + ] + ); + assert_eq!(g.name_of(d), "Damaged Golem"); + assert_eq!(g.board(0), vec![d, r]); + assert_eq!(g.zone_of(h), Zone::Graveyard); + assert_eq!(g.zone_of(golem), Zone::Graveyard); +} + +#[test] +fn test_03_simultaneous_summons_keep_board_order() { + let mut g = new_game(GameOpts::default()); + let a = g.summon("Harvest Golem", 0); + let b = g.summon("Harvest Golem", 0); + let r = g.summon("Test Reactor", 0); + g.deal_damage(a, 2); + g.deal_damage(b, 2); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + + let summoned = summoned_ids(&g.log()); + assert_eq!(summoned.len(), 2); + let (da, db) = (summoned[0], summoned[1]); + assert_eq!( + g.log(), + vec![ + whirlwind(), + damaged(a, 1), + damaged(b, 1), + damaged(r, 1), + GameEvent::Destroyed { id: a }, + GameEvent::Destroyed { id: b }, + GameEvent::DeathPhase { round: 1 }, + triggered(a, "deathrattle"), + GameEvent::Summoned { id: da }, + triggered(b, "deathrattle"), + GameEvent::Summoned { id: db }, + ] + ); + assert_eq!(g.name_of(da), "Damaged Golem"); + assert_eq!(g.name_of(db), "Damaged Golem"); + assert_eq!(g.board(0), vec![da, db, r]); + assert_eq!(g.zone_of(a), Zone::Graveyard); + assert_eq!(g.zone_of(b), Zone::Graveyard); +} diff --git a/course/03-death-step/tests/ts/test_03.test.ts b/course/03-death-step/tests/ts/test_03.test.ts index eda5ea5..e469fce 100644 --- a/course/03-death-step/tests/ts/test_03.test.ts +++ b/course/03-death-step/tests/ts/test_03.test.ts @@ -111,3 +111,72 @@ test("test_03_destroy_marks_pending", () => { expect(g.zoneOf(golem)).toBe("graveyard"); expect(g.nameOf(d)).toBe("Damaged Golem"); }); + +test("test_03_summon_position_after_simultaneous_deaths", () => { + const g = newGame({ decks: [fillers(5), []] }); + const h = g.summon("Loot Hoarder", 0); + const golem = g.summon("Harvest Golem", 0); + const r = g.summon("Test Reactor", 0); + g.dealDamage(golem, 2); + g.clearLog(); + + g.cast("Whirlwind", 0); + + const hand = g.hand(0); + expect(hand).toHaveLength(1); + const c = hand[0]; + const summoned = g.log().flatMap((e) => (e.type === "Summoned" ? [e.id] : [])); + expect(summoned).toHaveLength(1); + const d = summoned[0]; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Whirlwind" }, + { type: "Damaged", target: h, amount: 1 }, + { type: "Damaged", target: golem, amount: 1 }, + { type: "Damaged", target: r, amount: 1 }, + { type: "Destroyed", id: h }, + { type: "Destroyed", id: golem }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d }, + ]); + expect(g.nameOf(d)).toBe("Damaged Golem"); + expect(g.board(0)).toEqual([d, r]); + expect(g.zoneOf(h)).toBe("graveyard"); + expect(g.zoneOf(golem)).toBe("graveyard"); +}); + +test("test_03_simultaneous_summons_keep_board_order", () => { + const g = newGame(); + const a = g.summon("Harvest Golem", 0); + const b = g.summon("Harvest Golem", 0); + const r = g.summon("Test Reactor", 0); + g.dealDamage(a, 2); + g.dealDamage(b, 2); + g.clearLog(); + + g.cast("Whirlwind", 0); + + const summoned = g.log().flatMap((e) => (e.type === "Summoned" ? [e.id] : [])); + expect(summoned).toHaveLength(2); + const [da, db] = summoned; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Whirlwind" }, + { type: "Damaged", target: a, amount: 1 }, + { type: "Damaged", target: b, amount: 1 }, + { type: "Damaged", target: r, amount: 1 }, + { type: "Destroyed", id: a }, + { type: "Destroyed", id: b }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: a, trigger: "deathrattle" }, + { type: "Summoned", id: da }, + { type: "Triggered", source: b, trigger: "deathrattle" }, + { type: "Summoned", id: db }, + ]); + expect(g.nameOf(da)).toBe("Damaged Golem"); + expect(g.nameOf(db)).toBe("Damaged Golem"); + expect(g.board(0)).toEqual([da, db, r]); + expect(g.zoneOf(a)).toBe("graveyard"); + expect(g.zoneOf(b)).toBe("graveyard"); +}); diff --git a/course/04-interceptors/reference/rust/src/engine/death.rs b/course/04-interceptors/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/04-interceptors/reference/rust/src/engine/death.rs +++ b/course/04-interceptors/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/04-interceptors/reference/rust/src/engine/event.rs b/course/04-interceptors/reference/rust/src/engine/event.rs index 5d4f33e..0eb180b 100644 --- a/course/04-interceptors/reference/rust/src/engine/event.rs +++ b/course/04-interceptors/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/04-interceptors/reference/rust/src/engine/queue.rs b/course/04-interceptors/reference/rust/src/engine/queue.rs index 776db5e..d06e76a 100644 --- a/course/04-interceptors/reference/rust/src/engine/queue.rs +++ b/course/04-interceptors/reference/rust/src/engine/queue.rs @@ -69,8 +69,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -205,7 +205,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let ctx = Ctx { @@ -224,6 +226,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/04-interceptors/reference/rust/src/engine/world.rs b/course/04-interceptors/reference/rust/src/engine/world.rs index 87bd2e5..15744cc 100644 --- a/course/04-interceptors/reference/rust/src/engine/world.rs +++ b/course/04-interceptors/reference/rust/src/engine/world.rs @@ -295,10 +295,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/04-interceptors/reference/ts/src/engine/death.ts b/course/04-interceptors/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/04-interceptors/reference/ts/src/engine/death.ts +++ b/course/04-interceptors/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/04-interceptors/reference/ts/src/engine/event.ts b/course/04-interceptors/reference/ts/src/engine/event.ts index a8be9c7..06c814a 100644 --- a/course/04-interceptors/reference/ts/src/engine/event.ts +++ b/course/04-interceptors/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId }; diff --git a/course/04-interceptors/reference/ts/src/engine/queue.ts b/course/04-interceptors/reference/ts/src/engine/queue.ts index c1106ca..8a474a0 100644 --- a/course/04-interceptors/reference/ts/src/engine/queue.ts +++ b/course/04-interceptors/reference/ts/src/engine/queue.ts @@ -45,8 +45,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -161,7 +161,10 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); // Depth-first: each action, and every reaction it causes, finishes before the next. for (const action of actions) perform(world, action); @@ -169,6 +172,17 @@ function react(world: World, events: Event[]): void { } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/04-interceptors/reference/ts/src/engine/world.ts b/course/04-interceptors/reference/ts/src/engine/world.ts index be93f98..8fd71b5 100644 --- a/course/04-interceptors/reference/ts/src/engine/world.ts +++ b/course/04-interceptors/reference/ts/src/engine/world.ts @@ -225,10 +225,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/05-assemble/reference/rust/src/engine/death.rs b/course/05-assemble/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/05-assemble/reference/rust/src/engine/death.rs +++ b/course/05-assemble/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/05-assemble/reference/rust/src/engine/effect.rs b/course/05-assemble/reference/rust/src/engine/effect.rs index 9c9f438..98c981a 100644 --- a/course/05-assemble/reference/rust/src/engine/effect.rs +++ b/course/05-assemble/reference/rust/src/engine/effect.rs @@ -82,7 +82,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec { } /// 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. +/// source stood (left of the minion its Death Event remembers); at the right end for a spell. fn summon_position(ctx: &Ctx) -> Option { match ctx.source.and_then(|s| ctx.world.board_index(s)) { Some(index) => Some(index + 1), diff --git a/course/05-assemble/reference/rust/src/engine/event.rs b/course/05-assemble/reference/rust/src/engine/event.rs index 5d4f33e..0eb180b 100644 --- a/course/05-assemble/reference/rust/src/engine/event.rs +++ b/course/05-assemble/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/05-assemble/reference/rust/src/engine/queue.rs b/course/05-assemble/reference/rust/src/engine/queue.rs index f81c39a..7779d7f 100644 --- a/course/05-assemble/reference/rust/src/engine/queue.rs +++ b/course/05-assemble/reference/rust/src/engine/queue.rs @@ -86,8 +86,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -241,7 +241,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let owner = world.owner_of(source); @@ -250,6 +252,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/05-assemble/reference/rust/src/engine/world.rs b/course/05-assemble/reference/rust/src/engine/world.rs index b2224e2..e2f22f9 100644 --- a/course/05-assemble/reference/rust/src/engine/world.rs +++ b/course/05-assemble/reference/rust/src/engine/world.rs @@ -350,10 +350,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/05-assemble/reference/ts/src/engine/death.ts b/course/05-assemble/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/05-assemble/reference/ts/src/engine/death.ts +++ b/course/05-assemble/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/05-assemble/reference/ts/src/engine/effect.ts b/course/05-assemble/reference/ts/src/engine/effect.ts index 17a1a61..73bf6f3 100644 --- a/course/05-assemble/reference/ts/src/engine/effect.ts +++ b/course/05-assemble/reference/ts/src/engine/effect.ts @@ -60,7 +60,7 @@ function targets(target: Target, ctx: Ctx): EntityId[] { /** * 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. + * source stood (left of the minion its Death Event remembers); at the right end for a spell. */ function summonPosition(ctx: Ctx): number | undefined { const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); diff --git a/course/05-assemble/reference/ts/src/engine/event.ts b/course/05-assemble/reference/ts/src/engine/event.ts index a8be9c7..06c814a 100644 --- a/course/05-assemble/reference/ts/src/engine/event.ts +++ b/course/05-assemble/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId }; diff --git a/course/05-assemble/reference/ts/src/engine/queue.ts b/course/05-assemble/reference/ts/src/engine/queue.ts index 1f27521..d8aa76e 100644 --- a/course/05-assemble/reference/ts/src/engine/queue.ts +++ b/course/05-assemble/reference/ts/src/engine/queue.ts @@ -55,8 +55,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -188,12 +188,26 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/05-assemble/reference/ts/src/engine/world.ts b/course/05-assemble/reference/ts/src/engine/world.ts index 1522da4..4cfb022 100644 --- a/course/05-assemble/reference/ts/src/engine/world.ts +++ b/course/05-assemble/reference/ts/src/engine/world.ts @@ -275,10 +275,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/06-parser/reference/rust/src/engine/death.rs b/course/06-parser/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/06-parser/reference/rust/src/engine/death.rs +++ b/course/06-parser/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/06-parser/reference/rust/src/engine/effect.rs b/course/06-parser/reference/rust/src/engine/effect.rs index 9c9f438..98c981a 100644 --- a/course/06-parser/reference/rust/src/engine/effect.rs +++ b/course/06-parser/reference/rust/src/engine/effect.rs @@ -82,7 +82,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec { } /// 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. +/// source stood (left of the minion its Death Event remembers); at the right end for a spell. fn summon_position(ctx: &Ctx) -> Option { match ctx.source.and_then(|s| ctx.world.board_index(s)) { Some(index) => Some(index + 1), diff --git a/course/06-parser/reference/rust/src/engine/event.rs b/course/06-parser/reference/rust/src/engine/event.rs index 5d4f33e..0eb180b 100644 --- a/course/06-parser/reference/rust/src/engine/event.rs +++ b/course/06-parser/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/06-parser/reference/rust/src/engine/queue.rs b/course/06-parser/reference/rust/src/engine/queue.rs index f81c39a..7779d7f 100644 --- a/course/06-parser/reference/rust/src/engine/queue.rs +++ b/course/06-parser/reference/rust/src/engine/queue.rs @@ -86,8 +86,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -241,7 +241,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let owner = world.owner_of(source); @@ -250,6 +252,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/06-parser/reference/rust/src/engine/world.rs b/course/06-parser/reference/rust/src/engine/world.rs index b2224e2..e2f22f9 100644 --- a/course/06-parser/reference/rust/src/engine/world.rs +++ b/course/06-parser/reference/rust/src/engine/world.rs @@ -350,10 +350,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/06-parser/reference/ts/src/engine/death.ts b/course/06-parser/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/06-parser/reference/ts/src/engine/death.ts +++ b/course/06-parser/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/06-parser/reference/ts/src/engine/effect.ts b/course/06-parser/reference/ts/src/engine/effect.ts index 17a1a61..73bf6f3 100644 --- a/course/06-parser/reference/ts/src/engine/effect.ts +++ b/course/06-parser/reference/ts/src/engine/effect.ts @@ -60,7 +60,7 @@ function targets(target: Target, ctx: Ctx): EntityId[] { /** * 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. + * source stood (left of the minion its Death Event remembers); at the right end for a spell. */ function summonPosition(ctx: Ctx): number | undefined { const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); diff --git a/course/06-parser/reference/ts/src/engine/event.ts b/course/06-parser/reference/ts/src/engine/event.ts index a8be9c7..06c814a 100644 --- a/course/06-parser/reference/ts/src/engine/event.ts +++ b/course/06-parser/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId }; diff --git a/course/06-parser/reference/ts/src/engine/queue.ts b/course/06-parser/reference/ts/src/engine/queue.ts index 1f27521..d8aa76e 100644 --- a/course/06-parser/reference/ts/src/engine/queue.ts +++ b/course/06-parser/reference/ts/src/engine/queue.ts @@ -55,8 +55,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -188,12 +188,26 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/06-parser/reference/ts/src/engine/world.ts b/course/06-parser/reference/ts/src/engine/world.ts index 1522da4..4cfb022 100644 --- a/course/06-parser/reference/ts/src/engine/world.ts +++ b/course/06-parser/reference/ts/src/engine/world.ts @@ -275,10 +275,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/07-rule-resolution/reference/rust/src/engine/death.rs b/course/07-rule-resolution/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/death.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/07-rule-resolution/reference/rust/src/engine/effect.rs b/course/07-rule-resolution/reference/rust/src/engine/effect.rs index 9c9f438..98c981a 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/effect.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/effect.rs @@ -82,7 +82,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec { } /// 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. +/// source stood (left of the minion its Death Event remembers); at the right end for a spell. fn summon_position(ctx: &Ctx) -> Option { match ctx.source.and_then(|s| ctx.world.board_index(s)) { Some(index) => Some(index + 1), diff --git a/course/07-rule-resolution/reference/rust/src/engine/event.rs b/course/07-rule-resolution/reference/rust/src/engine/event.rs index 5d4f33e..0eb180b 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/event.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/07-rule-resolution/reference/rust/src/engine/queue.rs b/course/07-rule-resolution/reference/rust/src/engine/queue.rs index f81c39a..7779d7f 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/queue.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/queue.rs @@ -86,8 +86,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -241,7 +241,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let owner = world.owner_of(source); @@ -250,6 +252,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/07-rule-resolution/reference/rust/src/engine/world.rs b/course/07-rule-resolution/reference/rust/src/engine/world.rs index b2224e2..e2f22f9 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/world.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/world.rs @@ -350,10 +350,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/07-rule-resolution/reference/ts/src/engine/death.ts b/course/07-rule-resolution/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/death.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/07-rule-resolution/reference/ts/src/engine/effect.ts b/course/07-rule-resolution/reference/ts/src/engine/effect.ts index 17a1a61..73bf6f3 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/effect.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/effect.ts @@ -60,7 +60,7 @@ function targets(target: Target, ctx: Ctx): EntityId[] { /** * 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. + * source stood (left of the minion its Death Event remembers); at the right end for a spell. */ function summonPosition(ctx: Ctx): number | undefined { const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); diff --git a/course/07-rule-resolution/reference/ts/src/engine/event.ts b/course/07-rule-resolution/reference/ts/src/engine/event.ts index a8be9c7..06c814a 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/event.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId }; diff --git a/course/07-rule-resolution/reference/ts/src/engine/queue.ts b/course/07-rule-resolution/reference/ts/src/engine/queue.ts index 1f27521..d8aa76e 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/queue.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/queue.ts @@ -55,8 +55,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -188,12 +188,26 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/07-rule-resolution/reference/ts/src/engine/world.ts b/course/07-rule-resolution/reference/ts/src/engine/world.ts index 1522da4..4cfb022 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/world.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/world.ts @@ -275,10 +275,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/08-push-chains/reference/rust/src/engine/death.rs b/course/08-push-chains/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/08-push-chains/reference/rust/src/engine/death.rs +++ b/course/08-push-chains/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/08-push-chains/reference/rust/src/engine/effect.rs b/course/08-push-chains/reference/rust/src/engine/effect.rs index 9c9f438..98c981a 100644 --- a/course/08-push-chains/reference/rust/src/engine/effect.rs +++ b/course/08-push-chains/reference/rust/src/engine/effect.rs @@ -82,7 +82,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec { } /// 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. +/// source stood (left of the minion its Death Event remembers); at the right end for a spell. fn summon_position(ctx: &Ctx) -> Option { match ctx.source.and_then(|s| ctx.world.board_index(s)) { Some(index) => Some(index + 1), diff --git a/course/08-push-chains/reference/rust/src/engine/event.rs b/course/08-push-chains/reference/rust/src/engine/event.rs index 5d4f33e..0eb180b 100644 --- a/course/08-push-chains/reference/rust/src/engine/event.rs +++ b/course/08-push-chains/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/08-push-chains/reference/rust/src/engine/queue.rs b/course/08-push-chains/reference/rust/src/engine/queue.rs index f81c39a..7779d7f 100644 --- a/course/08-push-chains/reference/rust/src/engine/queue.rs +++ b/course/08-push-chains/reference/rust/src/engine/queue.rs @@ -86,8 +86,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -241,7 +241,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let owner = world.owner_of(source); @@ -250,6 +252,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/08-push-chains/reference/rust/src/engine/world.rs b/course/08-push-chains/reference/rust/src/engine/world.rs index b2224e2..e2f22f9 100644 --- a/course/08-push-chains/reference/rust/src/engine/world.rs +++ b/course/08-push-chains/reference/rust/src/engine/world.rs @@ -350,10 +350,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/08-push-chains/reference/ts/src/engine/death.ts b/course/08-push-chains/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/08-push-chains/reference/ts/src/engine/death.ts +++ b/course/08-push-chains/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/08-push-chains/reference/ts/src/engine/effect.ts b/course/08-push-chains/reference/ts/src/engine/effect.ts index 17a1a61..73bf6f3 100644 --- a/course/08-push-chains/reference/ts/src/engine/effect.ts +++ b/course/08-push-chains/reference/ts/src/engine/effect.ts @@ -60,7 +60,7 @@ function targets(target: Target, ctx: Ctx): EntityId[] { /** * 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. + * source stood (left of the minion its Death Event remembers); at the right end for a spell. */ function summonPosition(ctx: Ctx): number | undefined { const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); diff --git a/course/08-push-chains/reference/ts/src/engine/event.ts b/course/08-push-chains/reference/ts/src/engine/event.ts index a8be9c7..06c814a 100644 --- a/course/08-push-chains/reference/ts/src/engine/event.ts +++ b/course/08-push-chains/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId }; diff --git a/course/08-push-chains/reference/ts/src/engine/queue.ts b/course/08-push-chains/reference/ts/src/engine/queue.ts index 1f27521..d8aa76e 100644 --- a/course/08-push-chains/reference/ts/src/engine/queue.ts +++ b/course/08-push-chains/reference/ts/src/engine/queue.ts @@ -55,8 +55,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -188,12 +188,26 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/08-push-chains/reference/ts/src/engine/world.ts b/course/08-push-chains/reference/ts/src/engine/world.ts index 1522da4..4cfb022 100644 --- a/course/08-push-chains/reference/ts/src/engine/world.ts +++ b/course/08-push-chains/reference/ts/src/engine/world.ts @@ -275,10 +275,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/09-turn-pipeline/reference/rust/src/engine/death.rs b/course/09-turn-pipeline/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/death.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/09-turn-pipeline/reference/rust/src/engine/effect.rs b/course/09-turn-pipeline/reference/rust/src/engine/effect.rs index 9c9f438..98c981a 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/effect.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/effect.rs @@ -82,7 +82,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec { } /// 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. +/// source stood (left of the minion its Death Event remembers); at the right end for a spell. fn summon_position(ctx: &Ctx) -> Option { match ctx.source.and_then(|s| ctx.world.board_index(s)) { Some(index) => Some(index + 1), diff --git a/course/09-turn-pipeline/reference/rust/src/engine/event.rs b/course/09-turn-pipeline/reference/rust/src/engine/event.rs index 5d4f33e..0eb180b 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/event.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs index f81c39a..7779d7f 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs @@ -86,8 +86,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -241,7 +241,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let owner = world.owner_of(source); @@ -250,6 +252,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/09-turn-pipeline/reference/rust/src/engine/world.rs b/course/09-turn-pipeline/reference/rust/src/engine/world.rs index b2224e2..e2f22f9 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/world.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/world.rs @@ -350,10 +350,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/09-turn-pipeline/reference/ts/src/engine/death.ts b/course/09-turn-pipeline/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/death.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/09-turn-pipeline/reference/ts/src/engine/effect.ts b/course/09-turn-pipeline/reference/ts/src/engine/effect.ts index 17a1a61..73bf6f3 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/effect.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/effect.ts @@ -60,7 +60,7 @@ function targets(target: Target, ctx: Ctx): EntityId[] { /** * 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. + * source stood (left of the minion its Death Event remembers); at the right end for a spell. */ function summonPosition(ctx: Ctx): number | undefined { const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); diff --git a/course/09-turn-pipeline/reference/ts/src/engine/event.ts b/course/09-turn-pipeline/reference/ts/src/engine/event.ts index a8be9c7..06c814a 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/event.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId }; diff --git a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts index 1f27521..d8aa76e 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts @@ -55,8 +55,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -188,12 +188,26 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/09-turn-pipeline/reference/ts/src/engine/world.ts b/course/09-turn-pipeline/reference/ts/src/engine/world.ts index 1522da4..4cfb022 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/world.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/world.ts @@ -275,10 +275,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void { diff --git a/course/10-spellgrid/reference/rust/src/engine/death.rs b/course/10-spellgrid/reference/rust/src/engine/death.rs index 69a00e3..fbec8a9 100644 --- a/course/10-spellgrid/reference/rust/src/engine/death.rs +++ b/course/10-spellgrid/reference/rust/src/engine/death.rs @@ -5,26 +5,37 @@ //! ends. Then this step removes every marked minion at once. use super::event::Event; -use super::world::World; +use super::world::{EntityId, World, Zone}; /// 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 { - // Every position is read before anything moves: removal is simultaneous, and each - // Death Event remembers where its minion stood when the step began. + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). 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 }); + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); } } for death in &deaths { - if let Event::Destroyed { id, position } = *death { - world.kill(id, position); + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); } } deaths } + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/10-spellgrid/reference/rust/src/engine/effect.rs b/course/10-spellgrid/reference/rust/src/engine/effect.rs index 300436b..53cb47c 100644 --- a/course/10-spellgrid/reference/rust/src/engine/effect.rs +++ b/course/10-spellgrid/reference/rust/src/engine/effect.rs @@ -85,7 +85,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec { } /// 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. +/// source stood (left of the minion its Death Event remembers); at the right end for a spell. fn summon_position(ctx: &Ctx) -> Option { match ctx.source.and_then(|s| ctx.world.board_index(s)) { Some(index) => Some(index + 1), diff --git a/course/10-spellgrid/reference/rust/src/engine/event.rs b/course/10-spellgrid/reference/rust/src/engine/event.rs index 0a0ff2b..149737f 100644 --- a/course/10-spellgrid/reference/rust/src/engine/event.rs +++ b/course/10-spellgrid/reference/rust/src/engine/event.rs @@ -36,10 +36,12 @@ pub enum Event { 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. Destroyed { id: EntityId, - position: usize, + before: Option, }, DeathPhase { round: u32, diff --git a/course/10-spellgrid/reference/rust/src/engine/queue.rs b/course/10-spellgrid/reference/rust/src/engine/queue.rs index 4358c8f..f5bdf33 100644 --- a/course/10-spellgrid/reference/rust/src/engine/queue.rs +++ b/course/10-spellgrid/reference/rust/src/engine/queue.rs @@ -110,8 +110,8 @@ pub struct Ctx<'a> { pub source: Option, /// The chosen target, if any. pub target: Option, - /// The source's board position. For a Deathrattle, the source has left play, - /// and this is the position its Death Event remembered. + /// The source's board position. For a Deathrattle, the source has left play, and + /// this is where it stood: immediately left of the minion its Death Event remembers. pub position: Option, } @@ -270,7 +270,9 @@ fn react(world: &mut World, events: &[Event]) { on: trigger.on, }); let position = match *event { - Event::Destroyed { id, position } if id == source => Some(position), + Event::Destroyed { id, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } _ => world.board_index(source), }; let owner = world.owner_of(source); @@ -279,6 +281,16 @@ fn react(world: &mut World, events: &[Event]) { } } +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + /// 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 { diff --git a/course/10-spellgrid/reference/rust/src/engine/world.rs b/course/10-spellgrid/reference/rust/src/engine/world.rs index 01401d4..84b66fc 100644 --- a/course/10-spellgrid/reference/rust/src/engine/world.rs +++ b/course/10-spellgrid/reference/rust/src/engine/world.rs @@ -383,10 +383,10 @@ impl World { } /// Move a minion from play to its owner's graveyard, recording its Death Event. - pub fn kill(&mut self, id: EntityId, position: usize) { + pub fn kill(&mut self, id: EntityId, before: Option) { self.entity_mut(id).pending_destroy = false; self.move_to(id, Zone::Graveyard); - self.record(Event::Destroyed { id, position }); + self.record(Event::Destroyed { id, before }); } pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { diff --git a/course/10-spellgrid/reference/ts/src/engine/death.ts b/course/10-spellgrid/reference/ts/src/engine/death.ts index 6dc715d..d035e1c 100644 --- a/course/10-spellgrid/reference/ts/src/engine/death.ts +++ b/course/10-spellgrid/reference/ts/src/engine/death.ts @@ -5,7 +5,7 @@ // ends. Then this step removes every marked minion at once. import type { Event } from "./event"; -import type { World } from "./world"; +import type { EntityId, World } from "./world"; /** * Remove every mortally wounded or pending-destroy minion from play, simultaneously. @@ -13,9 +13,17 @@ import type { World } from "./world"; */ 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); + // Every neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); return deaths; } + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/10-spellgrid/reference/ts/src/engine/effect.ts b/course/10-spellgrid/reference/ts/src/engine/effect.ts index bb98be5..995f9be 100644 --- a/course/10-spellgrid/reference/ts/src/engine/effect.ts +++ b/course/10-spellgrid/reference/ts/src/engine/effect.ts @@ -60,7 +60,7 @@ function targets(target: Target, ctx: Ctx): EntityId[] { /** * 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. + * source stood (left of the minion its Death Event remembers); at the right end for a spell. */ function summonPosition(ctx: Ctx): number | undefined { const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); diff --git a/course/10-spellgrid/reference/ts/src/engine/event.ts b/course/10-spellgrid/reference/ts/src/engine/event.ts index fffe1e1..0272cab 100644 --- a/course/10-spellgrid/reference/ts/src/engine/event.ts +++ b/course/10-spellgrid/reference/ts/src/engine/event.ts @@ -14,9 +14,11 @@ export type Event = | { 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. */ - | { type: "Destroyed"; id: EntityId; position: number } + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } | { type: "DeathPhase"; round: number } /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ | { type: "Played"; id: EntityId } diff --git a/course/10-spellgrid/reference/ts/src/engine/queue.ts b/course/10-spellgrid/reference/ts/src/engine/queue.ts index 3558522..50bfdc0 100644 --- a/course/10-spellgrid/reference/ts/src/engine/queue.ts +++ b/course/10-spellgrid/reference/ts/src/engine/queue.ts @@ -75,8 +75,8 @@ export class Ctx { /** 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. + * The source's board position. For a Deathrattle, the source has left play, and + * this is where it stood: immediately left of the minion its Death Event remembers. */ readonly position?: number, ) {} @@ -219,12 +219,26 @@ function react(world: World, events: Event[]): void { 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); + const position = + event.type === "Destroyed" && event.id === source + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + /** * 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. diff --git a/course/10-spellgrid/reference/ts/src/engine/world.ts b/course/10-spellgrid/reference/ts/src/engine/world.ts index daedb91..8e33878 100644 --- a/course/10-spellgrid/reference/ts/src/engine/world.ts +++ b/course/10-spellgrid/reference/ts/src/engine/world.ts @@ -308,10 +308,10 @@ export class World { } /** Move a minion from play to its owner's graveyard, recording its Death Event. */ - kill(id: EntityId, position: number): void { + kill(id: EntityId, before: EntityId | undefined): void { this.entity(id).pendingDestroy = false; this.moveTo(id, "graveyard"); - this.record({ type: "Destroyed", id, position }); + this.record({ type: "Destroyed", id, before }); } gainArmor(player: PlayerId, amount: number): void {