Your first card fix¶
This walkthrough takes one report, "a card plays wrong", from triage to a
merged fix. The worked example is the first outside contribution to gorge,
786510b3f:
Omniscience in its owner's hand offered a free cast of itself, and Future
Sight on top of its library let its owner cast it from there. Both cards
were reading their own static ability in a zone where it does not function.
It is for a contributor fixing their first bug. You need the corpus fetched
and compiled first (make fetch-cards compile-cards), and if you work in a
git worktree, .cards linked into it; see Contributing. Terms in
italics are defined in the glossary.
Helper locations and the example test re-checked on develop @
513a2a0c3, 2026-10-21.
1. Confirm and triage¶
Read the card's script. Every card is one text file under
.cards/cardsfolder/<first letter>/<name>.txt, lower-case with underscores:
.cards/cardsfolder/o/omniscience.txt, .cards/cardsfolder/f/future_sight.txt.
Each line is a characteristic (Name:, ManaCost:, Types:) or an ability:
A: activated or spell, T: trigger, S: static, R: replacement, K:
keyword, SVar: a named sub-ability or value. Abilities are |-separated
Key$ Value parameters. Omniscience's one S: line is a Mode$ Continuous
static with MayPlay$ True, MayPlayWithoutManaCost$ True and
AffectedZone$ Hand, and no EffectZone$. That last absence was the
whole bug.
The scripts are GPL-3.0. Read them as much as you like, but never paste one into a commit, a test or an issue.
Check that it is not already known or deliberate.
AGENTS.md's Known approximations table lists primitives that knowingly behave more narrowly than the card text.docs/design/2026-09-22-engine-contracts.mdlists behaviour that is a deliberate contract, not a bug.engine/rules/testdata/known-unsupported/names repo-deck cards the build does not fully support.- gorge-compliance's
scenarios/oracle/*/known-divergent/lists audit scenarios already known to fail (andknown_bugrows in itsscenarios/xmageport/the XMage-ported ones).
Decide whose bug it is. Compare the script with the printed card (the
Oracle text on the card's Oracle: line, or Scryfall) and the
Comprehensive Rules ("CR"):
- The script is wrong (a wrong target restriction, a missing parameter,
the wrong subtype on a token): it is a Forge bug. Fix it on the
gorgebranch of the fork adams-shaun/forge, open the same fix as a PR to Card-Forge/forge, and gorge picks it up whenFORGE_REFin theMakefileis bumped. TheFORGE_REFcomment block lists past examples. - The script is right and gorge does the wrong thing: an engine bug.
Continue below. Omniscience's script is right: under CR 113.6, a static
ability of a permanent card functions only on the battlefield unless it
says otherwise, and Forge expresses "otherwise" with
EffectZone$.
2. Reproduce it in a failing test¶
Pick whichever option fits. A, B and C all end in a test that fails before your fix and passes after.
Option A: an Oracle scenario (JSON, no Go)¶
The Oracle-text audit runs JSON scenarios that state what a card does
according to its printed text and the CR. You write the expected outcome
without reading the engine's opinion. The runner (gorge's
engine/rules/oracle_run.go, which also holds the schema: oracleFile,
oracleScenario) builds a real two-seat game around real corpus cards,
drives it only through player actions, and checks observable state. The
scenario corpus and its driver live in
gorge-compliance: the
contract is in the header of its audit/oracle_audit_test.go.
One file per card per family, in a gorge-compliance checkout:
scenarios/oracle/<family>/<card-slug>.json. Families are the existing
directories (combat-damage, keywords, replacement, …). Copy a
neighbour such as scenarios/oracle/combat-damage/balefire-dragon.json (trimmed
here to one scenario and two expectations):
{
"card": "Balefire Dragon",
"family": "combat-damage",
"author": "<you> (Oracle + CR only)",
"scenarios": [
{
"name": "combat-damage-deals-that-much-to-each-creature",
"cr": ["603.10a"],
"why": "6 unblocked damage to p1 means each creature p1 controls takes 6 and dies.",
"setup": {
"p0": {"battlefield": ["Balefire Dragon"]},
"p1": {"battlefield": ["Grizzly Bears", "Bronze Sable"]}
},
"steps": [
{"op": "attack", "seat": 0, "attackers": ["p0:Balefire Dragon"], "defender": "p1"},
{"op": "pass_to", "seat": 0, "step": "end"}
],
"expect": [
{"card": "p1:Grizzly Bears", "zone": "graveyard"},
{"life": {"p1": 14}}
]
}
]
}
whyandcrare required: every scenario states its reasoning.- The step ops are a closed set (
oracleAuditOpsin gorge'sengine/rules/oracle_audit_export.go):mana,cast,activate,play,resolve,attack,block,pass,pass_to,move,life. Unknown fields are rejected. oracle_shais optional. When present it pins the card's printed text (oracletext.Digestinengine/cards/oracletext) so the scenario fails if the text changes.
Run it in gorge-compliance, with a go.work pointing at your gorge checkout
so it plays your engine change (subtest names replace spaces with _):
make oracle-audit ORACLE_RUN=Balefire_Dragon
# or: go test -run 'TestOracleAudit/Balefire_Dragon' ./audit/
TestOracleScenarioFilesWellFormed checks the schema without the corpus.
Every scenario must also replay from its event log alone, so the runner
doubles as a replay check. The scenario lands in gorge-compliance (its own
PR); the engine fix lands in gorge with a Go test (Option B) so gorge's own
suite holds it.
Option B: a Go test in engine/rules¶
Use this when the bug is about what the engine offers (a cast option that
should not exist) or needs precise setup. The tests live in package rules
itself, so they can call unexported helpers. The ones you will reach for
(file:line at the time of writing):
| Helper | Where | What it does |
|---|---|---|
handEngine(t, cards...) |
engine/rules/legal_test.go:21 |
Two-seat game, 40 Mountains each, exactly these cards in seat 0's hand, turn 1 main phase, seat 0 with priority. |
card(t, script) |
engine/rules/turn_test.go:12 |
Compiles a minimal inline script you write yourself ("Name:Mountain\nTypes:Basic Land Mountain\nOracle:x\n"). Never paste a corpus script. |
corpusCard(t, name) |
engine/rules/life_draw_trigger_test.go:13 |
Looks a real card up in the corpus registry (testutil.CorpusRegistry). |
corpusAlternativeCard(t, name) |
engine/rules/alternative_costs_test.go:13 |
The same lookup; danb's test uses it. |
onBoard(t, e, p, script) / onBoardCard(t, e, p, c) |
engine/rules/layers_test.go:20, engine/rules/signed_param_test.go:15 |
Put a card straight onto a player's battlefield. |
castAndResolve(t, e, id) |
engine/rules/defined_player_state_qualifier_test.go:68 |
Submit the cast option for id and pass until the stack is empty. |
passUntil(t, e, cond) |
engine/rules/explore_triggers_test.go:92 |
Pass priority (and Advance) until cond() holds; fails on an unexpected non-priority ask. |
freeHandCastOption(e, p, id) |
engine/rules/mayplay_free_hand_test.go:17 |
Finds a free (no-mana-cost) cast option for a hand card. |
danb's test (engine/rules/mayplay_self_effectzone_test.go) is the shape to
copy:
func TestOmniscienceInHandGrantsNothing(t *testing.T) {
t.Parallel()
e := handEngine(t, corpusAlternativeCard(t, "Omniscience"), corpusAlternativeCard(t, "Craw Wurm"))
omni, wurm := e.G.Zone(state.ZHand, 0)[0], e.G.Zone(state.ZHand, 0)[1]
if _, ok := freeHandCastOption(e, 0, omni); ok {
t.Errorf("Omniscience in hand offers its own free cast")
}
// ...and the control: on the battlefield it still works.
e.emit(events.Event{Kind: events.MoveZone, Obj: omni, From: state.ZHand, To: state.ZBattlefield})
if _, ok := freeHandCastOption(e, 0, wurm); !ok {
t.Errorf("Omniscience on the battlefield offers Craw Wurm no free cast")
}
}
Three habits worth copying:
- Assert the control as well as the bug. The fix must not break the card where it should work (on the battlefield).
- Move things with events (
e.emit(events.Event{...})) once the game is running, so the log stays truthful. - Fixtures may write state directly: the Future Sight test places the
card with
e.G.AddObject, setsfs.Zoneand callse.G.SetZone. That is allowed in test setup only. Engine code never writesstate.Gameoutsideevents.Apply.
Option C: a player's feedback capture¶
If the bug came from a game on a server, its feedback snapshot replays exactly:
go run ./cmd/repro <feedback-dir> # replay, verify, print the board
go run ./cmd/repro -list <feedback-dir> # the intent timeline
go run ./cmd/repro -emit-test ./engine/rules <feedback-dir> # copy the snapshot + write a failing test skeleton
The skeleton uses feedback.EngineAt(t, dir, n) from
testkit/testutil/feedback. Replace its TODO with your assertion.
AGENTS.md, section Reproduce a feedback report, has the details,
including why a DIVERGED replay can be an expected corpus change.
3. Find the code that implements it¶
Card behaviour is dispatched by primitive name, so search for the name.
- An
api:effect (A:SP$ DealDamage,DB$ Pump):git grep -n 'Register("DealDamage"' engine/effectsfinds the implementation (effects.Registerin aninit()). - A keyword, trigger mode, static mode or replacement (
kw:,trig:,stat:,repl:): these are implemented inengine/rulesand declared witheffects.RegisterNonAPI("kw:Flying", …). Search for the quoted name. - A keyword that expands into script lines (Prowess becomes a trigger
plus a pump):
engine/cards/kw_<name>.go, registered withregisterKeyword. - A parameter (
EffectZone$,ValidTgts$,AffectedZone$):git grep -n '"EffectZone"' engine/lists every reader. Omniscience's two wrong reads were inengine/rules/mayplay.go(mayPlayStaticandmayPlayAltCosts).
make report lists the primitives no implementation registers yet, ranked
by how many cards each would unlock.
4. Fix it, keeping the invariants¶
- Mutate state only by emitting events (
e.emitinengine/rules;h.Emitand the othereffects.HostEmitmethods inengine/effects). Never assign to astate.Gamefield. - No wall clock, no
math/randoutside the game's seeded RNG, nomaprange whose order reaches an event. engine/effectsmust not importengine/rules. If an effect needs more thaneffects.Hostoffers, the logic belongs inrules.- Comment the rule, with its CR number, at the line that enforces it, as danb's fix does.
5. Measure the class, not the card¶
A bug in one card is usually a bug in every card with the same shape. Before
calling it done, find the siblings. danb's commit counted the 105 corpus
cards with a Continuous MayPlay$ True static and no EffectZone$, checked
placed each alone in each zone it affects, and counted the card/zone pairs
where the card granted itself a play permission: 45 before the fix, 0 after.
A control group (the 32 cards whose self-grant does name EffectZone$) had
to stay identical, and did. Write such a
census as a temporary test that walks testutil.CorpusRegistry(t). Put the
numbers in the commit message. A census worth keeping becomes a committed
test with a per-card ratchet.
Then check that the existing ratchets agree (see Testing):
TestEveryRepoDeckIsFullySupported: if your fix makes a card inengine/rules/testdata/known-unsupported/supported, delete its file.TestEveryRepoDeckParamsAreRead: same, forengine/rules/testdata/paramcensus/.TestHeads: a moved chain head means the golden bot games now play differently. Re-pin it only if that is intended, and name the cause.
6. Gates and the PR¶
Run the PR checklist:
gofmt, go vet on the touched packages, go run ./cmd/gentypes -check if
server/protocol changed, the focused tests (with .cards/ present), make
sim, and TestHeads. Then write the commit message the way git show
786510b3f does: the rule and its CR number, what was wrong and where, what
you measured, and the gates you ran. Open the pull request against
develop.