fix(reference): Death Events remember a right-hand neighbour, not a board index

A Deathrattle summon used the dying minion's board index from before the
Death Creation Step removed anything. That index is stale when a minion to
its left dies in the same step, so the token landed one place too far right.

Each Death Event now records `before`: the nearest minion to the right that
is not dying in the same step. The position handed to the effect context is
resolved when the trigger resolves: the current index of `before`, or the
right end. Applied to the Rust and TS references of Modules 3-10.

Module 3 gains two tests, documented in scenarios.md:
test_03_summon_position_after_simultaneous_deaths (fails before the fix) and
test_03_simultaneous_summons_keep_board_order.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 09:15:48 +01:00
1 parent 638549161f
commit 429137a6ef
79 files changed
+808 -236

No files matched your search

@@ -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<Event> {
// 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<EntityId> {
world
.zone(world.owner_of(id), Zone::Board)
.iter()
.copied()
.skip_while(|&other| other != id)
.skip(1)
.find(|&other| !world.is_doomed(other))
}
@@ -82,7 +82,7 @@ fn targets(target: Target, ctx: &Ctx) -> Vec<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.
fn summon_position(ctx: &Ctx) -> Option<usize> {
match ctx.source.and_then(|s| ctx.world.board_index(s)) {
Some(index) => Some(index + 1),
@@ -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<EntityId>,
},
DeathPhase {
round: u32,
@@ -86,8 +86,8 @@ pub struct Ctx<'a> {
pub source: Option<EntityId>,
/// The chosen target, if any.
pub target: Option<EntityId>,
/// The source's board position. For a Deathrattle, the source has left play,
/// and this is the position its Death Event remembered.
/// 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<usize>,
}
@@ -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<EntityId>) -> 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<EntityId> {
@@ -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<EntityId>) {
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) {
@@ -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));
}
@@ -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);
@@ -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 };
@@ -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.
@@ -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 {