Skip to content

fix(ai): bind AI actions to engine-issued proposals - #6829

Merged
matthewevans merged 15 commits into
mainfrom
ship/ai-decision-contract
Jul 31, 2026
Merged

fix(ai): bind AI actions to engine-issued proposals#6829
matthewevans merged 15 commits into
mainfrom
ship/ai-decision-contract

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • AI actions now use secure, engine-validated proposals tied to the current game state.
    • AI card-data loading adapts to game size and device memory constraints.
    • Multiplayer, replay, automated gameplay, and Resolve All flows now share consistent proposal handling.
  • Bug Fixes

    • Prevented unauthorized, invalid, or outdated AI actions from being applied.
    • Paused games now stop AI submissions safely.
    • Stale proposals are retried, with repeated failures reported clearly.
    • Improved recovery after resets, state restoration, and failed AI initialization.
    • Corrected sacrifice-cost evaluation in AI decision making.

@matthewevans
matthewevans enabled auto-merge July 30, 2026 23:40
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR replaces direct AI actions with authority-bound proposals across the engine, WASM bridge, adapters, worker pool, dispatch pipeline, and AI controller. It also updates semantic-owner validation, Resolve All handling, card-pool lifecycle behavior, multiplayer retries, and sacrifice-cost synergy rules.

Changes

AI Action Proposal Migration

Layer / File(s) Summary
Decision contracts and AI enforcement
client/src/adapter/types.ts, crates/engine/src/ai_support/*, crates/phase-ai/src/{search.rs,auto_play.rs}
Adds proposal contracts and validates actions against semantic-owner candidate domains.
WASM proposal registry and Resolve All
crates/engine-wasm/src/lib.rs, crates/engine/src/game/*
Adds opaque token issuance, invalidation, submission outcomes, actor validation, and contract-backed Resolve All actions.
Worker, adapter, and pool integration
client/src/adapter/*
Replaces legacy AI RPCs with proposal generation and submission, and updates bounded, unbounded, failed, and stale pool lifecycle handling.
Dispatch, controller, and multiplayer flow
client/src/game/*, client/src/adapter/p2p-adapter.ts, client/src/adapter/__tests__/*
Propagates applied and stale outcomes, retries stale proposals, handles rejection and paused states, and updates proposal-focused tests and fixtures.
Legacy API cleanup
client/src/adapter/{replay-adapter.ts,ws-adapter.ts}, client/src/{game,pages}/**/__tests__/*
Removes obsolete direct AI methods and updates adapter mocks.
Sacrifice-cost synergy rejection
crates/phase-ai/src/policies/{self_cost.rs,self_cost_value.rs}
Blocks generic sacrifice-cost synergy justification, including nested composite cost forms, and updates regression coverage.

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

Sequence Diagram(s)

sequenceDiagram
  participant AiController
  participant WasmAdapter
  participant AiWorkerPool
  participant EngineWorker
  participant EngineWasm

  AiController->>WasmAdapter: request AiActionProposal
  WasmAdapter->>AiWorkerPool: score candidates
  AiWorkerPool-->>WasmAdapter: score-only results
  WasmAdapter->>EngineWorker: create proposal from scores
  EngineWorker->>EngineWasm: issue authority-bound proposal
  EngineWasm-->>EngineWorker: token, actor, and action
  EngineWorker-->>WasmAdapter: AiActionProposal
  WasmAdapter-->>AiController: proposal
  AiController->>WasmAdapter: submit proposal
  WasmAdapter->>EngineWorker: submit token, actor, and action
  EngineWorker->>EngineWasm: validate and apply
  EngineWasm-->>EngineWorker: applied, stale, or rejected
Loading

Possibly related PRs

Suggested labels: bug

🚥 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 main change: binding AI actions to engine-issued proposals.
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/ai-decision-contract

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

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@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: 6

Caution

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

⚠️ Outside diff range comments (2)
crates/phase-ai/src/search.rs (1)

232-249: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Contract filtering can turn the deadlock-escape fallback into None.

fallback_action exists so "the game never deadlocks waiting for the AI", but .and_then(exact_contract_action) now discards it whenever the escape action is not in the issued domain. The issued domain is the simulated-legal candidate set (see the comment in ai_support/context.rs Lines 61-63), which is deliberately narrower than engine legality for beam/clone-capped families such as SearchChoice and attacker/blocker declarations. Where that gap exists, the previous failure mode was one engine rejection; the new one is NoneChooseActionNone and a prompt nobody can answer.

Same exposure on Line 249 for the softmax pick, though that source is contract-derived and safe by construction. Consider making a nonmember fallback loud (log + pick any contract candidate for the current prompt) rather than silently returning None.

🛠️ Degrade to an issued candidate instead of no action
     let mut scored = score_candidates_with_session(state, ai_player, config, session);
     if scored.is_empty() {
         // No valid candidates from search — fall back to a safe escape action
         // so the game never deadlocks waiting for the AI.
-        return fallback_action(state, config).and_then(exact_contract_action);
+        if let Some(action) = fallback_action(state, config) {
+            if let Some(action) = exact_contract_action(action.clone()) {
+                return Some(action);
+            }
+            tracing::warn!(
+                ?action,
+                "fallback escape action is outside the issued contract; using an issued candidate"
+            );
+        }
+        return contract.candidates.first().map(|c| c.action.clone());
     }
🤖 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/phase-ai/src/search.rs` around lines 232 - 249, Update the result
handling around fallback_action and the final
chosen.and_then(exact_contract_action) so contract filtering never returns None
when issued candidates exist. Preserve exact_contract_action for
contract-derived picks, but if fallback_action is outside the issued domain, log
the mismatch and select any available contract candidate for the current prompt
instead; only return None when no contract candidate exists.
crates/engine/src/game/engine.rs (1)

239-244: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Validate semantic_owner at the public boundary.

check_actor_authorization authenticates only authenticated_actor, but apply_action and interaction rebinding use the caller-supplied semantic_owner. A caller can therefore submit another pending player’s decision (for example, during simultaneous mulligans). Derive the owner from an engine-issued interaction contract or reject mismatched actor/owner pairs before dispatch.

🤖 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/game/engine.rs` around lines 239 - 244, Update the public
apply_interaction boundary to validate semantic_owner before dispatching
actions: derive it from an engine-issued interaction contract or reject any
actor/owner mismatch, ensuring callers cannot submit another pending player’s
decision while preserving valid interaction handling.
🧹 Nitpick comments (6)
client/src/adapter/card-db-subset.ts (1)

6-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

auto and subset are indistinguishable.

resolveAiPoolCardDbPlan only branches on "full", so the three-variant mode carries two behaviors. Either collapse to "bounded" | "full" or give auto a real decision (e.g. subset only when the engine reports a bounded universe under a size threshold).

🤖 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 `@client/src/adapter/card-db-subset.ts` around lines 6 - 24, Update
resolveAiPoolCardDbPlan so AiCardDataMode values have distinct behavior: either
remove the redundant auto/subset distinction by collapsing the mode type, or
implement an explicit auto decision based on the engine’s bounded-universe and
size threshold result. Ensure subset mode remains explicitly bounded and full
mode remains full.
client/src/game/controllers/aiController.ts (1)

264-272: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The fallback escalation is gone, so MAX_TOTAL_FAILURES is now dead and the surrounding comments are stale.

consecutiveFailures and totalFailures are incremented together (Lines 410-411) and reset together (Lines 244-245), and MAX_CONSECUTIVE_FAILURES is 3. With this branch now doing exactly what the totalFailures >= MAX_TOTAL_FAILURES branch does (notifyEngineLost + stop()), the total-failure guard at Line 254 can never fire first — two counters, one behavior. The comments at Lines 90-93 and 249-253 still describe a "normal→fallback transition" and "fallback failures" that no longer exist.

Collapse to a single failure budget and update the comments to describe the proposal-only flow.

🤖 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 `@client/src/game/controllers/aiController.ts` around lines 264 - 272, Collapse
the failure tracking in the AI controller around consecutiveFailures and
totalFailures into the single MAX_CONSECUTIVE_FAILURES budget, removing the dead
MAX_TOTAL_FAILURES guard and any related counter updates or declarations. Update
the stale comments near the failure-limit definitions and stop logic to describe
the proposal-only flow, while preserving the existing notifyEngineLost and stop
behavior when the limit is reached.
crates/phase-ai/src/search.rs (1)

155-162: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Every AI decision now enumerates the validated candidate domain twice. choose_action_with_session issues its own AiDecisionContract and already guarantees the returned action is an exact member, yet both callers issue a second contract for the same state and owner and re-check membership. AiDecisionContract::issue runs validated_candidate_actions_for_semantic_owner, which clones and re-applies state per candidate (this file's own SearchChoice comment calls that "hundreds of state clones"), so the interactive path pays it twice.

  • crates/phase-ai/src/search.rs#L155-L162: return the issued contract alongside the action (or accept one from the caller) so the domain is enumerated once per decision.
  • crates/phase-ai/src/auto_play.rs#L205-L206: consume the contract produced by the chooser instead of issuing a separate one before the call.
  • crates/engine-wasm/src/lib.rs#L1961-L1981: reuse that same contract for both the membership check and the registry insert rather than issuing a second one.
🤖 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/phase-ai/src/search.rs` around lines 155 - 162, The AI decision flow
redundantly issues and validates the same candidate domain twice. In
crates/phase-ai/src/search.rs:155-162, update choose_action_with_session and its
callers to return or accept the issued AiDecisionContract alongside the selected
action; in crates/phase-ai/src/auto_play.rs:205-206, consume that returned
contract instead of issuing another; and in
crates/engine-wasm/src/lib.rs:1961-1981, reuse the same contract for membership
validation and registry insertion.
crates/phase-ai/src/auto_play.rs (1)

215-230: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Contract violations are reported as ApplyFailed, contradicting that variant's documented meaning.

ApplyFailed's doc comment states "apply() rejected player's chosen action", but here nothing was applied — the AI selected outside its issued domain, which is a policy bug in a different category (and callers that surface ApplyFailed as an engine rejection will misattribute it). A dedicated variant keeps the break reasons a faithful typed record of why the loop stopped.

♻️ Add a distinct break reason
+    /// `player`'s chosen `action` was not a member of the engine-issued
+    /// `AiDecisionContract` for `semantic_owner`. Nothing was applied.
+    ContractViolation {
+        semantic_owner: PlayerId,
+        player: PlayerId,
+        action: Box<GameAction>,
+    },
-        if !contract.permits(state, actor, &action) {
-            let error = EngineError::InvalidAction(
-                "AI chose an action outside its issued decision contract".to_string(),
-            );
-            tracing::error!(
+        if !contract.permits(state, actor, &action) {
+            tracing::error!(
                 ?semantic_owner,
                 ?actor,
                 "AI action violated decision contract"
             );
-            break_reason = Some(AiActionsBreakReason::ApplyFailed {
-                player: actor,
-                action: Box::new(action),
-                error,
-            });
+            break_reason = Some(AiActionsBreakReason::ContractViolation {
+                semantic_owner,
+                player: actor,
+                action: Box::new(action),
+            });
             break;
         }
🤖 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/phase-ai/src/auto_play.rs` around lines 215 - 230, Add a dedicated
AiActionsBreakReason variant for decision-contract violations, documenting that
the AI selected an action outside its issued contract, and update the contract
check in the auto-play loop to use it instead of ApplyFailed. Preserve the
existing player/action/error context and logging while keeping ApplyFailed
exclusively for actions rejected by apply().
crates/engine-wasm/src/lib.rs (1)

1944-1961: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Semantic-owner derivation is copy-pasted three times.

The same acting_players().contains(requested)acting_player()acting_players().first() cascade appears here, in get_ai_action_proposal_from_scores (Lines 2038-2047), and in resolve_all_inner (Lines 2178-2184, minus the requested-AI preference). Divergence between them silently changes which seat a proposal is bound to. Extract one engine-side helper (e.g. alongside AiDecisionContract::issue) and call it from all three.

🤖 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-wasm/src/lib.rs` around lines 1944 - 1961, Extract the repeated
semantic-owner selection into one engine-side helper near
AiDecisionContract::issue, preserving the requested-player preference followed
by acting_player, first acting player, and requested-player fallback. Replace
the inline logic in the shown contract creation,
get_ai_action_proposal_from_scores, and resolve_all_inner with this helper,
while preserving resolve_all_inner’s existing behavior when no requested-AI
preference applies.
crates/engine/src/game/engine_resolve_batch.rs (1)

117-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test covers the nonmember/stale proposal rejection arm.

raw_non_pass_callback_action_is_rejected_without_applying exercises Action(_), and current_contract_proposal_is_applied_for_non_requester_priority exercises the permitted Proposal. The Proposal { .. } fall-through — a contract that no longer permits its action — is the arm that actually protects Resolve All's action boundary, and it is untested. A case returning a proposal whose action is not in contract.candidates (or whose state_revision is stale) should assert items_resolved == 0 and an unmutated stack.

🤖 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/game/engine_resolve_batch.rs` around lines 117 - 131, Add a
regression test covering the `ResolveAllCallbackDecision::Proposal { .. }`
fall-through in the Resolve All callback handling. Return a proposal with an
action not permitted by `contract.candidates` or with a stale `state_revision`,
then assert `items_resolved == 0` and that the stack remains unchanged; keep the
existing permitted-proposal and raw-action tests intact.
🤖 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/adapter/__tests__/p2p-adapter-multiplayer.test.ts`:
- Line 268: Update the test reset block near mockGetAiActionProposal.mockClear()
to also clear the submitAiActionProposal mock before each test, ensuring
submission call history cannot carry over into later assertions.

In `@client/src/adapter/p2p-adapter.ts`:
- Around line 1897-1913: Update submitAiActionProposal to enforce the same
gameRunState gate as submitAction and submitInteraction: reject with the
established P2P_PAUSED result whenever the state is not "running", before
invoking wasm.submitAiActionProposal. Preserve the existing authority checks and
applied-outcome broadcast, AI-loop, and persistence behavior for running games.
- Around line 1163-1174: Update runAiLoop around submitAiActionProposal so stale
outcomes cannot trigger an unbounded continue loop. Track consecutive stale
retries, cap them using the existing AI retry-limit convention, and surface an
AdapterError once the cap is reached; reset the counter after a non-stale
successful submission while preserving existing rejected and result handling.

In `@client/src/game/dispatch.ts`:
- Around line 746-755: Update dispatchAiActionProposal and the
dispatchActionInternal/processAction flow to derive status from positive
submission evidence: have processAction invoke the callback with "applied"
immediately after submit() returns a non-null result, while preserving the
existing stale notification. Track that applied signal in
dispatchAiActionProposal and return "applied" only when it is received;
otherwise return "stale" for every silent-drop or stale path.

In `@crates/engine-wasm/src/lib.rs`:
- Around line 179-201: Update the proposal registry around insert and proposal
so issuance remains bounded: retain only the newest token for the current
generation, removing any previously stored proposal when insert creates a
replacement, and discard entries from older generations as needed. Preserve
lookup validation so only the current-generation proposal can be submitted.

In `@crates/phase-ai/src/policies/self_cost.rs`:
- Around line 785-792: Update contains_sacrifice_cost to replace the wildcard
fallback with explicit matches for every current non-sacrifice AbilityCost leaf
variant. Preserve recursive traversal for Composite and OneOf and return false
for each listed leaf, so newly added variants cause a compilation failure until
handled.

---

Outside diff comments:
In `@crates/engine/src/game/engine.rs`:
- Around line 239-244: Update the public apply_interaction boundary to validate
semantic_owner before dispatching actions: derive it from an engine-issued
interaction contract or reject any actor/owner mismatch, ensuring callers cannot
submit another pending player’s decision while preserving valid interaction
handling.

In `@crates/phase-ai/src/search.rs`:
- Around line 232-249: Update the result handling around fallback_action and the
final chosen.and_then(exact_contract_action) so contract filtering never returns
None when issued candidates exist. Preserve exact_contract_action for
contract-derived picks, but if fallback_action is outside the issued domain, log
the mismatch and select any available contract candidate for the current prompt
instead; only return None when no contract candidate exists.

---

Nitpick comments:
In `@client/src/adapter/card-db-subset.ts`:
- Around line 6-24: Update resolveAiPoolCardDbPlan so AiCardDataMode values have
distinct behavior: either remove the redundant auto/subset distinction by
collapsing the mode type, or implement an explicit auto decision based on the
engine’s bounded-universe and size threshold result. Ensure subset mode remains
explicitly bounded and full mode remains full.

In `@client/src/game/controllers/aiController.ts`:
- Around line 264-272: Collapse the failure tracking in the AI controller around
consecutiveFailures and totalFailures into the single MAX_CONSECUTIVE_FAILURES
budget, removing the dead MAX_TOTAL_FAILURES guard and any related counter
updates or declarations. Update the stale comments near the failure-limit
definitions and stop logic to describe the proposal-only flow, while preserving
the existing notifyEngineLost and stop behavior when the limit is reached.

In `@crates/engine-wasm/src/lib.rs`:
- Around line 1944-1961: Extract the repeated semantic-owner selection into one
engine-side helper near AiDecisionContract::issue, preserving the
requested-player preference followed by acting_player, first acting player, and
requested-player fallback. Replace the inline logic in the shown contract
creation, get_ai_action_proposal_from_scores, and resolve_all_inner with this
helper, while preserving resolve_all_inner’s existing behavior when no
requested-AI preference applies.

In `@crates/engine/src/game/engine_resolve_batch.rs`:
- Around line 117-131: Add a regression test covering the
`ResolveAllCallbackDecision::Proposal { .. }` fall-through in the Resolve All
callback handling. Return a proposal with an action not permitted by
`contract.candidates` or with a stale `state_revision`, then assert
`items_resolved == 0` and that the stack remains unchanged; keep the existing
permitted-proposal and raw-action tests intact.

In `@crates/phase-ai/src/auto_play.rs`:
- Around line 215-230: Add a dedicated AiActionsBreakReason variant for
decision-contract violations, documenting that the AI selected an action outside
its issued contract, and update the contract check in the auto-play loop to use
it instead of ApplyFailed. Preserve the existing player/action/error context and
logging while keeping ApplyFailed exclusively for actions rejected by apply().

In `@crates/phase-ai/src/search.rs`:
- Around line 155-162: The AI decision flow redundantly issues and validates the
same candidate domain twice. In crates/phase-ai/src/search.rs:155-162, update
choose_action_with_session and its callers to return or accept the issued
AiDecisionContract alongside the selected action; in
crates/phase-ai/src/auto_play.rs:205-206, consume that returned contract instead
of issuing another; and in crates/engine-wasm/src/lib.rs:1961-1981, reuse the
same contract for membership validation and registry insertion.
🪄 Autofix (Beta)

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: e7d68b54-5fbe-4340-b2ab-6501106769d0

📥 Commits

Reviewing files that changed from the base of the PR and between e3fc63b and 4df601e.

⛔ Files ignored due to path filters (1)
  • client/src/wasm/engine_wasm.d.ts is excluded by !client/src/wasm/**, !**/*.d.ts
📒 Files selected for processing (32)
  • client/src/adapter/__tests__/ai-card-subset.test.ts
  • client/src/adapter/__tests__/ai-worker-pool.test.ts
  • client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts
  • client/src/adapter/__tests__/wasm-adapter.test.ts
  • client/src/adapter/__tests__/ws-adapter.test.ts
  • client/src/adapter/ai-worker-pool.ts
  • client/src/adapter/card-db-subset.ts
  • client/src/adapter/engine-worker-client.ts
  • client/src/adapter/engine-worker.ts
  • client/src/adapter/p2p-adapter.ts
  • client/src/adapter/replay-adapter.ts
  • client/src/adapter/server-draft-adapter.ts
  • client/src/adapter/types.ts
  • client/src/adapter/wasm-adapter.ts
  • client/src/adapter/ws-adapter.ts
  • client/src/game/__tests__/dispatchSplitEpochSoftlock.test.ts
  • client/src/game/__tests__/dispatchTurnControlPayCostQueue.test.ts
  • client/src/game/controllers/__tests__/aiController.test.ts
  • client/src/game/controllers/aiController.ts
  • client/src/game/dispatch.ts
  • client/src/pages/__tests__/greenwardenDoubledTrigger.test.ts
  • client/src/pages/__tests__/optionalEffectChoiceTransition.test.tsx
  • client/src/test/factories/engineAdapterFactory.ts
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/ai_support/context.rs
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_resolve_batch.rs
  • crates/phase-ai/src/auto_play.rs
  • crates/phase-ai/src/policies/self_cost.rs
  • crates/phase-ai/src/policies/self_cost_value.rs
  • crates/phase-ai/src/search.rs
💤 Files with no reviewable changes (7)
  • client/src/adapter/tests/ws-adapter.test.ts
  • client/src/adapter/ws-adapter.ts
  • client/src/adapter/replay-adapter.ts
  • client/src/pages/tests/optionalEffectChoiceTransition.test.tsx
  • client/src/adapter/server-draft-adapter.ts
  • client/src/adapter/tests/wasm-adapter.test.ts
  • client/src/pages/tests/greenwardenDoubledTrigger.test.ts

Comment thread client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts
Comment thread client/src/adapter/p2p-adapter.ts
Comment thread client/src/adapter/p2p-adapter.ts
Comment thread client/src/game/dispatch.ts
Comment thread crates/engine-wasm/src/lib.rs
Comment thread crates/phase-ai/src/policies/self_cost.rs Outdated

@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.

Caution

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

⚠️ Outside diff range comments (1)
client/src/adapter/p2p-adapter.ts (1)

1168-1172: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Re-check host authority and run state before direct WASM submission.

A disconnect or host-lease handoff can occur during either await. This path then bypasses submitAiActionProposal’s fences and may apply an AI action after the game pauses or this host is superseded. Revalidate immediately before submitting.

Proposed fix
       if (!proposal) {
         return;
       }
+      if (!this.ownsAuthority()) return;
+      if (this.gameRunState !== "running") return;
       const outcome = await this.wasm.submitAiActionProposal(proposal);

As per path instructions, check relevant async races.

🤖 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 `@client/src/adapter/p2p-adapter.ts` around lines 1168 - 1172, Before calling
submitAiActionProposal in the AI action flow, revalidate the current host
authority and active game/run state after await this.wasm.getAiActionProposal
returns. Abort without submitting when either fence is no longer satisfied, then
preserve the existing proposal check and direct WASM submission for valid state.

Source: Path instructions

🤖 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.

Outside diff comments:
In `@client/src/adapter/p2p-adapter.ts`:
- Around line 1168-1172: Before calling submitAiActionProposal in the AI action
flow, revalidate the current host authority and active game/run state after
await this.wasm.getAiActionProposal returns. Abort without submitting when
either fence is no longer satisfied, then preserve the existing proposal check
and direct WASM submission for valid state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: edcec272-1a07-4b26-bd7f-5c51224e1569

📥 Commits

Reviewing files that changed from the base of the PR and between 4df601e and eacf0dc.

📒 Files selected for processing (8)
  • client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts
  • client/src/adapter/p2p-adapter.ts
  • client/src/game/dispatch.ts
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/ai_support/context.rs
  • crates/engine/src/game/engine_resolve_batch.rs
  • crates/phase-ai/src/policies/self_cost.rs
  • crates/phase-ai/src/policies/self_cost_value.rs

@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-wasm/src/lib.rs (1)

179-199: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Scope proposal invalidation by semantic owner.

self.proposals.clear() invalidates P0’s token when a P1 proposal is issued during a simultaneous mulligan, despite each contract being owner-scoped. Retain only prior entries for the incoming semantic_owner and add a regression test that submits P0 after issuing P1.

🤖 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-wasm/src/lib.rs` around lines 179 - 199, The insert method
currently clears proposals across all semantic owners, invalidating P0 when
issuing P1. In AiDecisionContract::insert, retain existing entries whose
semantic owner matches the incoming contract and remove proposals belonging to
other owners; then add a regression test that issues P1 followed by submitting
P0 and verifies P0 remains valid.
🤖 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/ai_support/context.rs`:
- Around line 79-93: Update candidate_action_matches to normalize and compare
the Vec payloads of ChooseKeptCreatures and ChooseKeptPermanents as unordered
sets, matching the existing SelectCards behavior. Preserve positional comparison
for all other action variants, including target slots, distributions, and
trigger ordering.

---

Outside diff comments:
In `@crates/engine-wasm/src/lib.rs`:
- Around line 179-199: The insert method currently clears proposals across all
semantic owners, invalidating P0 when issuing P1. In AiDecisionContract::insert,
retain existing entries whose semantic owner matches the incoming contract and
remove proposals belonging to other owners; then add a regression test that
issues P1 followed by submitting P0 and verifies P0 remains valid.
🪄 Autofix (Beta)

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: fc9d2801-aae9-4542-827a-0beb293ef9df

📥 Commits

Reviewing files that changed from the base of the PR and between eacf0dc and dbe914d.

📒 Files selected for processing (3)
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/ai_support/context.rs
  • crates/phase-ai/src/search.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/phase-ai/src/search.rs

Comment thread crates/engine/src/ai_support/context.rs
@matthewevans
matthewevans added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit e6c6bb0 Jul 31, 2026
16 of 18 checks passed
@matthewevans
matthewevans deleted the ship/ai-decision-contract branch July 31, 2026 03:18
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