feat: summoned minions enter the grid in Module 10 (a fourth seam; Clay Golem, Clay Shard)

Summoned records its summoner (the minion whose trigger is resolving) inside
the engine; the harness log is unchanged. When a Sequence ends, SpellGrid
writes spawned text, places each summoned minion with a noun on its
summoner's tile, removes the dead, reparses and syncs rule auras.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 11:35:59 +01:00
1 parent 820b378929
commit 4eaf24eed2
23 files changed
+361 -42

No files matched your search

@@ -17,6 +17,8 @@
{ "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" },
{ "name": "CRIER", "role": "noun" },
{ "name": "GOLEM", "role": "noun" },
{ "name": "SHARD", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
@@ -5,7 +5,13 @@ import type { TriggerOn } from "./card";
import type { EntityId, PlayerId } from "./world";
export type Event =
| { type: "Summoned"; id: EntityId }
/**
* `id` entered the board. `by` is the minion whose triggered ability put it there (the
* trigger resolving when it entered), or undefined for a spell, a play or a summon from
* outside a Sequence. Module 10: SpellGrid puts it on `by`'s grid tile. The course's log
* doesn't show `by`.
*/
| { type: "Summoned"; id: EntityId; by: EntityId | undefined }
| { type: "Damaged"; target: EntityId; amount: number }
| { type: "Triggered"; source: EntityId; on: TriggerOn }
| { type: "Drew"; player: PlayerId; id: EntityId }
@@ -1,7 +1,7 @@
// Part III: SpellGrid, the two engines in one level.
//
// Part I (the card engine) and Part II (the grid engine, `puzzle/`) don't import each
// other. This file is the only place that knows both, and they meet at three seams:
// other. This file is the only place that knows both, and they meet at four seams:
//
// 1. Rules become modifiers. After every reparse, the rules in force that join a
// trigger word to a multiplier word (`DEATHRATTLE IS DOUBLE`) are compiled into
@@ -15,6 +15,10 @@
// ends, the text its effects asked for (`TextSpawned` events) is written, the
// minions that died (`Destroyed` events) leave the grid, and the rules are parsed
// again, before anything else happens.
// 4. Summoned minions enter the grid. In the same place, before the dead leave: a
// minion that a minion's ability summoned (`Summoned` events with a `by`), whose card
// has a noun, becomes an object of that noun on its summoner's tile. One summoned by
// a spell, or with no noun, is in play but not on the grid.
//
// One id space: a minion's id is its grid object's id. Every other card (a hero, a
// card in hand) gets an id from the same counter that no object will have.
@@ -223,7 +227,7 @@ export class SpellGrid {
this.sequence({ type: "destroy", targets: minions.map((id) => this.entity(id)) });
};
/** Run one Sequence in Part I, then seam 3: apply its grid changes and reparse. */
/** Run one Sequence in Part I, then seams 3 and 4: apply its grid changes and reparse. */
private sequence(action: Action): void {
const from = this.world.log().length;
runSequence(this.world, action);
@@ -232,11 +236,22 @@ export class SpellGrid {
// Text first, while every minion that wrote it is still on its tile.
for (const event of events) {
if (event.type !== "TextSpawned") continue;
// A minion with no id (summoned by this Sequence) is not on the grid: nowhere to write.
// A minion with no id (summoned by this Sequence) is not on the grid yet: nowhere to write.
const id = this.ids.get(event.source);
const source = id === undefined ? undefined : board.find(id);
if (source !== undefined) board.place({ type: "text", word: event.word }, source.x, source.y);
}
// Seam 4: summoned minions, in summon order, on their summoner's tile. The dead are still
// on the grid here, so a Deathrattle's summoner is found where it died; a minion summoned
// earlier in the list is already placed, so it can be a summoner too.
for (const event of events) {
if (event.type !== "Summoned" || event.by === undefined) continue;
const card = this.world.cardName(event.id);
if (this.ids.has(event.id) || this.world.card(card).def.noun === undefined) continue;
const by = this.ids.get(event.by);
const summoner = by === undefined ? undefined : board.find(by);
if (summoner !== undefined) this.link(event.id, board.place(this.nounOf(card), summoner.x, summoner.y));
}
for (const event of deaths(events)) {
const id = this.ids.get(event.id);
if (id !== undefined) board.remove(id);
@@ -126,7 +126,8 @@ export class World {
entity.zone = zone;
if (zone === "board") {
entity.orderOfPlay = ++this.nextOrderOfPlay;
this.record({ type: "Summoned", id });
// The summoner is the minion whose trigger is resolving (the innermost one), if any.
this.record({ type: "Summoned", id, by: this.resolving.at(-1)?.[0] });
}
}
@@ -44,6 +44,7 @@ export type GameEvent =
function toGameEvent(event: Event): GameEvent | undefined {
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
if (event.type === "Destroyed") return { type: "Destroyed", id: event.id };
if (event.type === "Summoned") return { type: "Summoned", id: event.id };
// A played minion's `Summoned` already shows it entering the board.
if (event.type === "Played") return undefined;
// SpellGrid writes the text on the grid; the course's log doesn't show the request.