Skip to content

feat(engine): add storied and recruit - #7074

Merged
matthewevans merged 19 commits into
mainfrom
ship/feat-engine-add-storied-and-recruit
Aug 7, 2026
Merged

feat(engine): add storied and recruit#7074
matthewevans merged 19 commits into
mainfrom
ship/feat-engine-add-storied-and-recruit

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Added support for the Storied keyword and persistent Enduring Story designation.
    • Added visible Enduring Story badges for players and opponents.
    • Added the Recruit action, including card draw, discard choice, and conditional Soldier token creation.
    • Added the “You have an enduring story” game condition.
  • Bug Fixes
    • Improved discard resolution involving replacements and nested effects.
  • Localization
    • Added Enduring Story badge text and tooltips in supported languages.

@matthewevans
matthewevans enabled auto-merge August 7, 2026 02:52
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz is excluded by !**/*.gz

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bd65d033-a211-4660-842f-b7a9e6ab6e2f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The engine adds persistent Enduring Story designation state, Storied keyword support, Recruit discard-result handling, and discard-frame tracking. The client exposes localized Enduring Story badges. Tests cover rules, parsing, integration behavior, and serialization.

Changes

Enduring Story and Recruit functionality

Layer / File(s) Summary
Contracts and Oracle parsing
crates/engine/src/types/*, crates/engine/src/parser/*, client/src/adapter/types.ts
Adds Enduring Story, Storied, discard-frame, and Recruit contracts. Extends condition parsing and Recruit lowering.
Enduring Story designation and evaluation
crates/engine/src/game/sba.rs, crates/engine/src/game/conditions.rs, crates/engine/src/game/{effects,layers,triggers,restrictions}*
Grants and latches Enduring Story when battlefield requirements are met. Evaluates the designation across engine condition paths and emits EnduringStoryGained.
Recruit discard-result resolution
crates/engine/src/game/effects/discard.rs, crates/engine/src/game/zone_pipeline.rs, crates/engine/src/game/engine_{replacement,resolution_choices}*
Tracks discard frames through replacement and interactive choices. Captures the final discarded card and passes it only to the direct Recruit continuation.
Client designation display and validation
client/src/hooks/*, client/src/components/{hud,board}/*, client/src/i18n/locales/*, crates/engine/tests/integration/*
Projects Enduring Story state into client hooks and localized HUD badges. Adds HUD, Recruit, and deterministic serialization coverage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • phase-rs/phase#6933: Both PRs modify GameState and ResolutionStack persistence and resolution infrastructure, including state restoration and frame handling.

Suggested labels: enhancement

Suggested reviewers: andriypolanski, nishu-builder

Sequence Diagram(s)

sequenceDiagram
  participant Battlefield as Battlefield SBA
  participant GameState
  participant DiscardPipeline
  participant ResolutionStack
  participant ClientHUD
  Battlefield->>GameState: add player to enduring_story
  Battlefield->>GameState: emit EnduringStoryGained
  DiscardPipeline->>ResolutionStack: record direct discard result
  ResolutionStack->>GameState: resume Recruit continuation
  GameState->>ClientHUD: expose enduring_story
  ClientHUD->>ClientHUD: render EnduringStoryBadge
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main engine features added by the pull request: the Storied keyword and Recruit action.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/feat-engine-add-storied-and-recruit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Generated for head ace0d4194f4af8ce97b7b120b4827b7a8139927a.

Parse changes introduced by this PR · 17 card(s), 8 signature(s) (baseline: main ddae92a1e888)

🟢 Added (2 signatures)

  • 9 cards · ➕ ability/Draw · added: Draw
    • Affected (first 3): Bard's Company, Celebrate the Mountain-king, Esgaroth Garrison (+6 more)
  • 2 cards · ➕ keyword/Storied · added: Storied
    • Affected (first 3): Dáin, Lord of the Iron Hills, Thorin Oakenshield

🔴 Removed (2 signatures)

  • 9 cards · ➖ ability/recruit · removed: recruit
    • Affected (first 3): Bard's Company, Celebrate the Mountain-king, Esgaroth Garrison (+6 more)
  • 2 cards · ➖ ability/unknown · removed: unknown
    • Affected (first 3): Dáin, Lord of the Iron Hills, Thorin Oakenshield

🟡 Modified fields (4 signatures)

  • 4 cards · 🔄 static/Continuous · changed field conditional: unrecognizedhas an enduring story
    • Affected (first 3): Fíli the Pathfinder, Kíli the Resourceful, Ori, Keeper of Songs (+1 more)
  • 1 card · 🔄 static/CantAttack · changed field conditional: unrecognized and unless a cost is paidhas an enduring story and unless a cost is paid
    • Affected (first 3): Dáin, Lord of the Iron Hills
  • 1 card · 🔄 ability/DamageEachPlayer · changed field conditional: has an enduring story
    • Affected (first 3): Balin, Loremaster
  • 1 card · 🔄 static/DoubleTriggers(Any) · changed field conditional: has an enduring story
    • Affected (first 3): Bifur, Melodic Rider

2 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/engine/src/types/resolution.rs (1)

537-571: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

game_state_eq compares Discard frames by their monotonic id, breaking CR 104.4b loop-equality.

MultiDraw frames get a dedicated arm here that ignores per-instance frame ids via draw_sequences.loop_equal(...), specifically to keep state-equality checks (used for CR 104.4b mandatory-loop detection) working across states that differ only in a freshly minted frame identifier. Discard frames fall through to the generic (Some(left), Some(right)) if left == right arm, which compares the full DiscardFrame, including id.

Since begin_discard allocates a new DiscardFrameId per operation, two otherwise-identical repeating game states that each have a Recruit discard paused at the same decision point will compare unequal here, because their Discard frames carry different id values. This can prevent loop detection for a repeating action that involves a paused discard.

Add a Discard-specific arm that compares DiscardFrame while ignoring id, mirroring the MultiDraw carve-out.

🔁 Proposed fix
                 (
                     Some(ResolutionFrame::MultiDraw(left)),
                     Some(ResolutionFrame::MultiDraw(right)),
                 ) if left.draw_sequences.loop_equal(&right.draw_sequences)
                     && left.connive_reentry == right.connive_reentry => {}
+                (
+                    Some(ResolutionFrame::Discard(left)),
+                    Some(ResolutionFrame::Discard(right)),
+                ) if left.source_id == right.source_id && left.results == right.results => {}
                 (Some(left), Some(right)) if left == right => {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/resolution.rs` around lines 537 - 571, Update
game_state_eq with a Discard-specific comparison arm before the generic frame
equality arm. Compare the DiscardFrame contents while excluding the
per-operation id, so otherwise-identical paused discard states with freshly
allocated DiscardFrameId values are treated as equal; leave all other frame
comparisons unchanged.

Source: Learnings

crates/engine/src/types/ability.rs (1)

24154-24162: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clear direct_discard_result when propagating context to descendant nodes.

set_context_recursive copies the entire SpellContext, so if direct_discard_result reaches it, every descendant receives the stale discard result after it was cleared by set_direct_discard_result_for_immediate_node. Copy only direct_discard_result: None for sub/else branches, or otherwise clear it before re-propagating.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/ability.rs` around lines 24154 - 24162, Update
Ability::set_context_recursive so descendant sub_ability and else_ability nodes
receive a SpellContext with direct_discard_result cleared to None, while
preserving the current context for the node itself and propagating all other
context fields unchanged.
🧹 Nitpick comments (1)
crates/engine/src/types/game_state.rs (1)

14814-14818: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reconsider boxing enduring_story.

enduring_story is Box<HashSet<PlayerId>>, while the analogous sibling field city_blessing is a plain HashSet<PlayerId>. A HashSet header is small and cheap to move; boxing it adds a heap allocation on construction (Box::default()) and on every GameState::clone(), even when the set is empty (the common case). This file repeatedly explains that other fields use Arc/im specifically to keep GameState::clone() cheap on the AI-search hot path; boxing a small, usually-empty HashSet works against that same goal without an accompanying reason in the diff.

Drop the Box and use HashSet<PlayerId> directly, matching city_blessing, unless there is a documented size-budget reason to box it.

♻️ Proposed fix
-    pub enduring_story: Box<HashSet<PlayerId>>,
+    pub enduring_story: HashSet<PlayerId>,

And update the two initializers accordingly:

-            enduring_story: Box::default(),
+            enduring_story: HashSet::new(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/game_state.rs` around lines 14814 - 14818, Change the
GameState field enduring_story from Box<HashSet<PlayerId>> to HashSet<PlayerId>,
preserving its serde attributes. Update both enduring_story initializers to
construct or assign a plain HashSet directly, matching the city_blessing
representation; do not introduce boxing unless an existing documented
size-budget requirement requires it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/i18n/locales/en/game.json`:
- Around line 483-484: Update the enduringStoryTooltip translations in
client/src/i18n/locales/en/game.json:483-484,
client/src/i18n/locales/de/game.json:444-445,
client/src/i18n/locales/es/game.json:444-445,
client/src/i18n/locales/fr/game.json:444-445,
client/src/i18n/locales/it/game.json:444-445,
client/src/i18n/locales/pl/game.json:444-445, and
client/src/i18n/locales/pt/game.json:444-445. Use complete, locale-appropriate
wording stating that Enduring Story is gained by a permanent with Storied and
three or more artifacts, legendary permanents, and/or Sagas, and that the
designation lasts for the rest of the game; leave the enduringStory labels and
engine behavior unchanged.

In `@crates/engine/src/game/effects/discard.rs`:
- Around line 89-96: Extract a non-panicking retire_discard_frame(state,
frame_id) helper in crates/engine/src/game/effects/discard.rs, using
take_active_discard().flatten() and debug_assert! for the frame ID instead of
expect/assert_eq!. Replace the duplicated retirement block at lines 89-96 and
366-373 with calls to this helper.
- Around line 642-739: Add a production-path Recruit test that resolves the
effect through resolve, a GameAction, and resolve_ability_chain/GameRunner
rather than calling hand_off_recruit_discard_result directly or manually
populating DiscardedCardResult. Have the test draw a nonland spell, choose it as
Recruit’s discard, and assert DiscardedCardMatchesFilter creates the contingent
token only after the discard is produced through the normal flow; cover the
relevant failure path as well.

In `@crates/engine/src/game/sba.rs`:
- Around line 368-410: Add CR 702.195 citations at all five sites: in
crates/engine/src/game/sba.rs lines 368-410, document check_enduring_story’s
Storied-plus-three-Historic-permanents check under 702.195a; at
crates/engine/src/game/sba.rs line 269, add or extend the citation to cover the
enduring-story call; and in crates/engine/src/game/layers.rs line 1828,
crates/engine/src/game/restrictions.rs line 1638, and
crates/engine/src/game/conditions.rs lines 179-182, annotate the
HasEnduringStory dispatch/evaluation paths under 702.195b.

In `@crates/engine/src/game/trigger_index.rs`:
- Line 669: Add a rules annotation beside the no-op
GameEvent::CityBlessingGained and GameEvent::EnduringStoryGained classification,
using the verified format “CR 702.195b-c: Enduring Story is a designation, not
an inherent trigger event, so EnduringStoryGained produces no trigger-index
key.” Preserve the existing no-key behavior.

In `@crates/engine/src/game/triggers.rs`:
- Line 10386: Add a Comprehensive Rules annotation to the
TriggerCondition::HasEnduringStory match arm, using the required “CR <number>:
<description>” format and the appropriate rule reference and description for
this condition.

In `@crates/engine/src/parser/oracle_condition.rs`:
- Line 301: The HasEnduringStory conversion branches lack the required verified
CR 702.195 annotation. Add the annotation describing that storied grants
endurance story designation for the rest of the game at
crates/engine/src/parser/oracle_condition.rs:301,
crates/engine/src/parser/oracle_effect/conditions.rs:4534, and
crates/engine/src/parser/oracle_trigger.rs:4525, covering each corresponding
HasEnduringStory bridge.

In `@crates/engine/src/parser/oracle_ir/ast.rs`:
- Around line 612-617: Replace the incorrect CR 701.9a and CR 608.2c citations
for Recruit with the verified CR section defining Recruit and its nonland-token
condition. Apply the same citation to the Recruit parser production and the
draw/discard/conditional-token behavior in
crates/engine/src/parser/oracle_ir/ast.rs lines 612-617,
crates/engine/src/parser/oracle_effect/imperative.rs lines 9749-9761, and
crates/engine/src/parser/oracle_effect/imperative.rs lines 11861-11910, using
the Recruit symbols to locate each site.

In `@crates/engine/src/parser/oracle_nom/condition.rs`:
- Around line 1251-1254: Add the verified `CR <number>: <description>` rule
annotation immediately before the `StaticCondition::HasEnduringStory` entry in
the condition parser, documenting the `storied` to `enduring story` mapping
while preserving the existing value and tag behavior.

In `@crates/engine/src/types/ability.rs`:
- Around line 7709-7710: Add verified Comprehensive Rules citations for the
Storied/enduring-story symbols: in crates/engine/src/types/ability.rs lines
7709-7710 (anchor), 8171-8172, 18976-18977, and 19960-19961, cite CR 702.195 on
each HasEnduringStory variant; in crates/engine/src/types/events.rs lines
1347-1350, cite CR 702.195 or 702.195c on GameEvent::EnduringStoryGained; and in
crates/engine/src/types/keywords.rs lines 176 and 632, cite CR 702.195 on
KeywordKind::Storied and CR 702.195a on Keyword::Storied.

In `@crates/engine/src/types/game_state.rs`:
- Around line 14814-14818: Add a verified CR citation in the doc comment
immediately above the enduring_story field, using the format “CR <number>:
<description>” and accurately describing the Enduring Story designation’s
persistent behavior. Keep the existing serde attributes and field declaration
unchanged.

In `@crates/engine/src/types/resolution.rs`:
- Around line 5419-5462: Update
v2_reader_recovers_discard_allocator_and_rejects_duplicate_frame_ids to avoid
serializing the malformed duplicate through
ResolutionStateWire::from_game_state, since validation rejects it first.
Construct a ResolutionStack containing two identical discard frames and inject
it using the existing fixture helper, following the duplicate_draw pattern, then
keep the assertion that deserializing the malformed payload returns an error.

In `@crates/engine/tests/integration/bards_company_recruit.rs`:
- Around line 79-84: Update the Bard's Company fixture check in the integration
test to fail when db.get_face_by_name(BARDS_COMPANY) is absent instead of
printing a skip message and returning. Ensure the test continues into the cast,
discard, and token pipeline only when the required generated fixture is present.
- Around line 59-62: Update the token predicate in the Bard’s Company
integration test to match the Recruit’s base characteristics rather than its
modified current values: use base_power and base_toughness for 1/1, or
separately assert base 1/1 and current power/toughness 2/2 while preserving the
existing token and battlefield checks.

In `@crates/engine/tests/integration/deterministic_game_state_serde.rs`:
- Line 208: Update hash_shape to recurse through Box-wrapped values so
discover_fields records enduring_story with its nested Box<HashSet> shape.
Preserve the existing HASH_SET adapter classification for the inner hash set and
ensure the resulting shape is included in discovered_manifest.

---

Outside diff comments:
In `@crates/engine/src/types/ability.rs`:
- Around line 24154-24162: Update Ability::set_context_recursive so descendant
sub_ability and else_ability nodes receive a SpellContext with
direct_discard_result cleared to None, while preserving the current context for
the node itself and propagating all other context fields unchanged.

In `@crates/engine/src/types/resolution.rs`:
- Around line 537-571: Update game_state_eq with a Discard-specific comparison
arm before the generic frame equality arm. Compare the DiscardFrame contents
while excluding the per-operation id, so otherwise-identical paused discard
states with freshly allocated DiscardFrameId values are treated as equal; leave
all other frame comparisons unchanged.

---

Nitpick comments:
In `@crates/engine/src/types/game_state.rs`:
- Around line 14814-14818: Change the GameState field enduring_story from
Box<HashSet<PlayerId>> to HashSet<PlayerId>, preserving its serde attributes.
Update both enduring_story initializers to construct or assign a plain HashSet
directly, matching the city_blessing representation; do not introduce boxing
unless an existing documented size-budget requirement requires it.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 16937647-70d6-4031-8e0a-8eb09835805a

📥 Commits

Reviewing files that changed from the base of the PR and between 452216a and 3068f0e.

📒 Files selected for processing (60)
  • client/src/adapter/types.ts
  • client/src/components/board/OpponentSeatHeader.tsx
  • client/src/components/hud/HudBadges.tsx
  • client/src/components/hud/OpponentHud.tsx
  • client/src/components/hud/PlayerHud.tsx
  • client/src/components/hud/__tests__/OpponentHud.designations.test.tsx
  • client/src/components/hud/__tests__/PlayerHud.designations.test.tsx
  • client/src/hooks/usePlayerDesignations.ts
  • client/src/i18n/locales/de/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/es/game.json
  • client/src/i18n/locales/fr/game.json
  • client/src/i18n/locales/it/game.json
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/pt/game.json
  • crates/engine/src/ai_support/filter.rs
  • crates/engine/src/game/ability_rw.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/conditions.rs
  • crates/engine/src/game/costs.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/effects/discard.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/engine_debug.rs
  • crates/engine/src/game/engine_payment_choices.rs
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/engine_tests.rs
  • crates/engine/src/game/gap_analysis.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/log.rs
  • crates/engine/src/game/public_state.rs
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/trigger_index.rs
  • crates/engine/src/game/trigger_matchers.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/src/parser/oracle_condition.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_keyword.rs
  • crates/engine/src/parser/oracle_nom/condition.rs
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/events.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/identifiers.rs
  • crates/engine/src/types/keywords.rs
  • crates/engine/src/types/proposed_event.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/tests/integration/bards_company_recruit.rs
  • crates/engine/tests/integration/deterministic_game_state_serde.rs
  • crates/engine/tests/integration/integration_bending.rs
  • crates/engine/tests/integration/main.rs
  • crates/phase-ai/src/search.rs

Comment thread client/src/i18n/locales/en/game.json Outdated
Comment thread crates/engine/src/game/effects/discard.rs
Comment thread crates/engine/src/game/effects/discard.rs
Comment thread crates/engine/src/game/sba.rs
Comment thread crates/engine/src/game/trigger_index.rs
Comment thread crates/engine/src/types/game_state.rs Outdated
Comment thread crates/engine/src/types/resolution.rs
Comment thread crates/engine/tests/integration/bards_company_recruit.rs Outdated
Comment thread crates/engine/tests/integration/bards_company_recruit.rs
Comment thread crates/engine/tests/integration/deterministic_game_state_serde.rs Outdated
@matthewevans
matthewevans force-pushed the ship/feat-engine-add-storied-and-recruit branch from b2a75a5 to 1a99376 Compare August 7, 2026 15:47
@matthewevans
matthewevans force-pushed the ship/feat-engine-add-storied-and-recruit branch from 61a8eb0 to b13f9a5 Compare August 7, 2026 16:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/types/resolution.rs (1)

537-568: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve loop-state equality for discard frames.

game_state_eq falls through to strict PartialEq for ResolutionFrame::Discard. DiscardFrameId is fresh for every begin_discard call. Therefore, two otherwise identical repeated Recruit states compare unequal solely because their operation IDs differ. This can prevent mandatory-loop detection from recognizing a repeated game state.

Add a Discard comparison branch that compares semantic frame state without requiring equal absolute provenance IDs. Preserve the structural parent relationship when doing this comparison. Based on learnings: “preserve GameState::eq semantics for deferred trigger loop state” and “do not add fresh per-instance identifiers … unless loop-state equality remains intact.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/resolution.rs` around lines 537 - 568, The
game_state_eq method currently compares ResolutionFrame::Discard via strict
PartialEq, incorrectly treating fresh DiscardFrameId values as state
differences. Add a dedicated Discard comparison branch that compares semantic
discard state while ignoring absolute provenance IDs, but still requires the
structural parent relationship to match; leave all existing frame comparisons
unchanged.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/types/resolution.rs`:
- Around line 477-486: Change begin_discard to return an allocation error when
next_discard_frame_id reaches the reserved exhaustion sentinel, rather than
using saturating_add and reusing DiscardFrameId(u64::MAX). Propagate this Result
through the discard-resolution callers and handle the failure before creating or
pushing a DiscardFrame, while preserving normal ID allocation below the
exhaustion boundary.

---

Outside diff comments:
In `@crates/engine/src/types/resolution.rs`:
- Around line 537-568: The game_state_eq method currently compares
ResolutionFrame::Discard via strict PartialEq, incorrectly treating fresh
DiscardFrameId values as state differences. Add a dedicated Discard comparison
branch that compares semantic discard state while ignoring absolute provenance
IDs, but still requires the structural parent relationship to match; leave all
existing frame comparisons unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 54e83e08-9533-4f6e-b6c1-7a055a8e0c19

📥 Commits

Reviewing files that changed from the base of the PR and between b2a75a5 and b13f9a5.

📒 Files selected for processing (28)
  • client/src/i18n/locales/de/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/es/game.json
  • client/src/i18n/locales/fr/game.json
  • client/src/i18n/locales/it/game.json
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/pt/game.json
  • crates/engine/src/game/conditions.rs
  • crates/engine/src/game/effects/discard.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/trigger_index.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/parser/oracle_condition.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_nom/condition.rs
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/tests/integration/bards_company_recruit.rs
  • crates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (27)
  • crates/engine/src/parser/oracle_condition.rs
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/it/game.json
  • crates/engine/src/parser/oracle_nom/condition.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/engine.rs
  • client/src/i18n/locales/es/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/pt/game.json
  • crates/engine/src/parser/oracle_ir/ast.rs
  • client/src/i18n/locales/fr/game.json
  • crates/engine/src/game/layers.rs
  • crates/engine/tests/integration/bards_company_recruit.rs
  • client/src/i18n/locales/de/game.json
  • crates/engine/src/game/conditions.rs
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/effects/discard.rs
  • crates/engine/src/game/trigger_index.rs

Comment on lines +477 to +486
/// Starts one discard operation and returns its unique provenance id.
pub fn begin_discard(&mut self, source_id: Option<ObjectId>) -> DiscardFrameId {
let id = DiscardFrameId(self.next_discard_frame_id);
self.next_discard_frame_id = self.next_discard_frame_id.saturating_add(1);
self.push_discard(DiscardFrame {
id,
source_id,
results: Vec::new(),
});
id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make discard ID allocation fail before integer exhaustion.

When next_discard_frame_id is u64::MAX, Line 480 leaves it unchanged. After that frame retires, the next call reuses DiscardFrameId(u64::MAX). A stale replacement event can then bind to a later discard operation. The active u64::MAX frame also fails the allocator validation at Lines 2528-2535.

Reserve an exhaustion sentinel and return an allocation error before ID reuse. Propagate that error through discard resolution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/resolution.rs` around lines 477 - 486, Change
begin_discard to return an allocation error when next_discard_frame_id reaches
the reserved exhaustion sentinel, rather than using saturating_add and reusing
DiscardFrameId(u64::MAX). Propagate this Result through the discard-resolution
callers and handle the failure before creating or pushing a DiscardFrame, while
preserving normal ID allocation below the exhaustion boundary.

@matthewevans
matthewevans added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 30efee5 Aug 7, 2026
17 checks passed
@matthewevans
matthewevans deleted the ship/feat-engine-add-storied-and-recruit branch August 7, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant