diff --git a/README.md b/README.md index 0e286e6..7f76f9e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Builder path exercises exist in **Rust** and **TypeScript**. Both tracks have id From the course repository: ```sh -node --version # Node 22 or later for the course tooling (CI uses 24) +node --version # Node 22.15 or later for the course tooling (CI uses 24) npm run test:tools # tooling tests npm run check # test names agree across Rust, TS and scenarios ``` @@ -42,7 +42,7 @@ node tools/start-module.mjs 01 ~/my-engine `start-module` copies a module's tests for your track (from your workspace's `.course.json`) and refuses to start a module if you have not yet taken the previous one. Module 00 uses the `naive-engine/` project and is not started this way, and Module 13 has no tests to start. -Other tools: `tools/coupling-audit.mjs ` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs ` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests; `--expansion` also checks every Expansion Drop solution), `tools/play-spellgrid.mjs [l1..l10]` (play the Module 10 capstone, SpellGrid, on the command line with the reference engine), `tools/solve-spellgrid.mjs [l1..l10|level.json]` (search a SpellGrid level for its shortest solution by brute force; `--never-rule "X IS Y"` asks whether it can be solved without a given rule; see Module 13), `tools/verify-levels.mjs [level ...]` (checks every claim in `course/10-spellgrid/levels/proofs.json` with the solver: each level's shortest solution, and that it can't be solved without the rule its proof forbids; `npm run verify:levels`), `tools/diff-tracks.mjs [--seeds N] [--seed S] [--module NN] [--dir D] [--keep]` (differential fuzzer: plays random SpellGrid levels and scripts on the Rust and the TypeScript reference CLIs and fails on the first seed where they print different text or one of them crashes; `npm run diff:tracks`), `tools/mutation-check.mjs [name ...]` (applies each mutant in `tools/mutants.json`, a one-line change to an ordering decision, to the highest module's TypeScript reference, runs every module's tests, and fails if a mutant survives or no longer applies; see Module 13; `npm run mutation:check`), `tools/run-scenario.mjs ` (plays a designer's scenario, new cards, words and seam words as data with a level and inputs, on the Module 10 TypeScript reference and prints the board, rules, minions, hand, heroes' Health and log tree after each input; `npm run run:scenario`). +Other tools: `tools/coupling-audit.mjs ` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs ` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests; `--expansion` also checks every Expansion Drop solution), `tools/play-spellgrid.mjs [l1..l10]` (play the Module 10 capstone, SpellGrid, on the command line with the reference engine), `tools/solve-spellgrid.mjs [l1..l10|level.json]` (search a SpellGrid level for its shortest solution by brute force; `--never-rule "X IS Y"` asks whether it can be solved without a given rule; see Module 13), `tools/verify-levels.mjs [level ...]` (checks every claim in `course/10-spellgrid/levels/proofs.json` with the solver: each level's shortest solution, and that it can't be solved without the rule its proof forbids; `npm run verify:levels`), `tools/diff-tracks.mjs [--seeds N] [--seed S] [--module NN] [--dir D] [--keep]` (differential fuzzer: plays random SpellGrid levels and scripts on the Rust and the TypeScript reference CLIs and fails on the first seed where they print different text or one of them crashes; `npm run diff:tracks`), `tools/mutation-check.mjs [name ...]` (applies each mutant in `tools/mutants.json`, a small change (one to three lines) to one of the engine's decisions, nearly all of them about order, to the highest module's TypeScript reference, runs every module's tests, and fails if a mutant survives or no longer applies; see Module 13; `npm run mutation:check`), `tools/run-scenario.mjs ` (plays a designer's scenario, new cards, words and seam words as data with a level and inputs, on the Module 10 TypeScript reference and prints the board, rules, minions, hand, heroes' Health and log tree after each input; an engine error in the middle of a Sequence ends the run with exit code 1, and a run longer than 60 s is stopped; `npm run run:scenario`). ## Licence diff --git a/course/02-action-queue/lesson.md b/course/02-action-queue/lesson.md index 5b6d1fb..139a029 100644 --- a/course/02-action-queue/lesson.md +++ b/course/02-action-queue/lesson.md @@ -349,7 +349,7 @@ It never stops on its own. The Bomb's trigger damages the Bomb, which queues the Depth-first resolution by plain recursion is the right model and it has this cost: nothing bounds the depth. Two real games answer in two different ways. -- **Forbid it by rule.** The Advanced rulebook: "A trigger cannot be nested inside itself" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). In engine terms: keep the set of (source, trigger) pairs that are resolving right now, and don't queue an entry that is already in it. Try it. With the guard, the Bomb damages everything once and the Sequence ends: its trigger is on the stack when its own damage lands, so it isn't queued again. +- **Forbid it by rule.** The Advanced rulebook: "It seems that a trigger cannot be nested inside itself." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Its next sentence adds that the game "works the skipped times as a compensation after all other consequences of it are resolved" (same source); the course's guard, in Module 5, drops them instead, which is the course's choice. In engine terms: keep the set of (source, trigger) pairs that are resolving right now, and don't queue an entry that is already in it. Try it. With the guard, the Bomb damages everything once and the Sequence ends: its trigger is on the stack when its own damage lands, so it isn't queued again. - **Allow it and name the outcome.** Magic: The Gathering has no such guard. Rule 104.4b: "If a game ... somehow enters a 'loop' of mandatory actions, repeating a sequence of events with no way to stop, the game is a draw." ([Comprehensive Rules](https://magic.wizards.com/en/rules)). An engine for that game has to *detect* the loop, which is harder than preventing it. Either way the decision belongs in the engine's one resolution loop, and it is a rule of the game, not an implementation detail: two Bombs under the guard still set each other off, six activations in all, and a player can see that. Work out the six before you check them in Module 5's scenario. diff --git a/course/02-action-queue/reference/rust/src/engine/queue.rs b/course/02-action-queue/reference/rust/src/engine/queue.rs index 8833185..60f3010 100644 --- a/course/02-action-queue/reference/rust/src/engine/queue.rs +++ b/course/02-action-queue/reference/rust/src/engine/queue.rs @@ -5,6 +5,8 @@ //! is locked. Each queued trigger then resolves completely, including everything //! its own actions cause, before the next one starts: depth-first. +use std::panic::{self, AssertUnwindSafe}; + use super::card::TriggerOn; use super::event::Event; use super::world::{EntityId, PlayerId, World}; @@ -130,13 +132,19 @@ fn react(world: &mut World, events: &[Event]) { target: None, }; let actions = (trigger.effect)(&ctx); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an action panics, so a caught panic leaves the depth as it found it. world.open_block(); - // Depth-first: each action, and every reaction it causes, finishes before the next. - for action in actions { - perform(world, action); - } + let result = panic::catch_unwind(AssertUnwindSafe(|| { + // Depth-first: each action, and every reaction it causes, finishes before the next. + for action in actions { + perform(world, action); + } + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } diff --git a/course/02-action-queue/reference/ts/src/engine/queue.ts b/course/02-action-queue/reference/ts/src/engine/queue.ts index 1ba2899..f493bb2 100644 --- a/course/02-action-queue/reference/ts/src/engine/queue.ts +++ b/course/02-action-queue/reference/ts/src/engine/queue.ts @@ -94,11 +94,15 @@ function react(world: World, events: Event[]): void { for (const [source, trigger] of queue) { world.record({ type: "Triggered", source, on: trigger.on }); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source)); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an action throws, so an error leaves the depth as it found it. world.openBlock(); - // Depth-first: each action, and every reaction it causes, finishes before the next. - for (const action of actions) perform(world, action); - world.closeBlock(); + try { + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } finally { + world.closeBlock(); + } } } diff --git a/course/03-death-step/lesson.md b/course/03-death-step/lesson.md index 2537c14..7779e83 100644 --- a/course/03-death-step/lesson.md +++ b/course/03-death-step/lesson.md @@ -241,7 +241,7 @@ Three decisions in the reference are worth naming, because the rulebook raises t - **The remembered position is a handle.** A Death Event holds `before`, the id of the nearest surviving minion to the right, and `react` turns it into an index when the Deathrattle resolves. Tokens from the same gap line up left to right in the order they are summoned, which is the order their Deathrattles resolve: order of play. The rulebook says only that the position is "remembered"; it doesn't say how, and it doesn't cover two dying neighbours whose order of play is the reverse of their board order. In that case the reference puts the older minion's token on the left. That is the course's choice. - **A queued trigger whose source has left play.** In this engine that cannot happen to anything but a Deathrattle. Nothing leaves play mid-Phase, and the Death Creation Step runs only after the outermost Phase, when every Queue has been emptied. Deathrattles are the deliberate exception: their source has always left play by the time they resolve, and they resolve anyway. A minion that is only mortally wounded is still in play and still triggers (Module 2's Acolyte). The course's sources don't say what happens to a queued trigger whose source is removed some other way mid-Phase. No card in this course does that, so the engine doesn't re-check a source before resolving its trigger. That is a course simplification. -- **"A trigger cannot be nested inside itself"** ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The references of Modules 3 and 4 have no guard for this; it arrives in [Module 5](../05-assemble/lesson.md#the-harness). No card in this module can nest a trigger inside itself: a Ghoul whose Deathrattle kills another Ghoul doesn't nest anything, because the second Ghoul's Deathrattle waits for the next Death Phase. Two Module 2-style cards that deal damage whenever a minion takes damage *would* recurse forever, and the rule is the cure. Add it when a card needs it: in the course, that is Module 5, where a test can write such a card as data. +- **"It seems that a trigger cannot be nested inside itself."** ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The rulebook goes on: the game "works the skipped times as a compensation after all other consequences of it are resolved". The course's guard drops the skipped times; that is the course's choice (see [Module 5](../05-assemble/lesson.md#the-harness)). The references of Modules 3 and 4 have no guard for this; it arrives in [Module 5](../05-assemble/lesson.md#the-harness). No card in this module can nest a trigger inside itself: a Ghoul whose Deathrattle kills another Ghoul doesn't nest anything, because the second Ghoul's Deathrattle waits for the next Death Phase. Two Module 2-style cards that deal damage whenever a minion takes damage *would* recurse forever, and the rule is the cure. Add it when a card needs it: in the course, that is Module 5, where a test can write such a card as data. > **How the real game differs** > - The course treats each player action (a `dealDamage`, a `cast`, from Module 4 a `play`) as one outermost Phase, and processes deaths once, after the whole action. In the real game "A Sequence is what begins when a Player Action is taken" and a Sequence is made of Phases, and "death processing is not done in the middle of a Phase, but is done between Phases" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). So one player action can have deaths processed more than once, between its Phases. The course does not model which Phases each action has; none of the course's cards can tell the difference. 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 376e383..326408c 100644 --- a/course/03-death-step/reference/rust/src/engine/queue.rs +++ b/course/03-death-step/reference/rust/src/engine/queue.rs @@ -9,6 +9,8 @@ //! Death Creation Step removes the dead, and a Death Phase resolves their //! Deathrattles. That repeats until a step finds no new deaths. +use std::panic::{self, AssertUnwindSafe}; + use super::card::TriggerOn; use super::death::death_creation_step; use super::event::Event; @@ -180,13 +182,19 @@ fn react(world: &mut World, events: &[Event]) { position, }; let actions = (trigger.effect)(&ctx); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an action panics, so a caught panic leaves the depth as it found it. world.open_block(); - // Depth-first: each action, and every reaction it causes, finishes before the next. - for action in actions { - perform(world, action); - } + let result = panic::catch_unwind(AssertUnwindSafe(|| { + // Depth-first: each action, and every reaction it causes, finishes before the next. + for action in actions { + perform(world, action); + } + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } 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 1980cf0..2656ed9 100644 --- a/course/03-death-step/reference/ts/src/engine/queue.ts +++ b/course/03-death-step/reference/ts/src/engine/queue.ts @@ -134,11 +134,15 @@ function react(world: World, events: Event[]): void { ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an action throws, so an error leaves the depth as it found it. world.openBlock(); - // Depth-first: each action, and every reaction it causes, finishes before the next. - for (const action of actions) perform(world, action); - world.closeBlock(); + try { + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } finally { + world.closeBlock(); + } } } diff --git a/course/04-interceptors/lesson.md b/course/04-interceptors/lesson.md index 57b2676..84d42cc 100644 --- a/course/04-interceptors/lesson.md +++ b/course/04-interceptors/lesson.md @@ -349,7 +349,7 @@ The four tests (full statements in `scenarios.md`): - `test_04_armor_absorbs_before_health`: Armorsmith and Test Reactor, 2 Armor. Damage of 1 to the hero: `ArmorLost` 1, `Damaged` 1, and Reactor still grows. Then damage of 5: `ArmorLost` 1, `Damaged` 5, Health 26. - `test_04_redirect_replaces_hero_damage`: Bolf, Armorsmith, Test Reactor; 4 damage to the hero. Exact log: `Damaged(Bolf, 4)`, then Armorsmith's trigger. The hero is untouched and Reactor doesn't trigger. -- `test_04_redirect_applies_once`: two Bolfs. The older one takes all 3; the other is untouched. +- `test_04_redirect_applies_once`: two Bolfs. The older one takes all 3; the other is untouched (oldest wins). - `test_04_redirect_before_armor`: 1 Armor and a Bolf; 2 damage to the hero. Exact log: `Damaged(Bolf, 2)`. No `ArmorLost`, and the Armor is still there. Make the four tests pass, with the same rules as before: Bolf is data in `src/content/`, and nothing in `src/engine/` names him. @@ -358,7 +358,7 @@ Make the four tests pass, with the same rules as before: Bolf is data in `src/co - **One place.** Find the line in `perform` that applies damage to one target. Everything below happens there, before the world changes, so no trigger is involved. - **The snapshot needs the minion.** A trigger multiplier only needed the owner of the minion that has it. A redirect needs the minion itself. Store it next to each aura in force. -- **Each step once.** Walk the list once per Damage. The rewritten Damage is never offered to the redirect again, so two Bolfs can't pass the damage back and forth. That is Magic's guard, quoted in Name it: a replacement effect "gets only one opportunity to affect an event". +- **Each step once.** Walk the list once per Damage. The rewritten Damage is never offered to the redirect again, so two Bolfs can't pass the damage back and forth. That is Magic's guard, quoted in Name it: a replacement effect "gets only one opportunity to affect an event". `test_04_redirect_applies_once` doesn't test this guard: it pins that the oldest Bolf wins. With this module's vocabulary the guard can't be observed, because a redirect only takes damage meant for a hero, and the rewritten Damage is aimed at a minion, so no redirect would match it again anyway. @@ -425,7 +425,7 @@ function applyDamage(world: World, target: EntityId, amount: number): Event { Decisions in the reference that the tests don't force: -- **The rewritten Damage keeps its place.** Area damage creates its Damage Events in order of play of the original targets. A redirected one stays where the hero's would have been. +- **The rewritten Damage keeps its place.** Area damage creates its Damage Events in order of play of the original targets. A redirected one stays where the hero's would have been. So area damage is ordered by intended target, not by who takes it: with Bolf in play, Dread Infernal's Battlecry records Bolf's redirected `Damaged` in the hero's place, ahead of the other minions', although Bolf is the youngest minion. - **Any damage to the hero is redirected**, from either player's cards, since the card text doesn't say whose. > **How the real game differs** diff --git a/course/04-interceptors/reference/rust/src/engine/queue.rs b/course/04-interceptors/reference/rust/src/engine/queue.rs index 0c69985..b860984 100644 --- a/course/04-interceptors/reference/rust/src/engine/queue.rs +++ b/course/04-interceptors/reference/rust/src/engine/queue.rs @@ -17,6 +17,8 @@ //! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes //! who takes it, then Armor takes what it can of damage to a hero. +use std::panic::{self, AssertUnwindSafe}; + use super::aura::{activations, aura_update_other, redirect_for}; use super::card::TriggerOn; use super::death::death_creation_step; @@ -144,7 +146,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -266,13 +269,19 @@ fn react(world: &mut World, events: &[Event]) { position, }; let actions = (trigger.effect)(&ctx); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an action panics, so a caught panic leaves the depth as it found it. world.open_block(); - // Depth-first: each action, and every reaction it causes, finishes before the next. - for action in actions { - perform(world, action); - } + let result = panic::catch_unwind(AssertUnwindSafe(|| { + // Depth-first: each action, and every reaction it causes, finishes before the next. + for action in actions { + perform(world, action); + } + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } diff --git a/course/04-interceptors/reference/ts/src/engine/queue.ts b/course/04-interceptors/reference/ts/src/engine/queue.ts index 17a4583..9ed5d45 100644 --- a/course/04-interceptors/reference/ts/src/engine/queue.ts +++ b/course/04-interceptors/reference/ts/src/engine/queue.ts @@ -117,7 +117,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -207,11 +208,15 @@ function react(world: World, events: Event[]): void { ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an action throws, so an error leaves the depth as it found it. world.openBlock(); - // Depth-first: each action, and every reaction it causes, finishes before the next. - for (const action of actions) perform(world, action); - world.closeBlock(); + try { + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } finally { + world.closeBlock(); + } } } } diff --git a/course/04-interceptors/scenarios.md b/course/04-interceptors/scenarios.md index 01f2db1..a2cd503 100644 --- a/course/04-interceptors/scenarios.md +++ b/course/04-interceptors/scenarios.md @@ -94,7 +94,7 @@ The last four tests are about a second interceptor, replacement. Applying a Dama - Setup: Summon `Bolf Ramshield` (id B1), then another `Bolf Ramshield` (id B2). Let H be `hero(0)`. - Action: `dealDamage(H, 3)`. - Assertions: - - The log is exactly `Damaged{target: B1, amount: 3}`. The older Bolf takes all of it; the rewritten Damage is not offered to B2. + - The log is exactly `Damaged{target: B1, amount: 3}`. The older Bolf takes all of it (oldest wins). B2 is untouched: the rewritten Damage is aimed at B1, and a redirect only takes damage meant for a hero, so this test can't tell whether it is offered to B2 again. - `health(B1)` is 6, `health(B2)` is 9, `health(H)` is 30. ### test_04_redirect_before_armor diff --git a/course/05-assemble/lesson.md b/course/05-assemble/lesson.md index 14c0abc..b14650a 100644 --- a/course/05-assemble/lesson.md +++ b/course/05-assemble/lesson.md @@ -105,7 +105,7 @@ A card is a name, a type (`minion` or `spell`), a cost, Attack and Health for mi Once a test can write any card as data, a test can also write a card that never stops. This module therefore adds three rules about a Sequence: -- **No trigger nests inside itself.** A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. This is Hearthstone's rule as the Advanced rulebook reads it: "It seems that a trigger cannot be nested inside itself." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Module 2's [Stress it](../02-action-queue/lesson.md#stress-it) question 1 asked about it. +- **No trigger nests inside itself.** A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. The Advanced rulebook: "It seems that a trigger cannot be nested inside itself." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course stops there and drops the skipped activations: that is the course's choice, and the real game does more (see "How the real game differs" below), so the counts in `test_05_trigger_does_not_nest_in_itself` are the course's, not the game's. Module 2's [Stress it](../02-action-queue/lesson.md#stress-it) question 1 asked about it. - **At most 64 Death Phases.** More than 64 Death Phases in one Sequence is an error: TypeScript throws an `Error` and Rust panics, with a message containing `Death Phase limit`. The rulebook says the Death Phase cycle "repeats forever until no new Deaths occur" (same source); it names no limit. The limit, and its value, are the course's choice. Module 3's [Stress it](../03-death-step/lesson.md#stress-it) question 1 asked about it. - **Several tokens stand in summon order.** A `summon` effect with `count` 2 or more places its tokens left to right, each immediately right of the one before. @@ -315,6 +315,7 @@ Decisions in the reference that the tests don't force: > - The real pool is about 250 curated minions "whose effects can be split in half" (paraphrase, same source). The course's pool is every minion with text, and every card's data says where its half ends. > - The wiki doesn't define "half" (same source). The course's `split` is a definition, not a claim about the real game. > - The real Assembled minion also keeps its art, rarity and minion type (same source). The course's cards have none of these. +> - The nesting guard. Right after "It seems that a trigger cannot be nested inside itself.", the Advanced rulebook says: "Instead it works the skipped times as a compensation after all other consequences of it are resolved." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook), which points to its "Trigger Re-entrancy Exploration"). The course's guard drops the skipped activations instead of running them afterwards. That is simpler and still ends, but it is the course's choice. ## Name it diff --git a/course/05-assemble/reference/rust/src/engine/queue.rs b/course/05-assemble/reference/rust/src/engine/queue.rs index 4607606..37e6956 100644 --- a/course/05-assemble/reference/rust/src/engine/queue.rs +++ b/course/05-assemble/reference/rust/src/engine/queue.rs @@ -26,6 +26,8 @@ //! minion is resolving (the nesting guard in `react`), and a Sequence that needs more //! than `DEATH_PHASE_LIMIT` Death Phases is an error. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -175,7 +177,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -317,11 +320,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } diff --git a/course/05-assemble/reference/ts/src/engine/queue.ts b/course/05-assemble/reference/ts/src/engine/queue.ts index 4694ec3..cdf12cb 100644 --- a/course/05-assemble/reference/ts/src/engine/queue.ts +++ b/course/05-assemble/reference/ts/src/engine/queue.ts @@ -142,7 +142,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -252,10 +253,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } diff --git a/course/05-assemble/scenarios.md b/course/05-assemble/scenarios.md index 6a7940f..d787d2a 100644 --- a/course/05-assemble/scenarios.md +++ b/course/05-assemble/scenarios.md @@ -122,7 +122,7 @@ The test registers a one-off card with `registerCards`, written as data in the t ### test_05_trigger_does_not_nest_in_itself -The test registers a one-off card with `registerCards`, written as data in the test: `Test Bomb`, a 1/50 minion, "Whenever a minion takes damage, deal 1 damage to all minions." (`split` 1; an `anyMinionDamaged` trigger clause, then a `dealDamage` effect clause, target `allMinions`, amount 1). Without a guard, its trigger queues itself inside itself forever. The rule (Hearthstone's Advanced rulebook, see Module 2's Stress it question 1): a trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. +The test registers a one-off card with `registerCards`, written as data in the test: `Test Bomb`, a 1/50 minion, "Whenever a minion takes damage, deal 1 damage to all minions." (`split` 1; an `anyMinionDamaged` trigger clause, then a `dealDamage` effect clause, target `allMinions`, amount 1). Without a guard, its trigger queues itself inside itself forever. The rule (from Hearthstone's Advanced rulebook, see Module 2's Stress it question 1): a trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. The rulebook adds that the game runs the skipped times afterwards; the course drops them (its choice, see Lesson 5's "How the real game differs"), so the counts below are the course's. - Setup: `newGame()`. Register `Test Bomb`. Summon two (ids B1, then B2). Call `clearLog()`. - Action: `dealDamage(B1, 1)`. diff --git a/course/06-parser/reference/rust/src/engine/queue.rs b/course/06-parser/reference/rust/src/engine/queue.rs index 4607606..37e6956 100644 --- a/course/06-parser/reference/rust/src/engine/queue.rs +++ b/course/06-parser/reference/rust/src/engine/queue.rs @@ -26,6 +26,8 @@ //! minion is resolving (the nesting guard in `react`), and a Sequence that needs more //! than `DEATH_PHASE_LIMIT` Death Phases is an error. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -175,7 +177,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -317,11 +320,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } diff --git a/course/06-parser/reference/ts/src/engine/queue.ts b/course/06-parser/reference/ts/src/engine/queue.ts index 4694ec3..cdf12cb 100644 --- a/course/06-parser/reference/ts/src/engine/queue.ts +++ b/course/06-parser/reference/ts/src/engine/queue.ts @@ -142,7 +142,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -252,10 +253,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } 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 4607606..37e6956 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/queue.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/queue.rs @@ -26,6 +26,8 @@ //! minion is resolving (the nesting guard in `react`), and a Sequence that needs more //! than `DEATH_PHASE_LIMIT` Death Phases is an error. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -175,7 +177,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -317,11 +320,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } 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 4694ec3..cdf12cb 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/queue.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/queue.ts @@ -142,7 +142,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -252,10 +253,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } 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 4607606..37e6956 100644 --- a/course/08-push-chains/reference/rust/src/engine/queue.rs +++ b/course/08-push-chains/reference/rust/src/engine/queue.rs @@ -26,6 +26,8 @@ //! minion is resolving (the nesting guard in `react`), and a Sequence that needs more //! than `DEATH_PHASE_LIMIT` Death Phases is an error. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -175,7 +177,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -317,11 +320,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } 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 4694ec3..cdf12cb 100644 --- a/course/08-push-chains/reference/ts/src/engine/queue.ts +++ b/course/08-push-chains/reference/ts/src/engine/queue.ts @@ -142,7 +142,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -252,10 +253,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } diff --git a/course/09-turn-pipeline/lesson.md b/course/09-turn-pipeline/lesson.md index ec5a810..823a9f5 100644 --- a/course/09-turn-pipeline/lesson.md +++ b/course/09-turn-pipeline/lesson.md @@ -461,7 +461,7 @@ With this module's sixteen tests, each of those changes fails at least one test, |---|---| | No parse between movement and conversion | `test_09_mid_turn_conversion` | | No parse between conversion and hazards | `test_09_conversion_reparses_before_hazards` | -| Hazards before conversion | `test_09_conversion_before_hazards`, `test_09_conversion_reparses_before_hazards` | +| Hazards before conversion | `test_09_conversion_before_hazards`, `test_09_conversion_reparses_before_hazards`, `test_09_undo_restores_everything` | | Win check before hazards | `test_09_defeat_beats_win` | | No last parse | `test_09_last_parse_matches_board` | | DEFEAT before HOT/MELT | `test_09_hazard_order_melt_before_defeat` | 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 4607606..37e6956 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs @@ -26,6 +26,8 @@ //! minion is resolving (the nesting guard in `react`), and a Sequence that needs more //! than `DEATH_PHASE_LIMIT` Death Phases is an error. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -175,7 +177,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -317,11 +320,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } 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 4694ec3..cdf12cb 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts @@ -142,7 +142,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -252,10 +253,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } diff --git a/course/10-spellgrid/lesson.md b/course/10-spellgrid/lesson.md index 8a8b21a..d03778c 100644 --- a/course/10-spellgrid/lesson.md +++ b/course/10-spellgrid/lesson.md @@ -352,7 +352,7 @@ node tools/play-spellgrid.mjs rust l1 # Rust: cargo run --bin spellgrid -- node tools/play-spellgrid.mjs ts l1 # TypeScript: npx tsx src/cli.ts levels/l1-double-tap.json ``` -Or play in a browser: the course website's [Play SpellGrid](../../site/src/pages/play.astro) page has all ten levels. It runs the course's newest TypeScript reference, copied unmodified, with the course's cards and these levels; the page only draws the grid and turns clicks and keys into harness calls (`step`, `cast`, `play`, the chooser). It shows the rules in force, each minion's Health and the log tree of your last input. SpellGrid has no `undo` (see [Module 13](../13-break-your-engine/lesson.md#state-undo-and-replay)), so the page's Undo replays every input but the last on a fresh level. +Or play in a browser: the course website's [Play SpellGrid](../../site/src/pages/play.astro) page has all ten levels. It runs the course's newest TypeScript reference, copied unmodified, with the course's cards and these levels; the page only draws the grid and turns clicks and keys into harness calls (`step`, `cast`, `play`, the chooser). It shows the rules in force, each minion's Health and the log tree of your last input. SpellGrid has no `undo` (see [Module 13](../13-break-your-engine/lesson.md#state-undo-and-replay)), so the page's Undo replays every input but the last on a fresh level. The newest reference is a later module's (Module 12's today), not this one's. On these ten levels it plays the same game: none of them writes Module 11's conditions, SpellGrid has no input for Module 12's combat, and every later reference passes this module's level tests. On the command line, the level is `l1` to `l10` (or a path to a level file of your own). `--dir ` stages into a directory you choose and keeps it, and `--stage-only` prints the command without running it. The tool prints where it staged, so you can run the command there again. @@ -542,7 +542,7 @@ What it reports for the shipped levels: - **Levels 4 to 10** were designed against the solver: each was changed until its shortest solution was the intended one and the search, with one rule forbidden, ran out of positions. The forbidden rule is one the player has to form: `DEATHRATTLE IS DOUBLE` (level 4), `KEG IS NOT KEG` (5), `LAVA IS HOT` (6), `SHARD IS OPEN` (7), `SHARD IS WIN` (8), `SKULL IS SINK` (9), `KNIGHT IS YOU` (10). For levels 4, 5, 7 and 10 that rule is the level's idea. For levels 6, 8 and 9 it is only one ingredient of it: the search proves that the lava has to be `HOT` (6), that `SHARD IS WIN` has to be finished (8), and that the skulls have to be `SINK` (9). It doesn't prove the rest of the idea: that the keg melts before the shard's `DEFEAT` is checked, that the shard comes from Graft's top-half swap, that the two rocks move as one chain. -Each of these claims is written down in `course/10-spellgrid/levels/proofs.json`, one entry per level (`{ "level": "l4-crossed-out", "shortest": 7, "neverRule": "DEATHRATTLE IS DOUBLE" }`, or `"naiveUnsolvable": true` for level 3), and `node tools/verify-levels.mjs` (`npm run verify:levels`) checks every one with the solver. A forbidden rule is the closest a rule filter gets to "the idea". For level 6 the map does the rest, because the only push that sends the keg into the lava puts the knight on the shard. For levels 8 and 9 the rest is shown, not proved: each level's test plays one line that does it, and checks on the way that it happened. +Each of these claims is written down in `course/10-spellgrid/levels/proofs.json`, one entry per level (`{ "level": "l4-crossed-out", "shortest": 7, "neverRule": "DEATHRATTLE IS DOUBLE" }`, or `"naiveUnsolvable": true` for level 3), and `node tools/verify-levels.mjs` (`npm run verify:levels`) checks every one with the solver. The solver and `verify-levels`, like `play-spellgrid` and `run-scenario`, stage this module's reference, so the proofs are about the Module 10 engine. The play page, `diff-tracks` and `mutation-check` use the newest reference. For these levels that is the same game today, for the reasons given in [Play it](#play-it); a later module that changed it would need the proofs checked on its engine. A forbidden rule is the closest a rule filter gets to "the idea". For level 6 the map does the rest, because the only push that sends the keg into the lava puts the knight on the shard. For levels 8 and 9 the rest is shown, not proved: each level's test plays one line that does it, and checks on the way that it happened. An unintended solution is called a *cook*, and a level with one doesn't teach what it was built to teach. [Module 13](../13-break-your-engine/lesson.md) explains how the solver works and what it can't tell you. Then design an eleventh level, and try to cook it before the tool does. diff --git a/course/10-spellgrid/reference/rust/src/cli.rs b/course/10-spellgrid/reference/rust/src/cli.rs index 2e0e5b3..3e7adf7 100644 --- a/course/10-spellgrid/reference/rust/src/cli.rs +++ b/course/10-spellgrid/reference/rust/src/cli.rs @@ -15,9 +15,10 @@ use std::cell::RefCell; use std::io::{BufRead, Write}; +use std::panic::{self, AssertUnwindSafe}; use std::rc::Rc; -use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid}; +use crate::harness::{new_spell_grid, CardDef, Dir, Id, LevelDef, SpellGrid}; /// How a game ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +26,39 @@ pub enum Outcome { Won, Lost, Quit, + /// The engine failed in the middle of a Sequence (see `carry_out`). + Failed, +} + +/// What the CLI prints after an engine error, below the error's message. +pub const UNFINISHED: &str = "The Sequence did not finish, so the game cannot go on."; + +/// A command the rules allow, checked and ready to run. +enum Command { + Step(Dir), + Cast(String, Option), + Play(String, i32, i32), +} + +/// Run `command`. A panic inside it is caught and returned as its message. The panic hook is +/// silenced meanwhile, so the message is printed once, by the CLI, on its own output. +fn carry_out(g: &mut SpellGrid, command: Command) -> Result<(), String> { + let hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let result = panic::catch_unwind(AssertUnwindSafe(|| match command { + Command::Step(dir) => g.step(dir), + Command::Cast(card, target) => g.cast(&card, target), + Command::Play(card, x, y) => { + g.play(&card, x, y); + } + })); + panic::set_hook(hook); + result.map_err(|cause| match cause.downcast::() { + Ok(message) => *message, + Err(cause) => cause + .downcast_ref::<&str>() + .map_or_else(|| "the engine panicked".to_string(), |s| s.to_string()), + }) } /// The player's input and the screen, shared with the chooser. @@ -115,9 +149,19 @@ fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) { /// Play `level_text` (a level in the course's format), reading commands from `input` /// and writing to `output`. pub fn run(level_text: &str, input: Box, output: Box) -> Outcome { + run_with_cards(level_text, vec![], input, output) +} + +/// `run`, with `extra` cards added to the ones in cards/ (as `new_spell_grid` takes them). +pub fn run_with_cards( + level_text: &str, + extra: Vec, + input: Box, + output: Box, +) -> Outcome { let level: LevelDef = serde_json::from_str(level_text).expect("a level in the course's level format"); - let mut g = new_spell_grid(level.clone(), vec![]); + let mut g = new_spell_grid(level.clone(), extra); let io = Rc::new(RefCell::new(Io { input, output })); let chooser_io = Rc::clone(&io); g.set_chooser(move |kind, options| { @@ -172,25 +216,36 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc>) -> Outcome { }; // The harness panics on a move the rules don't allow (a card not in the hand, a // full tile). The CLI asks first, and reports the reason instead. - let done = match (dir, words.as_slice()) { - (Some(dir), _) => Ok(g.step(dir)), + let command = match (dir, words.as_slice()) { + (Some(dir), _) => Ok(Command::Step(dir)), (None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) { - (card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)), + (card, None) => g + .check_cast(&card, None) + .map(|()| Command::Cast(card, None)), (card, Some((x, y))) => match g.minion_at(x, y) { Some(target) => g .check_cast(&card, Some(target)) - .map(|()| g.cast(&card, Some(target))), - None => g.check_play(&card, x, y).map(|()| { - g.play(&card, x, y); - }), + .map(|()| Command::Cast(card, Some(target))), + None => g + .check_play(&card, x, y) + .map(|()| Command::Play(card, x, y)), }, }, _ => { Err("Commands: w a s d (move), c [x y] (cast or play), q (quit)".to_string()) } }; - if let Err(message) = done { - io.borrow_mut().write(&format!("{message}\n")); + match command { + Err(message) => io.borrow_mut().write(&format!("{message}\n")), + // Any other panic is the engine's (a card that names no card, a Sequence past the + // Death Phase limit): the Sequence stopped part-way, so the game is not a game any + // more. Say so and stop; its log is not printed, as it is not a whole Sequence. + Ok(command) => { + if let Err(message) = carry_out(g, command) { + io.borrow_mut().write(&format!("{message}\n{UNFINISHED}\n")); + return Outcome::Failed; + } + } } let tree = g.log_tree(); if !tree.is_empty() { diff --git a/course/10-spellgrid/reference/rust/src/engine/queue.rs b/course/10-spellgrid/reference/rust/src/engine/queue.rs index fcb0e09..4f4cd1e 100644 --- a/course/10-spellgrid/reference/rust/src/engine/queue.rs +++ b/course/10-spellgrid/reference/rust/src/engine/queue.rs @@ -30,6 +30,8 @@ //! read from the world, as Part II's turn is a list of phases. SpellGrid's tests run //! the same Sequence with the list reversed, to show what the order decides. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -203,7 +205,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -346,11 +349,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } diff --git a/course/10-spellgrid/reference/rust/tests/cli.rs b/course/10-spellgrid/reference/rust/tests/cli.rs index cf8d0f7..7377604 100644 --- a/course/10-spellgrid/reference/rust/tests/cli.rs +++ b/course/10-spellgrid/reference/rust/tests/cli.rs @@ -3,7 +3,8 @@ use std::cell::RefCell; use std::io::{Cursor, Write}; use std::rc::Rc; -use engine::cli::{run, Outcome}; +use engine::cli::{run, run_with_cards, Outcome}; +use engine::harness::{new_spell_grid, CardDef, LevelDef}; /// Collects what the CLI prints. #[derive(Clone, Default)] @@ -111,3 +112,69 @@ fn cli_numbers_are_plain_digits() { let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]); assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}"); } + +// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). +const ZAP: &str = r#"{"name": "Zap", "type": "spell", "cost": 1, "split": 0, + "text": [{"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; +// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. +const LOOP_EMBER: &str = r#"{"name": "Loop Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, + "noun": "ROCK", "split": 1, "text": [{"kind": "trigger", "on": "deathrattle"}, + {"kind": "effect", "effect": {"type": "summon", "card": "Loop Ember", "count": 1}}, + {"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; +// Its trigger names a card that does not exist, so it fails part-way through. +const BAD_GOLEM: &str = r#"{"name": "Bad Golem", "type": "minion", "cost": 1, "attack": 1, "health": 3, + "noun": "KEY", "split": 1, "text": [{"kind": "trigger", "on": "selfDamaged"}, + {"kind": "effect", "effect": {"type": "summon", "card": "No Such Card", "count": 1}}]}"#; + +/// A 6x3 level with BABA IS YOU, three Zaps in the hand, and `card` on (3, 0). +fn row(card: &str) -> String { + format!( + r#"{{"width": 6, "height": 3, "objects": [{{"kind": "baba", "x": 0, "y": 0}}, + {{"kind": "text:BABA", "x": 0, "y": 2}}, {{"kind": "text:IS", "x": 1, "y": 2}}, {{"kind": "text:YOU", "x": 2, "y": 2}}], + "cards": [{{"card": "{card}", "x": 3, "y": 0}}], "hand": ["Zap", "Zap", "Zap"]}}"# + ) +} + +fn cards(json: &[&str]) -> Vec { + json.iter() + .map(|j| serde_json::from_str(j).unwrap()) + .collect() +} + +#[test] +fn cli_stops_when_a_sequence_does_not_finish() { + let screen = Screen::default(); + let input = Box::new(Cursor::new("c Zap\nd\nq\n")); + let extra = cards(&[ZAP, LOOP_EMBER]); + let outcome = run_with_cards(&row("Loop Ember"), extra, input, Box::new(screen.clone())); + let out = String::from_utf8(screen.0.borrow().clone()).unwrap(); + assert_eq!(outcome, Outcome::Failed); + // The error, then the end: no log tree of a half-done Sequence, no further commands read. + let end = "> Death Phase limit: more than 64 Death Phases in one Sequence\n\ + The Sequence did not finish, so the game cannot go on.\n"; + assert!(out.ends_with(end), "{out}"); + assert!(!out.contains("Log:"), "{out}"); +} + +#[test] +fn an_error_mid_sequence_leaves_no_trigger_resolving() { + let level: LevelDef = serde_json::from_str(&row("Bad Golem")).unwrap(); + let mut g = new_spell_grid(level, cards(&[ZAP, BAD_GOLEM])); + let failed = |g: &mut engine::harness::SpellGrid| { + let cause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Zap", None))) + .expect_err("the trigger names no card"); + cause.downcast::().map(|m| *m).unwrap_or_default() + }; + assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); + // The failed trigger closed its block: the next Sequence's log starts at depth 0, and + // the golem's trigger is queued again (the nesting guard does not think it is still resolving). + g.clear_log(); + assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); + let tree = g.log_tree(); + let lines: Vec<&str> = tree.lines().collect(); + assert_eq!(lines.len(), 3, "{tree}"); + assert_eq!(lines[0], "SpellCast{card: \"Zap\"}"); + assert!(lines[1].starts_with("Damaged{target: "), "{tree}"); + assert!(lines[2].starts_with("Triggered{source: "), "{tree}"); + assert!(lines[2].ends_with(", trigger: \"damaged\"}"), "{tree}"); +} diff --git a/course/10-spellgrid/reference/ts/src/cli.ts b/course/10-spellgrid/reference/ts/src/cli.ts index 03b853b..93de136 100644 --- a/course/10-spellgrid/reference/ts/src/cli.ts +++ b/course/10-spellgrid/reference/ts/src/cli.ts @@ -13,7 +13,7 @@ import { readFileSync, readSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { newSpellGrid, type LevelDef, type SpellGrid } from "./harness"; +import { newSpellGrid, type CardDef, type LevelDef, type SpellGrid } from "./harness"; const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const; @@ -60,14 +60,23 @@ function parseCast(args: string[]): { card: string; at?: [number, number] } { return { card: args.join(" ") }; } +/** What the CLI prints after an engine error, below the error's message. */ +export const UNFINISHED = "The Sequence did not finish, so the game cannot go on."; + /** * Play `levelText` (a level in the course's format), reading commands from - * `readLine` (undefined at the end of input) and writing to `write`. Returns - * `"won"`, `"lost"` or `"quit"`. + * `readLine` (undefined at the end of input) and writing to `write`, with `extra` + * cards added to the ones in cards/. Returns `"won"`, `"lost"`, `"quit"`, or + * `"failed"` if the engine failed in the middle of a Sequence. */ -export function run(levelText: string, readLine: () => string | undefined, write: (s: string) => void): string { +export function run( + levelText: string, + readLine: () => string | undefined, + write: (s: string) => void, + extra: CardDef[] = [], +): string { const level = JSON.parse(levelText) as LevelDef; - const g = newSpellGrid(level, []); + const g = newSpellGrid(level, extra); g.setChooser((kind, options) => { write(`Choose the ${kind} (a number or a name):\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}\n? `); const answer = readLine()?.trim() ?? ""; @@ -88,20 +97,32 @@ export function run(levelText: string, readLine: () => string | undefined, write const line = readLine(); if (line === undefined) return "quit"; const [command, ...args] = line.trim().split(/\s+/); + if (command === "q") return "quit"; // The log holds only what this command does. g.clearLog(); - try { - if (command === "q") return "quit"; - if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]); - else if (command === "c" && args.length > 0) { - const { card, at } = parseCast(args); - const target = at === undefined ? null : g.minionAt(...at); - if (at === undefined) g.cast(card); - else if (target !== null) g.cast(card, target); - else g.play(card, ...at); - } else write("Commands: w a s d (move), c [x y] (cast or play), q (quit)\n"); - } catch (e) { - write(`${e instanceof Error ? e.message : String(e)}\n`); + // The harness throws on a move the rules don't allow (a card not in the hand, a + // full tile). The CLI asks first, and reports the reason instead. + let problem: string | null = null; + let act: (() => void) | undefined; + if (Object.hasOwn(DIRS, command)) act = () => g.step(DIRS[command as keyof typeof DIRS]); + else if (command === "c" && args.length > 0) { + const { card, at } = parseCast(args); + const target = at === undefined ? null : g.minionAt(...at); + if (at === undefined) [problem, act] = [g.checkCast(card), () => g.cast(card)]; + else if (target !== null) [problem, act] = [g.checkCast(card, target), () => g.cast(card, target)]; + else [problem, act] = [g.checkPlay(card, ...at), () => void g.play(card, ...at)]; + } else problem = "Commands: w a s d (move), c [x y] (cast or play), q (quit)"; + if (problem !== null) write(`${problem}\n`); + else { + try { + act?.(); + } catch (e) { + // Any other error is the engine's (a card that names no card, a Sequence past the + // Death Phase limit): the Sequence stopped part-way, so the game is not a game any + // more. Say so and stop; its log is not printed, as it is not a whole Sequence. + write(`${e instanceof Error ? e.message : String(e)}\n${UNFINISHED}\n`); + return "failed"; + } } const tree = g.logTree(); if (tree !== "") write(`Log:\n${tree}`); diff --git a/course/10-spellgrid/reference/ts/src/engine/queue.ts b/course/10-spellgrid/reference/ts/src/engine/queue.ts index 1bd5a03..bb9076a 100644 --- a/course/10-spellgrid/reference/ts/src/engine/queue.ts +++ b/course/10-spellgrid/reference/ts/src/engine/queue.ts @@ -170,7 +170,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -283,10 +284,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } diff --git a/course/10-spellgrid/reference/ts/tests/cli.test.ts b/course/10-spellgrid/reference/ts/tests/cli.test.ts index cb3bbcb..b587718 100644 --- a/course/10-spellgrid/reference/ts/tests/cli.test.ts +++ b/course/10-spellgrid/reference/ts/tests/cli.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { expect, test } from "vitest"; import { run } from "../src/cli"; +import { newSpellGrid, type CardDef, type LevelDef } from "../src/harness"; function play(level: string, input: string[]): { outcome: string; out: string } { let out = ""; @@ -68,3 +69,75 @@ test("cli_numbers_are_plain_digits", () => { const graft = play("l2-spliced-corpse", ["c Graft", "+2", "1", "1", "q"]); expect(graft.out).toContain("Hand: Clay Golem (Assembled)"); }); + +// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). +const ZAP: CardDef = { + name: "Zap", + type: "spell", + cost: 1, + split: 0, + text: [{ kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }], +}; +// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. +const LOOP_EMBER: CardDef = { + name: "Loop Ember", + type: "minion", + cost: 1, + attack: 1, + health: 1, + noun: "ROCK", + split: 1, + text: [ + { kind: "trigger", on: "deathrattle" }, + { kind: "effect", effect: { type: "summon", card: "Loop Ember", count: 1 } }, + { kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }, + ], +}; +// Its trigger names a card that does not exist, so it fails part-way through. +const BAD_GOLEM: CardDef = { + name: "Bad Golem", + type: "minion", + cost: 1, + attack: 1, + health: 3, + noun: "KEY", + split: 1, + text: [ + { kind: "trigger", on: "selfDamaged" }, + { kind: "effect", effect: { type: "summon", card: "No Such Card", count: 1 } }, + ], +}; +const ROW: LevelDef = { + width: 6, + height: 3, + objects: [ + { kind: "baba", x: 0, y: 0 }, + { kind: "text:BABA", x: 0, y: 2 }, + { kind: "text:IS", x: 1, y: 2 }, + { kind: "text:YOU", x: 2, y: 2 }, + ], + cards: [], + hand: ["Zap", "Zap", "Zap"], +}; + +test("cli_stops_when_a_sequence_does_not_finish", () => { + const level = { ...ROW, cards: [{ card: "Loop Ember", x: 3, y: 0 }] }; + const input = ["c Zap", "d", "q"]; + let out = ""; + const outcome = run(JSON.stringify(level), () => input.shift(), (s) => (out += s), [ZAP, LOOP_EMBER]); + expect(outcome).toBe("failed"); + // The error, then the end: no log tree of a half-done Sequence, no further commands read. + expect(out).toMatch(/> Death Phase limit: more than 64 Death Phases in one Sequence\nThe Sequence did not finish, so the game cannot go on\.\n$/); + expect(out).not.toContain("Log:"); + expect(input).toEqual(["d", "q"]); +}); + +test("an_error_mid_sequence_leaves_no_trigger_resolving", () => { + const g = newSpellGrid({ ...ROW, cards: [{ card: "Bad Golem", x: 3, y: 0 }] }, [ZAP, BAD_GOLEM]); + expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); + // The failed trigger closed its block: the next Sequence's log starts at depth 0, and + // the golem's trigger is queued again (the nesting guard does not think it is still resolving). + g.clearLog(); + expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); + expect(g.logTree()).toMatch(/^SpellCast\{card: "Zap"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n$/); +}); diff --git a/course/11-conditions/lesson.md b/course/11-conditions/lesson.md index 64fa673..e4984f3 100644 --- a/course/11-conditions/lesson.md +++ b/course/11-conditions/lesson.md @@ -251,7 +251,7 @@ Decisions in the reference that the tests don't force: - **Only other objects count.** `ROCK ON ROCK IS WIN` needs a second rock. The ON page says "overlapping another object"; the course reads that as excluding the object itself, and applies the same reading to `ALL`. - **A different object for each noun, found by search.** With the course's meaning of `ON ALL` (any other object that isn't text; the game's differs, see the box below), `ON ALL AND KEY` on a tile with a key and a rock holds: the rock stands for `ALL` and the key for `KEY`, even if the key is checked first. The game claims objects in order and the wiki says that can fail (see the box below). The course tries every assignment, so the answer never depends on object order. - **Same conditions means the same nouns in any order.** `BABA ON KEY AND ROCK IS NOT YOU` cancels `BABA ON ROCK AND KEY IS YOU`. They are true of exactly the same objects. `rules()` still prints each as written. -- **`ON` stacked on `AND` reads both ways.** `FLAG [ON AND] KEY IS PUSH` is `FLAG ON KEY IS PUSH`, and also `FLAG IS PUSH` and `KEY IS PUSH`. That is Module 6's rule for stacks: every reading the grammar allows. No source covers this board. +- **`ON` stacked on `AND` reads both ways.** `FLAG [ON AND] KEY IS PUSH` is `FLAG ON KEY IS PUSH`, and also `FLAG IS PUSH` and `KEY IS PUSH`. That is Module 6's rule for stacks: every reading the grammar allows. No source shows `AND` in such a stack. The Conditions page's "Sentence Interruption" bullet shows the nearest case, other text stacked on a condition word: `BABA [DEFEAT ON] GHOST IS DONE` "is read as both" `BABA ON GHOST IS DONE` and `BABA DEFEAT GHOST IS DONE`, which parses as `GHOST IS DONE` ([Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions)). Reading the `AND` stack both ways too is the course's choice, by analogy. - **A condition crosses no seam.** In SpellGrid, `DEATHRATTLE ON KEY IS DOUBLE` gives no aura. A condition is about one object on the grid, and a trigger word names no object. Module 10's `ruleAuras` / `rule_auras` skips rules with conditions. - **Who moves is decided once per movement phase.** Module 8's movement collects the `YOU` objects when it starts. A conditional `YOU` that becomes true halfway through the moves doesn't add a mover. The responses (`PUSH`, `STOP`, `OPEN`/`SHUT`) ask `hasProperty` as each step is planned, so they see the board as the earlier moves left it. The game also fixes its movers per move stage: "it completely finishes moving for a particular move stage before checking which units are in the next". @@ -263,9 +263,9 @@ Decisions in the reference that the tests don't force: > - **`ALL` after a condition.** In the game, "ALL in the noun2 part requires one of every object in ALL", and `BABA ON ALL AND ALL AND FLAG IS WIN` needs Baba on two of everything in ALL, "not counting itself", plus a flag ([Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions)). The course's `ON ALL` means any one other object that isn't text, the way `ALL` names objects as a subject. That simpler meaning is the course's choice, and `test_11_on_group_noun` pins it. > - **`LEVEL ON ROCK`**, and the level icon's position ([ON](https://babaiswiki.fandom.com/wiki/ON)). The course has no `LEVEL`. > - **When a condition is checked.** The game checks conditions "usually right before it uses it" ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations)). The course always checks at use. The page doesn't list the exceptions. -> - **The hazard board is inferred.** Baba surviving after SINK removes the key follows from "Conditions process ... right before it uses it" and "Deletion occurs after each step that removes objects". No source shows this board. +> - **The hazard board is inferred.** Baba surviving after SINK removes the key follows from "Conditions process when the game checks for what objects have a rule, which is usually right before it uses it." and "Deletion occurs after each step that removes objects" (both ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations))). No source shows this board, and "usually" leaves room for an exception the page doesn't name. > - **Conditional `NOT` against an unconditional rule.** The NOT page says a negation overrides "other instances of the rule it disables", and its example "ROCK ON BABA IS NOT PUSH makes ROCK that is on BABA not PUSHable" names only those rocks. No page read spells out what happens with `ROCK IS PUSH` elsewhere on the board. Settling the negation per object, among the rules that apply, is the course's reading. -> - **Object order.** "Objects are parsed in order of object priority" ([ON](https://babaiswiki.fandom.com/wiki/ON)), which the game sets by placement on the grid ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations)). The course's objects are in age order, and no answer in this module depends on it. +> - **Object order.** "Objects are parsed in order of object priority" ([ON](https://babaiswiki.fandom.com/wiki/ON)). Object priority is the order of the game's one list of objects: at a level's start they are ordered by placement, "columns from left to right, from top to bottom within each column", and new objects are "added to the end of the object list in the order they were created" ([Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations)). The course's objects are in age order, and no answer in this module depends on it. ## Name it diff --git a/course/11-conditions/reference/rust/src/cli.rs b/course/11-conditions/reference/rust/src/cli.rs index 2e0e5b3..3e7adf7 100644 --- a/course/11-conditions/reference/rust/src/cli.rs +++ b/course/11-conditions/reference/rust/src/cli.rs @@ -15,9 +15,10 @@ use std::cell::RefCell; use std::io::{BufRead, Write}; +use std::panic::{self, AssertUnwindSafe}; use std::rc::Rc; -use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid}; +use crate::harness::{new_spell_grid, CardDef, Dir, Id, LevelDef, SpellGrid}; /// How a game ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +26,39 @@ pub enum Outcome { Won, Lost, Quit, + /// The engine failed in the middle of a Sequence (see `carry_out`). + Failed, +} + +/// What the CLI prints after an engine error, below the error's message. +pub const UNFINISHED: &str = "The Sequence did not finish, so the game cannot go on."; + +/// A command the rules allow, checked and ready to run. +enum Command { + Step(Dir), + Cast(String, Option), + Play(String, i32, i32), +} + +/// Run `command`. A panic inside it is caught and returned as its message. The panic hook is +/// silenced meanwhile, so the message is printed once, by the CLI, on its own output. +fn carry_out(g: &mut SpellGrid, command: Command) -> Result<(), String> { + let hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let result = panic::catch_unwind(AssertUnwindSafe(|| match command { + Command::Step(dir) => g.step(dir), + Command::Cast(card, target) => g.cast(&card, target), + Command::Play(card, x, y) => { + g.play(&card, x, y); + } + })); + panic::set_hook(hook); + result.map_err(|cause| match cause.downcast::() { + Ok(message) => *message, + Err(cause) => cause + .downcast_ref::<&str>() + .map_or_else(|| "the engine panicked".to_string(), |s| s.to_string()), + }) } /// The player's input and the screen, shared with the chooser. @@ -115,9 +149,19 @@ fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) { /// Play `level_text` (a level in the course's format), reading commands from `input` /// and writing to `output`. pub fn run(level_text: &str, input: Box, output: Box) -> Outcome { + run_with_cards(level_text, vec![], input, output) +} + +/// `run`, with `extra` cards added to the ones in cards/ (as `new_spell_grid` takes them). +pub fn run_with_cards( + level_text: &str, + extra: Vec, + input: Box, + output: Box, +) -> Outcome { let level: LevelDef = serde_json::from_str(level_text).expect("a level in the course's level format"); - let mut g = new_spell_grid(level.clone(), vec![]); + let mut g = new_spell_grid(level.clone(), extra); let io = Rc::new(RefCell::new(Io { input, output })); let chooser_io = Rc::clone(&io); g.set_chooser(move |kind, options| { @@ -172,25 +216,36 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc>) -> Outcome { }; // The harness panics on a move the rules don't allow (a card not in the hand, a // full tile). The CLI asks first, and reports the reason instead. - let done = match (dir, words.as_slice()) { - (Some(dir), _) => Ok(g.step(dir)), + let command = match (dir, words.as_slice()) { + (Some(dir), _) => Ok(Command::Step(dir)), (None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) { - (card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)), + (card, None) => g + .check_cast(&card, None) + .map(|()| Command::Cast(card, None)), (card, Some((x, y))) => match g.minion_at(x, y) { Some(target) => g .check_cast(&card, Some(target)) - .map(|()| g.cast(&card, Some(target))), - None => g.check_play(&card, x, y).map(|()| { - g.play(&card, x, y); - }), + .map(|()| Command::Cast(card, Some(target))), + None => g + .check_play(&card, x, y) + .map(|()| Command::Play(card, x, y)), }, }, _ => { Err("Commands: w a s d (move), c [x y] (cast or play), q (quit)".to_string()) } }; - if let Err(message) = done { - io.borrow_mut().write(&format!("{message}\n")); + match command { + Err(message) => io.borrow_mut().write(&format!("{message}\n")), + // Any other panic is the engine's (a card that names no card, a Sequence past the + // Death Phase limit): the Sequence stopped part-way, so the game is not a game any + // more. Say so and stop; its log is not printed, as it is not a whole Sequence. + Ok(command) => { + if let Err(message) = carry_out(g, command) { + io.borrow_mut().write(&format!("{message}\n{UNFINISHED}\n")); + return Outcome::Failed; + } + } } let tree = g.log_tree(); if !tree.is_empty() { diff --git a/course/11-conditions/reference/rust/src/engine/queue.rs b/course/11-conditions/reference/rust/src/engine/queue.rs index fcb0e09..4f4cd1e 100644 --- a/course/11-conditions/reference/rust/src/engine/queue.rs +++ b/course/11-conditions/reference/rust/src/engine/queue.rs @@ -30,6 +30,8 @@ //! read from the world, as Part II's turn is a list of phases. SpellGrid's tests run //! the same Sequence with the list reversed, to show what the order decides. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -203,7 +205,8 @@ fn perform(world: &mut World, action: Action) { mut targets, amount, } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. targets.sort_by_key(|&id| world.order_of_play(id)); let mut events = Vec::new(); for target in targets { @@ -346,11 +349,17 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } diff --git a/course/11-conditions/reference/rust/tests/cli.rs b/course/11-conditions/reference/rust/tests/cli.rs index cf8d0f7..7377604 100644 --- a/course/11-conditions/reference/rust/tests/cli.rs +++ b/course/11-conditions/reference/rust/tests/cli.rs @@ -3,7 +3,8 @@ use std::cell::RefCell; use std::io::{Cursor, Write}; use std::rc::Rc; -use engine::cli::{run, Outcome}; +use engine::cli::{run, run_with_cards, Outcome}; +use engine::harness::{new_spell_grid, CardDef, LevelDef}; /// Collects what the CLI prints. #[derive(Clone, Default)] @@ -111,3 +112,69 @@ fn cli_numbers_are_plain_digits() { let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]); assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}"); } + +// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). +const ZAP: &str = r#"{"name": "Zap", "type": "spell", "cost": 1, "split": 0, + "text": [{"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; +// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. +const LOOP_EMBER: &str = r#"{"name": "Loop Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, + "noun": "ROCK", "split": 1, "text": [{"kind": "trigger", "on": "deathrattle"}, + {"kind": "effect", "effect": {"type": "summon", "card": "Loop Ember", "count": 1}}, + {"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; +// Its trigger names a card that does not exist, so it fails part-way through. +const BAD_GOLEM: &str = r#"{"name": "Bad Golem", "type": "minion", "cost": 1, "attack": 1, "health": 3, + "noun": "KEY", "split": 1, "text": [{"kind": "trigger", "on": "selfDamaged"}, + {"kind": "effect", "effect": {"type": "summon", "card": "No Such Card", "count": 1}}]}"#; + +/// A 6x3 level with BABA IS YOU, three Zaps in the hand, and `card` on (3, 0). +fn row(card: &str) -> String { + format!( + r#"{{"width": 6, "height": 3, "objects": [{{"kind": "baba", "x": 0, "y": 0}}, + {{"kind": "text:BABA", "x": 0, "y": 2}}, {{"kind": "text:IS", "x": 1, "y": 2}}, {{"kind": "text:YOU", "x": 2, "y": 2}}], + "cards": [{{"card": "{card}", "x": 3, "y": 0}}], "hand": ["Zap", "Zap", "Zap"]}}"# + ) +} + +fn cards(json: &[&str]) -> Vec { + json.iter() + .map(|j| serde_json::from_str(j).unwrap()) + .collect() +} + +#[test] +fn cli_stops_when_a_sequence_does_not_finish() { + let screen = Screen::default(); + let input = Box::new(Cursor::new("c Zap\nd\nq\n")); + let extra = cards(&[ZAP, LOOP_EMBER]); + let outcome = run_with_cards(&row("Loop Ember"), extra, input, Box::new(screen.clone())); + let out = String::from_utf8(screen.0.borrow().clone()).unwrap(); + assert_eq!(outcome, Outcome::Failed); + // The error, then the end: no log tree of a half-done Sequence, no further commands read. + let end = "> Death Phase limit: more than 64 Death Phases in one Sequence\n\ + The Sequence did not finish, so the game cannot go on.\n"; + assert!(out.ends_with(end), "{out}"); + assert!(!out.contains("Log:"), "{out}"); +} + +#[test] +fn an_error_mid_sequence_leaves_no_trigger_resolving() { + let level: LevelDef = serde_json::from_str(&row("Bad Golem")).unwrap(); + let mut g = new_spell_grid(level, cards(&[ZAP, BAD_GOLEM])); + let failed = |g: &mut engine::harness::SpellGrid| { + let cause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Zap", None))) + .expect_err("the trigger names no card"); + cause.downcast::().map(|m| *m).unwrap_or_default() + }; + assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); + // The failed trigger closed its block: the next Sequence's log starts at depth 0, and + // the golem's trigger is queued again (the nesting guard does not think it is still resolving). + g.clear_log(); + assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); + let tree = g.log_tree(); + let lines: Vec<&str> = tree.lines().collect(); + assert_eq!(lines.len(), 3, "{tree}"); + assert_eq!(lines[0], "SpellCast{card: \"Zap\"}"); + assert!(lines[1].starts_with("Damaged{target: "), "{tree}"); + assert!(lines[2].starts_with("Triggered{source: "), "{tree}"); + assert!(lines[2].ends_with(", trigger: \"damaged\"}"), "{tree}"); +} diff --git a/course/11-conditions/reference/ts/src/cli.ts b/course/11-conditions/reference/ts/src/cli.ts index 03b853b..93de136 100644 --- a/course/11-conditions/reference/ts/src/cli.ts +++ b/course/11-conditions/reference/ts/src/cli.ts @@ -13,7 +13,7 @@ import { readFileSync, readSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { newSpellGrid, type LevelDef, type SpellGrid } from "./harness"; +import { newSpellGrid, type CardDef, type LevelDef, type SpellGrid } from "./harness"; const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const; @@ -60,14 +60,23 @@ function parseCast(args: string[]): { card: string; at?: [number, number] } { return { card: args.join(" ") }; } +/** What the CLI prints after an engine error, below the error's message. */ +export const UNFINISHED = "The Sequence did not finish, so the game cannot go on."; + /** * Play `levelText` (a level in the course's format), reading commands from - * `readLine` (undefined at the end of input) and writing to `write`. Returns - * `"won"`, `"lost"` or `"quit"`. + * `readLine` (undefined at the end of input) and writing to `write`, with `extra` + * cards added to the ones in cards/. Returns `"won"`, `"lost"`, `"quit"`, or + * `"failed"` if the engine failed in the middle of a Sequence. */ -export function run(levelText: string, readLine: () => string | undefined, write: (s: string) => void): string { +export function run( + levelText: string, + readLine: () => string | undefined, + write: (s: string) => void, + extra: CardDef[] = [], +): string { const level = JSON.parse(levelText) as LevelDef; - const g = newSpellGrid(level, []); + const g = newSpellGrid(level, extra); g.setChooser((kind, options) => { write(`Choose the ${kind} (a number or a name):\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}\n? `); const answer = readLine()?.trim() ?? ""; @@ -88,20 +97,32 @@ export function run(levelText: string, readLine: () => string | undefined, write const line = readLine(); if (line === undefined) return "quit"; const [command, ...args] = line.trim().split(/\s+/); + if (command === "q") return "quit"; // The log holds only what this command does. g.clearLog(); - try { - if (command === "q") return "quit"; - if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]); - else if (command === "c" && args.length > 0) { - const { card, at } = parseCast(args); - const target = at === undefined ? null : g.minionAt(...at); - if (at === undefined) g.cast(card); - else if (target !== null) g.cast(card, target); - else g.play(card, ...at); - } else write("Commands: w a s d (move), c [x y] (cast or play), q (quit)\n"); - } catch (e) { - write(`${e instanceof Error ? e.message : String(e)}\n`); + // The harness throws on a move the rules don't allow (a card not in the hand, a + // full tile). The CLI asks first, and reports the reason instead. + let problem: string | null = null; + let act: (() => void) | undefined; + if (Object.hasOwn(DIRS, command)) act = () => g.step(DIRS[command as keyof typeof DIRS]); + else if (command === "c" && args.length > 0) { + const { card, at } = parseCast(args); + const target = at === undefined ? null : g.minionAt(...at); + if (at === undefined) [problem, act] = [g.checkCast(card), () => g.cast(card)]; + else if (target !== null) [problem, act] = [g.checkCast(card, target), () => g.cast(card, target)]; + else [problem, act] = [g.checkPlay(card, ...at), () => void g.play(card, ...at)]; + } else problem = "Commands: w a s d (move), c [x y] (cast or play), q (quit)"; + if (problem !== null) write(`${problem}\n`); + else { + try { + act?.(); + } catch (e) { + // Any other error is the engine's (a card that names no card, a Sequence past the + // Death Phase limit): the Sequence stopped part-way, so the game is not a game any + // more. Say so and stop; its log is not printed, as it is not a whole Sequence. + write(`${e instanceof Error ? e.message : String(e)}\n${UNFINISHED}\n`); + return "failed"; + } } const tree = g.logTree(); if (tree !== "") write(`Log:\n${tree}`); diff --git a/course/11-conditions/reference/ts/src/engine/queue.ts b/course/11-conditions/reference/ts/src/engine/queue.ts index 1bd5a03..bb9076a 100644 --- a/course/11-conditions/reference/ts/src/engine/queue.ts +++ b/course/11-conditions/reference/ts/src/engine/queue.ts @@ -170,7 +170,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); const events: Event[] = []; for (const target of targets) events.push(applyDamage(world, target, action.amount)); @@ -283,10 +284,14 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } diff --git a/course/11-conditions/reference/ts/tests/cli.test.ts b/course/11-conditions/reference/ts/tests/cli.test.ts index cb3bbcb..b587718 100644 --- a/course/11-conditions/reference/ts/tests/cli.test.ts +++ b/course/11-conditions/reference/ts/tests/cli.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { expect, test } from "vitest"; import { run } from "../src/cli"; +import { newSpellGrid, type CardDef, type LevelDef } from "../src/harness"; function play(level: string, input: string[]): { outcome: string; out: string } { let out = ""; @@ -68,3 +69,75 @@ test("cli_numbers_are_plain_digits", () => { const graft = play("l2-spliced-corpse", ["c Graft", "+2", "1", "1", "q"]); expect(graft.out).toContain("Hand: Clay Golem (Assembled)"); }); + +// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). +const ZAP: CardDef = { + name: "Zap", + type: "spell", + cost: 1, + split: 0, + text: [{ kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }], +}; +// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. +const LOOP_EMBER: CardDef = { + name: "Loop Ember", + type: "minion", + cost: 1, + attack: 1, + health: 1, + noun: "ROCK", + split: 1, + text: [ + { kind: "trigger", on: "deathrattle" }, + { kind: "effect", effect: { type: "summon", card: "Loop Ember", count: 1 } }, + { kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }, + ], +}; +// Its trigger names a card that does not exist, so it fails part-way through. +const BAD_GOLEM: CardDef = { + name: "Bad Golem", + type: "minion", + cost: 1, + attack: 1, + health: 3, + noun: "KEY", + split: 1, + text: [ + { kind: "trigger", on: "selfDamaged" }, + { kind: "effect", effect: { type: "summon", card: "No Such Card", count: 1 } }, + ], +}; +const ROW: LevelDef = { + width: 6, + height: 3, + objects: [ + { kind: "baba", x: 0, y: 0 }, + { kind: "text:BABA", x: 0, y: 2 }, + { kind: "text:IS", x: 1, y: 2 }, + { kind: "text:YOU", x: 2, y: 2 }, + ], + cards: [], + hand: ["Zap", "Zap", "Zap"], +}; + +test("cli_stops_when_a_sequence_does_not_finish", () => { + const level = { ...ROW, cards: [{ card: "Loop Ember", x: 3, y: 0 }] }; + const input = ["c Zap", "d", "q"]; + let out = ""; + const outcome = run(JSON.stringify(level), () => input.shift(), (s) => (out += s), [ZAP, LOOP_EMBER]); + expect(outcome).toBe("failed"); + // The error, then the end: no log tree of a half-done Sequence, no further commands read. + expect(out).toMatch(/> Death Phase limit: more than 64 Death Phases in one Sequence\nThe Sequence did not finish, so the game cannot go on\.\n$/); + expect(out).not.toContain("Log:"); + expect(input).toEqual(["d", "q"]); +}); + +test("an_error_mid_sequence_leaves_no_trigger_resolving", () => { + const g = newSpellGrid({ ...ROW, cards: [{ card: "Bad Golem", x: 3, y: 0 }] }, [ZAP, BAD_GOLEM]); + expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); + // The failed trigger closed its block: the next Sequence's log starts at depth 0, and + // the golem's trigger is queued again (the nesting guard does not think it is still resolving). + g.clearLog(); + expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); + expect(g.logTree()).toMatch(/^SpellCast\{card: "Zap"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n$/); +}); diff --git a/course/12-combat/lesson.md b/course/12-combat/lesson.md index 995b9b9..0211617 100644 --- a/course/12-combat/lesson.md +++ b/course/12-combat/lesson.md @@ -258,7 +258,7 @@ Decisions in the reference that the tests don't force: - **Every entry answering the defender's damage goes first,** not only the defender's own trigger. The rulebook's sentence is about two minions that each trigger "when they take damage". An Armorsmith beside the defender answers the same Damage Event, and in the reference it shares the event's priority. The course's reading: the exception is about the event, and the entry carries the event's priority. No test has a third minion. - **No `Damaged` for 0.** A hero, or a 0 Attack minion, as defender adds no hit at all, so no `Damaged{amount: 0}` event and no "takes damage" trigger. The Attack page's "hit the hero without harm" suggests nothing happens to the attacker. -- **An illegal attack changes nothing and records nothing.** `orderAttack` asks `canAttack` before the Sequence starts. +- **An illegal attack changes nothing and records nothing.** `orderAttack` asks `canAttack` before anything changes. - **A redirected attack has no counterattack from the redirect.** When Bolf takes an attack aimed at his hero, the defender is still the hero, whose Attack was read when the attack began. Bolf doesn't hit the attacker. The [Bolf Ramshield](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield) page confirms that he intercepts attacks: effects of "the attacking character dealing damage" "will still take effect if Bolf Ramshield intercepts the damage". It says nothing about Bolf hitting back. That part is the course's choice. - **The end of a turn is an engine event the log doesn't show,** as `Played` isn't shown (Module 4). The triggers that answer it are the log's evidence. @@ -266,7 +266,7 @@ Decisions in the reference that the tests don't force: > - **The order of the `Damaged` events.** The rulebook says the damage is dealt "in the order (attack, counterattack)" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)): the defender's Damage Event first. The course creates the two events in order of play, as for every Damage action since Module 2, and puts the exception in the Queue. The triggers come out in the same order either way for every board in this module; only the order of the two `Damaged` lines differs. Stress it question 1 asks what the difference would buy. > - **Triggers on dealing damage.** The rulebook's mirror rule, "the attacker ALWAYS triggers first, regardless of order of play" (same source), isn't modelled: the course has no trigger on dealing damage. > - **The rest of the attack.** In the game an attack is "a Sequence of two Phases - "Preparation" and "Attack"" (same source), with a Proposed Attack Event whose triggers "include those that can change the defender", an Attack Event, and an After Attack Event. Between the steps, "Hearthstone checks for win/loss/draw.", and "the Combat sequence additionally checks just before the Combat Phase would begin." (same source). The course's attack is one Phase: the damage. -> - **Turns.** "A character can usually attack up to one time per turn" ([Attack](https://hearthstone.wiki.gg/wiki/Attack)), new minions have "summoning sickness" (same source), and attacking costs nothing but playing costs mana. The course models none of these, and only minions attack: heroes with weapons are not in the course. `endTurn` can be called for either player at any moment, and there is no start of turn. +> - **Turns.** "A character can usually attack up to one time per turn" ([Attack](https://hearthstone.wiki.gg/wiki/Attack)), and new minions have "summoning sickness" (same source). The course models none of these, and only minions attack: heroes with weapons are not in the course. `endTurn` can be called for either player at any moment, and there is no start of turn. > - **Stealth.** "The Taunt effect is temporarily negated by the Stealth or Immunity." ([Taunt](https://hearthstone.wiki.gg/wiki/Taunt)). The course has neither. > - **Drakkari's exceptions.** "Drakkari Enchanter doesn't affect some end of turn effects." ([Drakkari Enchanter](https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter)). The page doesn't list them; the course's Drakkari doubles every `endOfTurn` trigger of its owner's minions. @@ -295,7 +295,7 @@ queue.sort_by_key(|entry| std::cmp::Reverse(entry.4)); queue.sort((a, b) => b[4] - a[4]); ``` -The tie-breaker is the order the Queue was filled in, which is order of play: for each event in order of play, each listener in order of play. That is why the sort must be **stable**. Rust's `sort_by_key` is stable, and so is JavaScript's `Array.prototype.sort`. An unstable sort would keep the defender's entries first and shuffle everything else, and every Module 2 test would start failing at random. +The tie-breaker is the order the Queue was filled in, which is order of play: for each event in order of play, each listener in order of play. That is why the sort must be **stable**. Rust's `sort_by_key` is stable, and so is JavaScript's `Array.prototype.sort`. An unstable sort would keep the defender's entries first but could reorder the rest, so a test that pins order of play among same-priority triggers, as Module 2's do, could pass or fail depending on the sort's implementation. And the attack, which is where the priority comes from: diff --git a/course/12-combat/reference/rust/src/cli.rs b/course/12-combat/reference/rust/src/cli.rs index 2e0e5b3..3e7adf7 100644 --- a/course/12-combat/reference/rust/src/cli.rs +++ b/course/12-combat/reference/rust/src/cli.rs @@ -15,9 +15,10 @@ use std::cell::RefCell; use std::io::{BufRead, Write}; +use std::panic::{self, AssertUnwindSafe}; use std::rc::Rc; -use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid}; +use crate::harness::{new_spell_grid, CardDef, Dir, Id, LevelDef, SpellGrid}; /// How a game ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +26,39 @@ pub enum Outcome { Won, Lost, Quit, + /// The engine failed in the middle of a Sequence (see `carry_out`). + Failed, +} + +/// What the CLI prints after an engine error, below the error's message. +pub const UNFINISHED: &str = "The Sequence did not finish, so the game cannot go on."; + +/// A command the rules allow, checked and ready to run. +enum Command { + Step(Dir), + Cast(String, Option), + Play(String, i32, i32), +} + +/// Run `command`. A panic inside it is caught and returned as its message. The panic hook is +/// silenced meanwhile, so the message is printed once, by the CLI, on its own output. +fn carry_out(g: &mut SpellGrid, command: Command) -> Result<(), String> { + let hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let result = panic::catch_unwind(AssertUnwindSafe(|| match command { + Command::Step(dir) => g.step(dir), + Command::Cast(card, target) => g.cast(&card, target), + Command::Play(card, x, y) => { + g.play(&card, x, y); + } + })); + panic::set_hook(hook); + result.map_err(|cause| match cause.downcast::() { + Ok(message) => *message, + Err(cause) => cause + .downcast_ref::<&str>() + .map_or_else(|| "the engine panicked".to_string(), |s| s.to_string()), + }) } /// The player's input and the screen, shared with the chooser. @@ -115,9 +149,19 @@ fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) { /// Play `level_text` (a level in the course's format), reading commands from `input` /// and writing to `output`. pub fn run(level_text: &str, input: Box, output: Box) -> Outcome { + run_with_cards(level_text, vec![], input, output) +} + +/// `run`, with `extra` cards added to the ones in cards/ (as `new_spell_grid` takes them). +pub fn run_with_cards( + level_text: &str, + extra: Vec, + input: Box, + output: Box, +) -> Outcome { let level: LevelDef = serde_json::from_str(level_text).expect("a level in the course's level format"); - let mut g = new_spell_grid(level.clone(), vec![]); + let mut g = new_spell_grid(level.clone(), extra); let io = Rc::new(RefCell::new(Io { input, output })); let chooser_io = Rc::clone(&io); g.set_chooser(move |kind, options| { @@ -172,25 +216,36 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc>) -> Outcome { }; // The harness panics on a move the rules don't allow (a card not in the hand, a // full tile). The CLI asks first, and reports the reason instead. - let done = match (dir, words.as_slice()) { - (Some(dir), _) => Ok(g.step(dir)), + let command = match (dir, words.as_slice()) { + (Some(dir), _) => Ok(Command::Step(dir)), (None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) { - (card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)), + (card, None) => g + .check_cast(&card, None) + .map(|()| Command::Cast(card, None)), (card, Some((x, y))) => match g.minion_at(x, y) { Some(target) => g .check_cast(&card, Some(target)) - .map(|()| g.cast(&card, Some(target))), - None => g.check_play(&card, x, y).map(|()| { - g.play(&card, x, y); - }), + .map(|()| Command::Cast(card, Some(target))), + None => g + .check_play(&card, x, y) + .map(|()| Command::Play(card, x, y)), }, }, _ => { Err("Commands: w a s d (move), c [x y] (cast or play), q (quit)".to_string()) } }; - if let Err(message) = done { - io.borrow_mut().write(&format!("{message}\n")); + match command { + Err(message) => io.borrow_mut().write(&format!("{message}\n")), + // Any other panic is the engine's (a card that names no card, a Sequence past the + // Death Phase limit): the Sequence stopped part-way, so the game is not a game any + // more. Say so and stop; its log is not printed, as it is not a whole Sequence. + Ok(command) => { + if let Err(message) = carry_out(g, command) { + io.borrow_mut().write(&format!("{message}\n{UNFINISHED}\n")); + return Outcome::Failed; + } + } } let tree = g.log_tree(); if !tree.is_empty() { diff --git a/course/12-combat/reference/rust/src/engine/queue.rs b/course/12-combat/reference/rust/src/engine/queue.rs index 315e094..254938d 100644 --- a/course/12-combat/reference/rust/src/engine/queue.rs +++ b/course/12-combat/reference/rust/src/engine/queue.rs @@ -38,6 +38,8 @@ //! priority. Ending a turn raises an event that the turn's player's end of turn //! triggers answer. +use std::panic::{self, AssertUnwindSafe}; + use super::assemble::assemble_from_pool; use super::aura::{activations, aura_update_other, redirect_for}; use super::card::{Effect, PoolFilter, TriggerOn}; @@ -249,7 +251,8 @@ pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option fn perform(world: &mut World, action: Action) { match action { Action::Damage { mut hits } => { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. hits.sort_by_key(|hit| world.order_of_play(hit.target)); let mut events = Vec::new(); for hit in &hits { @@ -423,11 +426,17 @@ fn react(world: &mut World, events: &[Event], priorities: &[Priority]) { } _ => world.board_index(source), }; - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect panics, so a caught panic leaves no trigger resolving. world.open_block(source, index); let owner = world.owner_of(source); - run_effects(world, &trigger.effects, owner, Some(source), None, position); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + run_effects(world, &trigger.effects, owner, Some(source), None, position); + })); world.close_block(); + if let Err(cause) = result { + panic::resume_unwind(cause); + } } } } diff --git a/course/12-combat/reference/rust/tests/cli.rs b/course/12-combat/reference/rust/tests/cli.rs index cf8d0f7..7377604 100644 --- a/course/12-combat/reference/rust/tests/cli.rs +++ b/course/12-combat/reference/rust/tests/cli.rs @@ -3,7 +3,8 @@ use std::cell::RefCell; use std::io::{Cursor, Write}; use std::rc::Rc; -use engine::cli::{run, Outcome}; +use engine::cli::{run, run_with_cards, Outcome}; +use engine::harness::{new_spell_grid, CardDef, LevelDef}; /// Collects what the CLI prints. #[derive(Clone, Default)] @@ -111,3 +112,69 @@ fn cli_numbers_are_plain_digits() { let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]); assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}"); } + +// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). +const ZAP: &str = r#"{"name": "Zap", "type": "spell", "cost": 1, "split": 0, + "text": [{"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; +// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. +const LOOP_EMBER: &str = r#"{"name": "Loop Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, + "noun": "ROCK", "split": 1, "text": [{"kind": "trigger", "on": "deathrattle"}, + {"kind": "effect", "effect": {"type": "summon", "card": "Loop Ember", "count": 1}}, + {"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; +// Its trigger names a card that does not exist, so it fails part-way through. +const BAD_GOLEM: &str = r#"{"name": "Bad Golem", "type": "minion", "cost": 1, "attack": 1, "health": 3, + "noun": "KEY", "split": 1, "text": [{"kind": "trigger", "on": "selfDamaged"}, + {"kind": "effect", "effect": {"type": "summon", "card": "No Such Card", "count": 1}}]}"#; + +/// A 6x3 level with BABA IS YOU, three Zaps in the hand, and `card` on (3, 0). +fn row(card: &str) -> String { + format!( + r#"{{"width": 6, "height": 3, "objects": [{{"kind": "baba", "x": 0, "y": 0}}, + {{"kind": "text:BABA", "x": 0, "y": 2}}, {{"kind": "text:IS", "x": 1, "y": 2}}, {{"kind": "text:YOU", "x": 2, "y": 2}}], + "cards": [{{"card": "{card}", "x": 3, "y": 0}}], "hand": ["Zap", "Zap", "Zap"]}}"# + ) +} + +fn cards(json: &[&str]) -> Vec { + json.iter() + .map(|j| serde_json::from_str(j).unwrap()) + .collect() +} + +#[test] +fn cli_stops_when_a_sequence_does_not_finish() { + let screen = Screen::default(); + let input = Box::new(Cursor::new("c Zap\nd\nq\n")); + let extra = cards(&[ZAP, LOOP_EMBER]); + let outcome = run_with_cards(&row("Loop Ember"), extra, input, Box::new(screen.clone())); + let out = String::from_utf8(screen.0.borrow().clone()).unwrap(); + assert_eq!(outcome, Outcome::Failed); + // The error, then the end: no log tree of a half-done Sequence, no further commands read. + let end = "> Death Phase limit: more than 64 Death Phases in one Sequence\n\ + The Sequence did not finish, so the game cannot go on.\n"; + assert!(out.ends_with(end), "{out}"); + assert!(!out.contains("Log:"), "{out}"); +} + +#[test] +fn an_error_mid_sequence_leaves_no_trigger_resolving() { + let level: LevelDef = serde_json::from_str(&row("Bad Golem")).unwrap(); + let mut g = new_spell_grid(level, cards(&[ZAP, BAD_GOLEM])); + let failed = |g: &mut engine::harness::SpellGrid| { + let cause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Zap", None))) + .expect_err("the trigger names no card"); + cause.downcast::().map(|m| *m).unwrap_or_default() + }; + assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); + // The failed trigger closed its block: the next Sequence's log starts at depth 0, and + // the golem's trigger is queued again (the nesting guard does not think it is still resolving). + g.clear_log(); + assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); + let tree = g.log_tree(); + let lines: Vec<&str> = tree.lines().collect(); + assert_eq!(lines.len(), 3, "{tree}"); + assert_eq!(lines[0], "SpellCast{card: \"Zap\"}"); + assert!(lines[1].starts_with("Damaged{target: "), "{tree}"); + assert!(lines[2].starts_with("Triggered{source: "), "{tree}"); + assert!(lines[2].ends_with(", trigger: \"damaged\"}"), "{tree}"); +} diff --git a/course/12-combat/reference/ts/src/cli.ts b/course/12-combat/reference/ts/src/cli.ts index 03b853b..93de136 100644 --- a/course/12-combat/reference/ts/src/cli.ts +++ b/course/12-combat/reference/ts/src/cli.ts @@ -13,7 +13,7 @@ import { readFileSync, readSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { newSpellGrid, type LevelDef, type SpellGrid } from "./harness"; +import { newSpellGrid, type CardDef, type LevelDef, type SpellGrid } from "./harness"; const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const; @@ -60,14 +60,23 @@ function parseCast(args: string[]): { card: string; at?: [number, number] } { return { card: args.join(" ") }; } +/** What the CLI prints after an engine error, below the error's message. */ +export const UNFINISHED = "The Sequence did not finish, so the game cannot go on."; + /** * Play `levelText` (a level in the course's format), reading commands from - * `readLine` (undefined at the end of input) and writing to `write`. Returns - * `"won"`, `"lost"` or `"quit"`. + * `readLine` (undefined at the end of input) and writing to `write`, with `extra` + * cards added to the ones in cards/. Returns `"won"`, `"lost"`, `"quit"`, or + * `"failed"` if the engine failed in the middle of a Sequence. */ -export function run(levelText: string, readLine: () => string | undefined, write: (s: string) => void): string { +export function run( + levelText: string, + readLine: () => string | undefined, + write: (s: string) => void, + extra: CardDef[] = [], +): string { const level = JSON.parse(levelText) as LevelDef; - const g = newSpellGrid(level, []); + const g = newSpellGrid(level, extra); g.setChooser((kind, options) => { write(`Choose the ${kind} (a number or a name):\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}\n? `); const answer = readLine()?.trim() ?? ""; @@ -88,20 +97,32 @@ export function run(levelText: string, readLine: () => string | undefined, write const line = readLine(); if (line === undefined) return "quit"; const [command, ...args] = line.trim().split(/\s+/); + if (command === "q") return "quit"; // The log holds only what this command does. g.clearLog(); - try { - if (command === "q") return "quit"; - if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]); - else if (command === "c" && args.length > 0) { - const { card, at } = parseCast(args); - const target = at === undefined ? null : g.minionAt(...at); - if (at === undefined) g.cast(card); - else if (target !== null) g.cast(card, target); - else g.play(card, ...at); - } else write("Commands: w a s d (move), c [x y] (cast or play), q (quit)\n"); - } catch (e) { - write(`${e instanceof Error ? e.message : String(e)}\n`); + // The harness throws on a move the rules don't allow (a card not in the hand, a + // full tile). The CLI asks first, and reports the reason instead. + let problem: string | null = null; + let act: (() => void) | undefined; + if (Object.hasOwn(DIRS, command)) act = () => g.step(DIRS[command as keyof typeof DIRS]); + else if (command === "c" && args.length > 0) { + const { card, at } = parseCast(args); + const target = at === undefined ? null : g.minionAt(...at); + if (at === undefined) [problem, act] = [g.checkCast(card), () => g.cast(card)]; + else if (target !== null) [problem, act] = [g.checkCast(card, target), () => g.cast(card, target)]; + else [problem, act] = [g.checkPlay(card, ...at), () => void g.play(card, ...at)]; + } else problem = "Commands: w a s d (move), c [x y] (cast or play), q (quit)"; + if (problem !== null) write(`${problem}\n`); + else { + try { + act?.(); + } catch (e) { + // Any other error is the engine's (a card that names no card, a Sequence past the + // Death Phase limit): the Sequence stopped part-way, so the game is not a game any + // more. Say so and stop; its log is not printed, as it is not a whole Sequence. + write(`${e instanceof Error ? e.message : String(e)}\n${UNFINISHED}\n`); + return "failed"; + } } const tree = g.logTree(); if (tree !== "") write(`Log:\n${tree}`); diff --git a/course/12-combat/reference/ts/src/engine/queue.ts b/course/12-combat/reference/ts/src/engine/queue.ts index cd4b267..73a920e 100644 --- a/course/12-combat/reference/ts/src/engine/queue.ts +++ b/course/12-combat/reference/ts/src/engine/queue.ts @@ -208,7 +208,8 @@ export function summon(world: World, card: string, player: PlayerId, at?: number function perform(world: World, action: Action): void { switch (action.type) { case "damage": { - // Area damage: every Damage Event is created, in order of play, before any reaction. + // Area damage: every Damage Event is created, in order of play of the intended targets + // (a redirected Damage keeps its target's place), before any reaction. const sorted = [...action.hits].sort((a, b) => world.orderOfPlay(a.target)! - world.orderOfPlay(b.target)!); const events = sorted.map((hit) => applyDamage(world, hit.target, hit.amount)); react(world, events, sorted.map((hit) => hit.priority)); @@ -343,10 +344,14 @@ function react(world: World, events: Event[], priorities: readonly Priority[] = event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); - // The trigger's consequences are one level deeper in the log tree. + // The trigger's consequences are one level deeper in the log tree. The block + // closes even if an effect throws, so an error leaves no trigger resolving. world.openBlock(source, index); - runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); - world.closeBlock(); + try { + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } finally { + world.closeBlock(); + } } } } diff --git a/course/12-combat/reference/ts/tests/cli.test.ts b/course/12-combat/reference/ts/tests/cli.test.ts index cb3bbcb..b587718 100644 --- a/course/12-combat/reference/ts/tests/cli.test.ts +++ b/course/12-combat/reference/ts/tests/cli.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { expect, test } from "vitest"; import { run } from "../src/cli"; +import { newSpellGrid, type CardDef, type LevelDef } from "../src/harness"; function play(level: string, input: string[]): { outcome: string; out: string } { let out = ""; @@ -68,3 +69,75 @@ test("cli_numbers_are_plain_digits", () => { const graft = play("l2-spliced-corpse", ["c Graft", "+2", "1", "1", "q"]); expect(graft.out).toContain("Hand: Clay Golem (Assembled)"); }); + +// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). +const ZAP: CardDef = { + name: "Zap", + type: "spell", + cost: 1, + split: 0, + text: [{ kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }], +}; +// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. +const LOOP_EMBER: CardDef = { + name: "Loop Ember", + type: "minion", + cost: 1, + attack: 1, + health: 1, + noun: "ROCK", + split: 1, + text: [ + { kind: "trigger", on: "deathrattle" }, + { kind: "effect", effect: { type: "summon", card: "Loop Ember", count: 1 } }, + { kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }, + ], +}; +// Its trigger names a card that does not exist, so it fails part-way through. +const BAD_GOLEM: CardDef = { + name: "Bad Golem", + type: "minion", + cost: 1, + attack: 1, + health: 3, + noun: "KEY", + split: 1, + text: [ + { kind: "trigger", on: "selfDamaged" }, + { kind: "effect", effect: { type: "summon", card: "No Such Card", count: 1 } }, + ], +}; +const ROW: LevelDef = { + width: 6, + height: 3, + objects: [ + { kind: "baba", x: 0, y: 0 }, + { kind: "text:BABA", x: 0, y: 2 }, + { kind: "text:IS", x: 1, y: 2 }, + { kind: "text:YOU", x: 2, y: 2 }, + ], + cards: [], + hand: ["Zap", "Zap", "Zap"], +}; + +test("cli_stops_when_a_sequence_does_not_finish", () => { + const level = { ...ROW, cards: [{ card: "Loop Ember", x: 3, y: 0 }] }; + const input = ["c Zap", "d", "q"]; + let out = ""; + const outcome = run(JSON.stringify(level), () => input.shift(), (s) => (out += s), [ZAP, LOOP_EMBER]); + expect(outcome).toBe("failed"); + // The error, then the end: no log tree of a half-done Sequence, no further commands read. + expect(out).toMatch(/> Death Phase limit: more than 64 Death Phases in one Sequence\nThe Sequence did not finish, so the game cannot go on\.\n$/); + expect(out).not.toContain("Log:"); + expect(input).toEqual(["d", "q"]); +}); + +test("an_error_mid_sequence_leaves_no_trigger_resolving", () => { + const g = newSpellGrid({ ...ROW, cards: [{ card: "Bad Golem", x: 3, y: 0 }] }, [ZAP, BAD_GOLEM]); + expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); + // The failed trigger closed its block: the next Sequence's log starts at depth 0, and + // the golem's trigger is queued again (the nesting guard does not think it is still resolving). + g.clearLog(); + expect(() => g.cast("Zap")).toThrow('unknown card "No Such Card"'); + expect(g.logTree()).toMatch(/^SpellCast\{card: "Zap"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n$/); +}); diff --git a/course/13-break-your-engine/lesson.md b/course/13-break-your-engine/lesson.md index ce84cdc..13f8eeb 100644 --- a/course/13-break-your-engine/lesson.md +++ b/course/13-break-your-engine/lesson.md @@ -138,7 +138,7 @@ How it works: - **A key for "the same position".** Two input sequences that lead to the same tiles, minions, Health, hand and hero Health are one position, and only the first is explored. This is the delicate part, for the reason the last section gave: the key has to include everything that can change the future. The solver adds the Assemble choices made so far, because two Assembled cards can share a name and differ in text. Each tile's objects go into the key oldest first (`stackAt`, from Module 8), because age breaks ties: which `YOU` object moves first, which `SHUT` partner an `OPEN` key takes. The key still doesn't record age between objects on different tiles, or the order in which minions were played, so in a level where one of those breaks a tie it could merge two positions that differ. Keep that in mind when your level has two `YOU` objects, or two minions that die together. - **A filter.** `--never-rule` discards positions in which a given rule is in force. That turns "is the level solvable?" into "is it solvable *without the idea*?", which is the question a designer cares about. -What it found for the shipped levels is in [Module 10](../10-spellgrid/lesson.md)'s Stress it: each is solvable in the scripted number of inputs, Level 1 has no solution that avoids `DEATHRATTLE IS DOUBLE`, and Level 3 has no solution at all on the naive engine. Those are stronger statements than any test in Module 10 makes, and they hold for every line of play, not for one script. Levels 4 to 10 were designed with the solver as the adversary, each around one idea, and for each the solver proves the level can't be solved without one rule the player has to form. For levels 4, 5, 7 and 10 that rule is the idea; for levels 6, 8 and 9 it is one ingredient of it, and Module 10 says what the rest rests on. `course/10-spellgrid/levels/proofs.json` writes down all ten claims, and `node tools/verify-levels.mjs` checks them again, in about two and a half minutes: run it after any change to the engine or the levels. CI runs it too. +What it found for the shipped levels is in [Module 10](../10-spellgrid/lesson.md)'s Stress it: each is solvable in the scripted number of inputs, Level 1 has no solution that avoids `DEATHRATTLE IS DOUBLE`, and Level 3 has no solution at all on the naive engine. Those are stronger statements than any test in Module 10 makes, and they hold for every line of play, not for one script. Levels 4 to 10 were designed with the solver as the adversary, each around one idea, and for each the solver proves the level can't be solved without one rule the player has to form. For levels 4, 5, 7 and 10 that rule is the idea; for levels 6, 8 and 9 it is one ingredient of it, and Module 10 says what the rest rests on. `course/10-spellgrid/levels/proofs.json` writes down all ten claims, and `node tools/verify-levels.mjs` checks them again, in about two and a half minutes: run it after any change to the engine or the levels. CI runs it too. Like the solver, it stages Module 10's reference, while the play page, `diff-tracks` and `mutation-check` use Module 12's: the proofs are about the Module 10 engine. For these ten levels the two play the same game, since no level writes a condition and SpellGrid has no attack input; a module that changed that would need the proofs run on its engine. An unintended solution is called a *cook*. Puzzle designers have always hunted cooks by hand; a solver hunts them while you sleep. The method generalises: state what the level is *for* as a predicate on positions, forbid it, and search. diff --git a/docs/research/baba-is-you.md b/docs/research/baba-is-you.md index 0d51b55..7309aca 100644 --- a/docs/research/baba-is-you.md +++ b/docs/research/baba-is-you.md @@ -131,6 +131,9 @@ Loaded with curl through the MediaWiki API, as in §5: `https://babaiswiki.fando | [NOT](https://babaiswiki.fandom.com/wiki/NOT) (re-fetched) | 18314 | "When added after a verb it makes the corresponding rule not apply, overriding other instances of the rule it disables." / "ROCK ON BABA IS NOT PUSH makes ROCK that is on BABA not PUSHable." / "When added before a condition, it reverses the condition." / "FLAG ON NOT BABA IS WIN makes flag WIN when it is ON something other than baba" | | [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) (re-fetched) | 17942 (2026-01-03) | Same text as §3 for the quotes recorded there. On conditions: "Conditions process when the game checks for what objects have a rule, which is usually right before it uses it." / "it completely finishes moving for a particular move stage before checking which units are in the next, so conditions (e.g. IT ON BABA IS YOU AND MOVE) reparse in between." / Under "Other considerations": "conditions on these rules are rechecked at the time of destruction, even if multiple destructions occur in the same turn." (about HAS). | +| [Conditions](https://babaiswiki.fandom.com/wiki/Category:Conditions) (re-fetched 2026-11-11, final review) | (current) | "Sentence Interruption" bullet: "If TEXT or a WORD object is stacked on or before the condition, this might not apply." / "BABA [DEFEAT ON] GHOST IS DONE is read as both BABA ON GHOST IS DONE and BABA DEFEAT GHOST IS DONE, the later of which gets parsed as GHOST IS DONE with extraneous TEXT before it." (templates rendered as words) | +| [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) (re-fetched 2026-11-11, final review) | (current) | Under "Object Priority": "The game stores objects as a single list, and objects earlier in the list get processed first." / "When (re)starting a level, objects are ordered in the list by placement: columns from left to right, from top to bottom within each column." / "New objects that are created are added to the end of the object list in the order they were created." | + - **What the pages do not say.** None of them says in so many words how a conditional `NOT` rule meets an unconditional one (`BABA IS YOU` with `BABA ON KEY IS NOT YOU`). The NOT page says a negation overrides "other instances of the rule it disables", and its example "ROCK ON BABA IS NOT PUSH makes ROCK that is on BABA not PUSHable" names only the rocks on a Baba. Module 11 reads the two together: the negation wins for each object its condition holds for, and only for those. That reading is the course's. - **`ALL` after a condition (stated, not followed by the course).** The Conditions page says `ON ALL` needs one of every object in ALL (not counting the object itself), where `NOT TEXT` would need any one. Module 11 gives `ON ALL` the simpler meaning "any other object that isn't text", as `ALL` names objects when it is a subject. That meaning is the course's choice, labelled in the lesson and scenarios. - **Whether an object can satisfy its own condition.** ON says "overlapping another object"; NEAR says "on a target or in one of the eight spaces around it". Neither says whether `ROCK ON ROCK` can be satisfied by the rock itself. The course counts only other objects. diff --git a/docs/research/hearthstone.md b/docs/research/hearthstone.md index 4ba3ad8..8d613d2 100644 --- a/docs/research/hearthstone.md +++ b/docs/research/hearthstone.md @@ -53,7 +53,9 @@ URL: https://hearthstone.wiki.gg/wiki/Advanced_rulebook - Also: "the need to resolve an event before starting the next is a depth-first resolution." - A newly raised event "immediately begins exploring its consequences, before resolving those remaining for the current trigger/Event." - The rulebook contrasts this with: "(If multiple events could be partially resolved, that would be breadth-first)" - - Also: "a trigger cannot be nested inside itself" + - Also: "a trigger cannot be nested inside itself"; the full sentence: "It seems that a trigger cannot be nested inside itself." + - The next sentence: "Instead it works the skipped times as a compensation after all other consequences of it are resolved." It then points to a section "Trigger Re-entrancy Exploration". (Re-fetched 2026-11-11.) + - Death Phases (Rule 8): "This process repeats forever until no new Deaths occur, and we can finally move on to the next intended Phase in the Sequence." - Outermost Phase: - "When the outermost Phase resolves, Hearthstone will run several Steps, including processing Deaths and updating Auras." - "but only the outermost Phase ending initiates the Death Creation Step." diff --git a/package.json b/package.json index 2f20a47..70229ca 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "license": "MIT", "engines": { - "node": ">=22" + "node": ">=22.15" }, "scripts": { "test:tools": "node --test tools/test/*.test.mjs", diff --git a/site/README.md b/site/README.md index c75b5dd..aa12acd 100644 --- a/site/README.md +++ b/site/README.md @@ -9,7 +9,7 @@ Run locally: `npm ci && npm run dev` (from this directory), then open http://loc - `src/content/docs/course/`: one page per `course/*/lesson.md`, and `course/harness.md`. - `src/game/generated/`: the play page's game. The TypeScript reference of the highest module that has one (`engine/`, `harness.ts`, `content/`), copied unmodified, except that `content/index.ts` (which reads the cards with `node:fs`) is replaced by one that re-exports `src/game/content.browser.ts`; plus `cards.json` and `levels.json`, which that module imports statically. -The play page (`/play/`, `src/pages/play.astro`) is SpellGrid in the browser. `src/game/session.ts` holds the harness and the list of inputs (Undo replays them; Assemble's questions are answered by replaying with one more answer), and `src/game/play.ts` draws it. Neither knows a rule. `scripts/game.test.mjs` loads the same files under plain Node and plays Level 3 to the win (`npm run test:site` from the repository root). +The play page (`/play/`, `src/pages/play.astro`) is SpellGrid in the browser. `src/game/session.ts` holds the harness and the list of inputs (Undo replays them; Assemble's questions are answered by replaying with one more answer), and `src/game/play.ts` draws it. Neither knows a rule. The page uses the highest module's reference (Module 12 today), while `course/10-spellgrid/levels/proofs.json` and the tools that check it (the solver, `verify-levels`) stage Module 10's: the proofs are about the Module 10 engine. On the ten levels the two play the same game, because no level writes a condition (Module 11) and SpellGrid has no attack input (Module 12). `scripts/game.test.mjs` loads the same files under plain Node and plays Level 3 to the win (`npm run test:site` from the repository root; Node 22.15 or later, for `module.registerHooks`). A lesson links to a site page by its source file, such as `[Play SpellGrid](../../site/src/pages/play.astro)`: the sync rewrites that to `/play/`, and the link still works on the repository's web view. diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index dc60d06..edddfb5 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -19,11 +19,11 @@ import { Card, CardGrid } from "@astrojs/starlight/components"; A free course on **systemic game design**. You learn by rebuilding the rules machinery of two games, then extending it: -- **Part I: Hearthstone**: cards, zones, triggers, death phases, auras, text assembly. -- **Part II: Baba Is You**: rules as pushable text, parsing, movement, win and lose. +- **Part I: Hearthstone**: cards, zones, triggers, death phases, auras, text assembly; Module 12 adds combat, Taunt and the end of a turn. +- **Part II: Baba Is You**: rules as pushable text, parsing, movement, win and lose; Module 11 adds conditions, `BABA ON KEY IS WIN`. - **Part III: SpellGrid**: a small original game that combines both systems. [Play it in your browser](/play/). -Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine. +Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Stress it, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine. Stress it is optional: edge cases and stretch exercises that push the design past what the module's tests check. ## Pick your path diff --git a/tools/run-scenario.mjs b/tools/run-scenario.mjs index 63bb4a8..158b55f 100644 --- a/tools/run-scenario.mjs +++ b/tools/run-scenario.mjs @@ -18,6 +18,9 @@ // plays the inputs there. It prints, at the start and after each input, the Part I log of that // input as an indented tree, the board, the rules, the minions, the hand and both heroes' Health. // No engine file is touched: a scenario can say exactly what an Expansion Drop can. +// +// An engine error in the middle of a Sequence ends the run there (exit code 1). A scenario that +// runs longer than 60 s (RUN_SCENARIO_TIMEOUT_MS to change it) is stopped with a message. import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { basename, join } from "node:path"; @@ -36,7 +39,8 @@ const triggers = (cardSchema) => cardSchema.properties.text.items.oneOf.find((b) /** * Everything wrong with a scenario, as readable lines (empty if nothing). `ctx` is what the staged - * reference already has: `words` (its words.json) and `cardNames` (the names of its cards). + * reference already has: `words` (its words.json), `cardNames` (the names of its cards) and + * `cardFiles` (the file names in its cards/; by default, those of `cardNames`). */ export function scenarioProblems(s, ctx) { if (typeof s !== "object" || s === null || Array.isArray(s)) return ["the scenario must be a JSON object"]; @@ -48,13 +52,28 @@ export function scenarioProblems(s, ctx) { if (!Array.isArray(s.inputs) || !s.inputs.every((i) => typeof i === "string")) problems.push("inputs: expected a list of command lines, such as [\"d\", \"c Whirlwind\"]"); const cards = s.cards ?? []; + const files = ctx.cardFiles ?? ctx.cardNames.map(fileName); + const known = [...ctx.cardNames, ...(Array.isArray(cards) ? cards.map((c) => c?.name) : [])]; if (!Array.isArray(cards)) problems.push("cards: expected a list of cards"); else { cards.forEach((card, i) => { const at = `cards[${i}]${typeof card?.name === "string" ? ` (${JSON.stringify(card.name)})` : ""}`; problems.push(...validate(cardSchema, card, "$").map((e) => `${at}${e.slice(1)}`)); - if (ctx.cardNames.includes(card?.name)) problems.push(`${at}: the course already has a card named ${JSON.stringify(card.name)}; give yours another name`); - if (cards.findIndex((c) => c?.name === card?.name) < i) problems.push(`${at}: two scenario cards are named ${JSON.stringify(card.name)}`); + if (typeof card?.name !== "string") return; + // A card is written to cards/, and file names are in lower case: "graft" would + // overwrite the course's Graft, so names are compared as file names. + const file = fileName(card.name); + if (ctx.cardNames.includes(card.name)) problems.push(`${at}: the course already has a card named ${JSON.stringify(card.name)}; give yours another name`); + else if (files.includes(file)) problems.push(`${at}: its file would be cards/${file}, which a course card already has; give yours another name`); + const first = cards.findIndex((c) => typeof c?.name === "string" && fileName(c.name) === file); + if (first < i) problems.push(`${at}: ${JSON.stringify(cards[first].name)} and ${JSON.stringify(card.name)} would both be cards/${file}; give one another name`); + // A summon of a card that exists nowhere fails in the middle of a Sequence: say so now. + (Array.isArray(card.text) ? card.text : []).forEach((clause, j) => { + const named = clause?.effect?.type === "summon" ? clause.effect.card : undefined; + if (typeof named === "string" && !known.includes(named)) { + problems.push(`${at}.text[${j}].effect.card: no card is named ${JSON.stringify(named)} (neither a course card nor one of the scenario's)`); + } + }); }); } @@ -126,12 +145,21 @@ export function mergeSeams(base, added) { return out; } +/** How long the scenario may run: 60 s, or RUN_SCENARIO_TIMEOUT_MS milliseconds if set. */ +function timeoutMs() { + const ms = Number(process.env.RUN_SCENARIO_TIMEOUT_MS ?? 60_000); + if (!Number.isInteger(ms) || ms < 1) fail(`RUN_SCENARIO_TIMEOUT_MS: expected a whole number of milliseconds, got ${process.env.RUN_SCENARIO_TIMEOUT_MS}`); + return ms; +} + /** A file name for a card: its name in lower case, words joined by "-". */ const fileName = (name) => `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.json`; +/** The card files in a staged reference's cards/. */ +const cardFiles = (dir) => readdirSync(join(dir, "cards")).filter((f) => f.endsWith(".json")); + /** The names of the cards in a staged reference's cards/. */ -const cardNames = (dir) => - readdirSync(join(dir, "cards")).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(readFileSync(join(dir, "cards", f), "utf8")).name); +const cardNames = (dir) => cardFiles(dir).map((f) => JSON.parse(readFileSync(join(dir, "cards", f), "utf8")).name); function main() { const [file, ...rest] = process.argv.slice(2); @@ -142,12 +170,13 @@ function main() { } catch (e) { fail(existsSync(file) ? `${file} is not JSON: ${e.message}` : `no file ${file}`); } + const ms = timeoutMs(); const dir = stagePlay("ts", null, MODULE); const content = join(dir, "src", "content"); let code = 1; try { const words = JSON.parse(readFileSync(join(content, "words.json"), "utf8")); - const problems = scenarioProblems(s, { words, cardNames: cardNames(dir) }); + const problems = scenarioProblems(s, { words, cardNames: cardNames(dir), cardFiles: cardFiles(dir) }); if (problems.length > 0) { console.error(`${basename(file)} is not a valid scenario:\n${problems.map((p) => ` - ${p}`).join("\n")}`); return; @@ -167,7 +196,12 @@ function main() { return; } } - const r = spawnSync(join(dir, "node_modules", ".bin", "tsx"), ["run.ts", "scenario/level.json", "scenario/inputs.json"], { cwd: dir, stdio: "inherit" }); + // One node process (tsx as a loader, not tsx's own launcher), so the timeout's kill stops it all. + const r = spawnSync(process.execPath, ["--import", "tsx", "run.ts", "scenario/level.json", "scenario/inputs.json"], { cwd: dir, stdio: "inherit", timeout: ms, killSignal: "SIGKILL" }); + if (r.error?.code === "ETIMEDOUT") { + console.error(`\nThe scenario did not finish within ${ms / 1000} s, so it was stopped. A card may set itself off without end (a trigger whose effect raises the event it listens for, again and again).`); + return; + } code = r.status ?? 1; } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/tools/scenario/run.ts b/tools/scenario/run.ts index cb6654f..958d3b1 100644 --- a/tools/scenario/run.ts +++ b/tools/scenario/run.ts @@ -7,7 +7,9 @@ // and the answers to Assemble's questions, one input each. After the start and after each // input it prints the input, the Part I log of that input as an indented tree, the board, the // rules, the minions, the hand and both heroes' Health. No rules live here: it calls the -// harness, as the command line does, and draws with the command line's `render`. +// harness, as the command line does, and draws with the command line's `render`. An input the +// rules don't allow is reported and play goes on; an engine error in the middle of a Sequence +// (the Death Phase limit, say) ends the run with exit code 1, without that input's log. import { readFileSync } from "node:fs"; import { render } from "./src/cli"; @@ -68,19 +70,32 @@ for (;;) { } const [command, ...args] = line.trim().split(/\s+/); g.clearLog(); - try { - if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]); - else if (command === "c" && args.length > 0) { - const n = args.length; - const at = n >= 3 && NUMBER.test(args[n - 2]) && NUMBER.test(args[n - 1]) ? ([Number(args[n - 2]), Number(args[n - 1])] as const) : undefined; - const card = (at === undefined ? args : args.slice(0, n - 2)).join(" "); - const target = at === undefined ? null : g.minionAt(...at); - if (at === undefined) g.cast(card); - else if (target !== null) g.cast(card, target); - else g.play(card, ...at); - } else out("Commands: w a s d (move), c [x y] (cast or play)"); - } catch (e) { - out(`Error: ${e instanceof Error ? e.message : String(e)}`); + // A move the rules don't allow (a card not in the hand, a full tile) is asked about first, + // as the command line does, and reported; the game goes on. + let problem: string | null = null; + let act: (() => void) | undefined; + if (Object.hasOwn(DIRS, command)) act = () => g.step(DIRS[command as keyof typeof DIRS]); + else if (command === "c" && args.length > 0) { + const n = args.length; + const at = n >= 3 && NUMBER.test(args[n - 2]) && NUMBER.test(args[n - 1]) ? ([Number(args[n - 2]), Number(args[n - 1])] as const) : undefined; + const card = (at === undefined ? args : args.slice(0, n - 2)).join(" "); + const target = at === undefined ? null : g.minionAt(...at); + if (at === undefined) [problem, act] = [g.checkCast(card), () => g.cast(card)]; + else if (target !== null) [problem, act] = [g.checkCast(card, target), () => g.cast(card, target)]; + else [problem, act] = [g.checkPlay(card, ...at), () => void g.play(card, ...at)]; + } else out("Commands: w a s d (move), c [x y] (cast or play)"); + if (problem !== null) out(`Error: ${problem}`); + else { + try { + act?.(); + } catch (e) { + // Any other error is the engine's, in the middle of a Sequence (a Death Phase limit, a + // card the engine cannot find): the game is left half-way through a rule, so stop here. + out(`Error: ${e instanceof Error ? e.message : String(e)}`); + out("The Sequence did not finish, so the game cannot go on."); + process.exitCode = 1; + break; + } } const tree = g.logTree(); if (tree !== "") out(`Log:\n${tree.trimEnd()}`); diff --git a/tools/test/run-scenario.test.mjs b/tools/test/run-scenario.test.mjs index 3d92fa6..1508526 100644 --- a/tools/test/run-scenario.test.mjs +++ b/tools/test/run-scenario.test.mjs @@ -111,3 +111,54 @@ test("run_scenario_designer_example_output_is_current", () => { assert.deepEqual(s.words, json("words.json")); assert.deepEqual(s.seams, json("seams.json")); }); + +// Two designer cards that the engine cannot finish with (the final review's I1). +const ZAP = { name: "Zap", type: "spell", cost: 1, split: 0, text: [{ kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }] }; +const ROW = { ...LEVEL, width: 6, height: 3, objects: [{ kind: "baba", x: 0, y: 0 }, text("BABA", 0, 2), text("IS", 1, 2), text("YOU", 2, 2)], hand: ["Zap", "Zap", "Zap"] }; + +test("run_scenario_rejects_a_summon_of_an_unknown_card", () => { + // Its trigger would fail in the middle of a Sequence; the check finds it before anything runs. + const golem = { ...TOTEM, name: "Bad Golem", text: [TOTEM.text[0], { kind: "effect", effect: { type: "summon", card: "No Such Card", count: 1 } }] }; + const r = scenario({ cards: [golem, ZAP], level: { ...ROW, cards: [{ card: "Bad Golem", x: 3, y: 0 }] }, inputs: ["c Zap", "c Zap"] }); + assert.equal(r.code, 1, r.out); + assert.match(r.out, /is not a valid scenario:\n {2}- cards\[0\] \("Bad Golem"\)\.text\[1\]\.effect\.card: no card is named "No Such Card"/); + assert.doesNotMatch(r.out, /== start/); + // A summon of a course card, or of another card of the scenario, is fine. + const ctx = { words: [], cardNames: ["Clay Golem"] }; + const summon = (card) => ({ ...golem, text: [TOTEM.text[0], { kind: "effect", effect: { type: "summon", card, count: 1 } }] }); + assert.deepEqual(scenarioProblems({ cards: [summon("Clay Golem"), ZAP], level: LEVEL, inputs: [] }, ctx), []); + assert.deepEqual(scenarioProblems({ cards: [summon("Bad Golem")], level: LEVEL, inputs: [] }, ctx), []); +}); + +test("run_scenario_stops_when_a_sequence_does_not_finish", () => { + // Each Loop Ember's Deathrattle summons the next and hits it: the Death Phase limit. + const ember = { + name: "Loop Ember", type: "minion", cost: 1, attack: 1, health: 1, noun: "ROCK", split: 1, + text: [{ kind: "trigger", on: "deathrattle" }, { kind: "effect", effect: { type: "summon", card: "Loop Ember", count: 1 } }, { kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }], + }; + const r = scenario({ cards: [ember, ZAP], level: { ...ROW, cards: [{ card: "Loop Ember", x: 3, y: 0 }] }, inputs: ["c Zap", "d", "c Zap"] }); + assert.equal(r.code, 1, r.out); + // The error, a plain word that the game stops, and nothing after: no log of the half-done + // Sequence (that is what crashed before), no further inputs. + assert.match(r.out, /\n> c Zap\nError: Death Phase limit: more than 64 Death Phases in one Sequence\nThe Sequence did not finish, so the game cannot go on\.\n$/); + assert.doesNotMatch(r.out, /Log:|has no id|> d/); +}); + +test("run_scenario_times_out_readably", () => { + const r = run("run-scenario.mjs", [join(example, "scenario.json")], { RUN_SCENARIO_TIMEOUT_MS: "1" }); + assert.equal(r.code, 1, r.out); + assert.match(r.out, /The scenario did not finish within 0\.001 s, so it was stopped\. A card may set itself off without end/); +}); + +test("run_scenario_compares_card_names_as_file_names", () => { + // Card files are named in lower case, so "graft" would overwrite the course's Graft. + const ctx = { words: [], cardNames: ["Graft"], cardFiles: ["graft.json"] }; + const problems = (cards) => scenarioProblems({ cards, level: LEVEL, inputs: [] }, ctx).join("\n"); + assert.match(problems([{ ...TOTEM, name: "graft" }]), /cards\[0\] \("graft"\): its file would be cards\/graft\.json, which a course card already has/); + assert.match(problems([TOTEM, { ...TOTEM, name: "test totem" }]), /cards\[1\] \("test totem"\): "Test Totem" and "test totem" would both be cards\/test-totem\.json/); + assert.equal(problems([TOTEM]), ""); + // Through the tool, against the real cards/. + const r = scenario({ cards: [{ ...TOTEM, name: "GRAFT" }], level: LEVEL, inputs: [] }); + assert.equal(r.code, 1, r.out); + assert.match(r.out, /its file would be cards\/graft\.json/); +});