Skip to content

fix(server): accept Interaction submissions over WebSocket (#6941) - #6949

Merged
matthewevans merged 3 commits into
mainfrom
fix/6941-interaction-wire
Aug 3, 2026
Merged

fix(server): accept Interaction submissions over WebSocket (#6941)#6949
matthewevans merged 3 commits into
mainfrom
fix/6941-interaction-wire

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes #6941.

The bug

ClientMessage had no Interaction variant, so the server rejected every interaction submission at serde deserialization — before any game logic ran — with unknown variant \Interaction``.

PR #6778 ("authorize attachment interaction fan") wired the engine, WASM, and client halves of the interaction path but shipped no server half — git show --stat a8766823cd -- crates/server-core is empty. Single-player and P2P were unaffected because they dispatch to wasm.submitInteraction and never cross the wire.

Live since v0.42.0 (2026-07-31), through v0.43.0. Every interaction submission in a WebSocket multiplayer game has failed for ~2 days. The attachment fan is the most visible surface, which is why it was reported as an equipment bug, but the failure is at the transport layer — it is not specific to equipment.

The client envelope was already correct (ClientMessage is #[serde(tag = "type", content = "data")], matching what ws-adapter.ts sends), so no client file changes.

What changed

  • Add ClientMessage::Interaction { submission } and declare its wire policy in all three exhaustive ClientMessage matches. No wildcard arm was added — client_message_wire_guard.rs still guarantees that a new variant cannot compile without declaring policy.
  • Add SessionManager::handle_interaction, deriving the acting seat from the authenticated session token, never from the payload. submit_interaction then re-authorizes against the interaction slot inside the engine, so a forged id belonging to another seat is rejected twice.
  • Extract the ClientMessage::Action handler body into handle_full_game_submission so Action and Interaction share one authenticated / applied / broadcast path instead of forking eight fan-out behaviours. This is the bulk of the main.rs diff and is a pure move — see the move evidence below.
  • Answer interaction bounds failures on ServerMessage::ActionRejected, not ServerMessage::Error. The native client disposes its adapter on any Error, and a free-form Text response exceeding the 256-byte bound is reachable by an ordinary paste — routing it to Error would end the match. wire_rejection_message makes that channel a per-variant wire policy.
  • Promote the engine's existing response bounds to a public bound_interaction_submission, so the wire invokes the engine's own limits rather than restating them. One authority, no second copy.

Verification

Verified against the immutable commit 064d5190 in a detached, clean worktree, with HEAD and clean state attested before and after every command:

Check Result
cargo fmt --all -- --check pass
cargo clippy -p phase-server -p server-core -p phase-engine --all-targets -- -D warnings pass
cargo test -p server-core 322 passed, 0 failed
cargo test -p phase-server --bin phase-server 123 passed, 0 failed
cargo test -p phase-engine 22,717 passed, 0 failed
parser range (-- crates/engine/src/parser/) empty — parser gate N/A
frontend range (-- client/) empty

17 new tests cover the wire guard, payload bounding, and session authorization — including handle_interaction_binds_the_actor_to_the_authenticated_token, handle_interaction_rejects_an_unknown_token, handle_interaction_rejects_a_stale_submission_benignly, and interaction_wire_rejection_answers_on_the_benign_channel.

Parser impact: none. Both BASE and HEAD were projected from the same pinned AtomicCards.json (sha256 474b3fa7…) and the base-built comparator reported oracle_changed: 0, no clusters. The two sides' card-data.json are byte-identical (fbf1dc80…).

Move evidence for the main.rs extraction: --color-moved=zebra is not applicable — the moved block was dedented 8 columns and rustfmt then re-wrapped it, so zebra cannot match. Instead, rustfmt(dedent_8(original)) was diffed against the actual extracted function: 360 of 373 lines identical (98.1%), with exactly 3 differing hunks, all intended — (1) a debug! field rename actionsubmission, (2) the payload guard replaced by submission.payload_rejection(), and (3) the new match submission dispatch to handle_action/handle_interaction.

git merge-tree against current main produces a conflict-free tree, and the 5 commits main gained since the base touch none of the changed files.

Notes

Summary by CodeRabbit

  • New Features

    • Added support for submitting game interactions in Full-mode games.
    • Interaction submissions now receive consistent validation, authorization, processing, persistence, and broadcast handling.
    • Added clear rejection responses for invalid or oversized interaction payloads.
  • Bug Fixes

    • Prevented debug-related actions from appearing among interaction choices in sandbox-enabled games.
    • Improved handling of stale, unknown, unauthorized, and takeback-blocked submissions.
  • Tests

    • Added comprehensive coverage for interaction validation, session handling, mode restrictions, payload limits, and response behavior.

`ClientMessage` had no `Interaction` variant, so the server rejected every
interaction submission at serde deserialization -- before any game logic ran --
with "unknown variant `Interaction`". PR #6778 wired the engine, WASM, and
client halves of the attachment interaction fan but shipped no server half
(`git show --stat a876682 -- crates/server-core` is empty). Live since
v0.42.0: every interaction submission in a WebSocket multiplayer game failed.

The client envelope was already correct -- `ClientMessage` is
`#[serde(tag = "type", content = "data")]`, matching what `ws-adapter.ts`
sends -- so no client file changes.

- Add `ClientMessage::Interaction { submission }` and declare its wire policy
  in all three exhaustive `ClientMessage` matches.
- Add `SessionManager::handle_interaction`, deriving the acting seat from the
  authenticated session token, never from the payload. `submit_interaction`
  then re-authorizes against the interaction slot inside the engine, so a
  forged id belonging to another seat is rejected twice.
- Extract the `ClientMessage::Action` handler body into
  `handle_full_game_submission` so both wire variants share one authenticated,
  applied, and broadcast path rather than forking eight fan-out behaviours.
- Answer interaction bounds failures on `ServerMessage::ActionRejected`, not
  `ServerMessage::Error`. The native client disposes its adapter on any
  `Error`, and a free-form `Text` response exceeding the 256-byte bound is
  reachable by an ordinary paste -- routing it to `Error` would end the match.
  `wire_rejection_message` makes that channel a per-variant wire policy.
- Promote the engine's existing response bounds to a public
  `bound_interaction_submission` so the wire invokes the engine's own limits
  instead of restating them.

Server-hosted draft matches remain blocked on a separate identity-binding
defect (the draft adapter drops `DraftMatchStart.player_token`), which needs a
client change and is filed separately.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 228c6948-ea42-4b50-ad46-955f09621f49

📥 Commits

Reviewing files that changed from the base of the PR and between 88ed143 and 32db11f.

📒 Files selected for processing (1)
  • crates/server-core/src/client_message_wire_guard.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/server-core/src/client_message_wire_guard.rs

📝 Walkthrough

Walkthrough

The PR adds WebSocket support for interaction submissions. It introduces shared payload validation, a new client protocol variant, authenticated session handling, Full-mode dispatch, standardized rejection responses, state updates, broadcasts, and integration coverage.

Changes

Interaction submission flow

Layer / File(s) Summary
Shared interaction bounds
crates/engine/src/game/interaction.rs, crates/engine/tests/integration/interaction_contract.rs
The engine validates interaction IDs and response bounds through a public shared validator. Sandbox interaction enumeration excludes debug actions.
Wire protocol and payload guards
crates/server-core/src/protocol.rs, crates/server-core/src/interaction_payload_guard.rs, crates/server-core/src/client_message_wire_guard.rs, crates/server-core/src/lib.rs
The protocol accepts ClientMessage::Interaction. Payload validation uses engine bounds, and validation failures produce ActionRejected responses.
Authenticated interaction application
crates/server-core/src/session.rs
GameSession::handle_interaction authenticates the player, checks takeback state, submits the interaction, records applicable history, updates metadata, and returns an ActionResult.
Full-mode dispatch and broadcasts
crates/phase-server/src/main.rs
Full-mode handling routes actions and interactions through shared submission processing, including AI follow-ups, persistence, terminal handling, and player/spectator broadcasts. Tests cover dispatch, mode gates, payload channels, and sessionless submissions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PhaseServer
  participant WireGuard
  participant GameSession
  participant Engine
  participant PlayersAndSpectators

  Client->>PhaseServer: ClientMessage::Interaction
  PhaseServer->>WireGuard: Validate interaction payload
  WireGuard-->>PhaseServer: Accept or ActionRejected
  PhaseServer->>GameSession: handle_interaction
  GameSession->>Engine: submit_interaction
  Engine-->>GameSession: ActionResult or rejection
  GameSession-->>PhaseServer: ActionResult
  PhaseServer->>PlayersAndSpectators: Broadcast filtered game updates
Loading

Possibly related issues

Possibly related PRs

  • phase-rs/phase#6778: Adds the client-side interaction submission flow that this PR enables on the server.
  • phase-rs/phase#6700: Changes resolve_interaction_response and related interaction contract tests used by this submission path.
  • phase-rs/phase#6639: Modifies interaction choice projection and the interaction action structures used by this flow.

Suggested labels: enhancement, needs-maintainer

🚥 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 describes the main change: accepting Interaction submissions over WebSocket in the server.
Linked Issues check ✅ Passed The changes add the Interaction variant, authenticated handling, validation, expected rejection responses, broadcasts, and related tests required by issue #6941.
Out of Scope Changes check ✅ Passed The changes support WebSocket interaction handling, shared validation, authorization, wire policy, and regression coverage without unrelated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ 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 fix/6941-interaction-wire

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

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

🧹 Nitpick comments (1)
crates/phase-server/src/main.rs (1)

3613-3623: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the diagnostics that still name only Action.

This handler now serves both GameSubmission::Action and GameSubmission::Interaction. Two log strings still name only the action path:

  • Line 3616: "Action received but not in a game" fires for an Interaction frame with no session.
  • Line 3695: "action processed (lock held)" fires for an applied interaction.

An operator triaging an interaction-submission report searches for "interaction" and finds neither event. Derive the label from the submission variant so the shared handler stays diagnosable.

♻️ Proposed fix: label the two events by submission kind
 impl GameSubmission {
+    /// Stable label for diagnostics emitted by the shared handler.
+    fn kind(&self) -> &'static str {
+        match self {
+            GameSubmission::Action(_) => "action",
+            GameSubmission::Interaction(_) => "interaction",
+        }
+    }
+
     fn payload_rejection(&self) -> Result<(), Box<ServerMessage>> {
 ) {
+    let kind = submission.kind();
     let game_code = match &identity.game_code {
         Some(c) => c.clone(),
         None => {
-            warn!("Action received but not in a game");
+            warn!(kind, "game submission received but not in a game");
             let msg = ServerMessage::error("Not in a game".to_string());
                 info!(
                     game = %game_code,
+                    kind,
                     lock_ms,
                     ai_actions = ai_results.len(),
-                    "action processed (lock held)"
+                    "game submission processed (lock held)"
                 );
🤖 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-server/src/main.rs` around lines 3613 - 3623, Update the shared
game-submission handler to derive a submission-kind label from the
`GameSubmission` variant, covering both `Action` and `Interaction`. Reuse that
label in the no-game warning near `identity.game_code` and the `"processed (lock
held)"` diagnostic so both events identify the actual submission kind, including
interactions.
🤖 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.

Nitpick comments:
In `@crates/phase-server/src/main.rs`:
- Around line 3613-3623: Update the shared game-submission handler to derive a
submission-kind label from the `GameSubmission` variant, covering both `Action`
and `Interaction`. Reuse that label in the no-game warning near
`identity.game_code` and the `"processed (lock held)"` diagnostic so both events
identify the actual submission kind, including interactions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ae41aa1d-9742-43f9-9983-f02c160a9709

📥 Commits

Reviewing files that changed from the base of the PR and between d8856f1 and 064d519.

📒 Files selected for processing (8)
  • crates/engine/src/game/interaction.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/phase-server/src/main.rs
  • crates/server-core/src/client_message_wire_guard.rs
  • crates/server-core/src/interaction_payload_guard.rs
  • crates/server-core/src/lib.rs
  • crates/server-core/src/protocol.rs
  • crates/server-core/src/session.rs

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Generated for head 32db11f6ce045a85ec4c40a7eced4afa12fff913.

Parse changes introduced by this PR

✓ No card-parse changes detected.

`handle_full_game_submission` serves both `GameSubmission::Action` and
`GameSubmission::Interaction`, but two of its events still named only the
action path -- so an operator triaging an interaction-submission report
would grep for "interaction" and match neither the "not in a game" warning
nor the "processed (lock held)" info event.

Derive a `kind` label from the submission variant and attach it to both,
renaming the messages to name the shared concept. `GameSubmission::kind`
keeps the two call sites from restating the variant set.

Raised by CodeRabbit on #6949.
The module header claimed this layer "guarantees a single exhaustive
match". It has had two for some time and now has three, and the doc on
`wire_rejection_message` already referred to "the two sibling matches in
this module" -- contradicting the header it sits under.

State the guarantee in terms of what it actually enforces (every wire
policy is declared in an exhaustive, wildcard-free match, so a new
variant cannot compile until it states one) and name the three policy
axes by function rather than by count, so the header does not go stale
again the next time one is added.

Raised by review on #6949. Doc-only; no behavior change.
@matthewevans
matthewevans added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit dec276f Aug 3, 2026
14 checks passed
@matthewevans
matthewevans deleted the fix/6941-interaction-wire branch August 3, 2026 09:46
matthewevans added a commit that referenced this pull request Aug 3, 2026
The seven tests added with the fix all drive `TapLandForMana`. The
`ActivateManaSource` arm keeps its original resolver and is behaviourally
unchanged, but its surface emission now runs through the extracted
`push_produced_mana_surfaces`, and nothing covered that.

Reaching it is not obvious: the reducer accepts `ActivateManaSource` under
`WaitingFor::Priority` (`engine.rs`), but `direct_choice_projection` only
constructs those actions in its `WaitingFor::ManaSourceSelection` arm, so a
label test has to drive the game into a mana-source-selection window rather
than activate at priority. A sacrificial mana source gets there.

Covers a fixed and a flexible source through the same window, so the shared
helper is pinned for both arms rather than only the one the fix changed.

Raised by CodeRabbit on #6949's sibling PR.
matthewevans added a commit to tp5grphwkt-cell/phase that referenced this pull request Aug 3, 2026
…ce (phase-rs#6944) (phase-rs#6953)

* fix(engine): label flexible mana lands with the color they will produce (phase-rs#6944)

City of Brass, Reflecting Pool, Command Tower and friends rendered an
unlabelled "Tap for mana" instead of showing the mana each activation
would produce.

`project_action_payload` handled `TapLandForMana` and `ActivateManaSource`
in one arm and resolved both through `live_mana_source_option_for_selection`.
But the two actions carry deliberately different selection forms:

- `TapLandForMana` is minted from `ManaSourceOption::semantic_selection` --
  one concrete row per producible color -- and is executed by
  `handle_tap_land_for_mana` via `live_land_mana_option_for_selection`.
- `ActivateManaSource` is minted from `activatable_mana_source_selections`,
  whose `manual_selection_for_option` intentionally collapses a flexible
  source to `Colorless` + `DeferredColorChoice` so the ordinary mana-choice
  resolver asks for the color, and is executed by
  `activate_mana_source_selection` via `live_mana_source_option_for_selection`.

That divergence is deliberate and is not the bug. The bug is that the label
path resolved a planner-minted `TapLandForMana` through the *manual*
authority, which can never match a flexible source -- so the lookup failed
and the arm returned without pushing a surface. The old code was correct
for `ActivateManaSource` and wrong for `TapLandForMana`.

Split the arm so each action is labelled through the same resolver its own
reducer executes, with the resolver passed to a shared
`push_produced_mana_surfaces`. A future mana action variant now has to name
an authority to compile, which is what the function's doc comment already
claimed.

The fix is variant-agnostic: `production_override_for_option` maps all eight
`flexible_output` variants to `ProductionOverride::SingleColor`, so nothing
is special-cased per card.

Seven tests drive the real projection pipeline (`derive_viewer_interaction`
over a viewer-filtered state), covering six of the eight flexible variants:
City of Brass, Reflecting Pool, Exotic Orchard, Command Tower, Plaza of
Heroes, Pit of Offerings, and a Resonating Lute grant. All seven were
confirmed red at base by restoring the old resolver and re-running -- 7
failures, no collateral -- reproducing the report verbatim, including mixed
rows where a non-flexible sibling ability keeps its label while the flexible
one goes blank.

Not covered: `AnyCombination` has no bare-{T} land printing (every printing
gates it behind a Composite/PaySpeed cost, so it needs a funded pool) and
`AnyCombinationOfObjectColors` is unreachable for its only current printing,
as already documented at casting_costs.rs. Both share the fixed code path.

* test(engine): cover the ActivateManaSource mana-label path

The seven tests added with the fix all drive `TapLandForMana`. The
`ActivateManaSource` arm keeps its original resolver and is behaviourally
unchanged, but its surface emission now runs through the extracted
`push_produced_mana_surfaces`, and nothing covered that.

Reaching it is not obvious: the reducer accepts `ActivateManaSource` under
`WaitingFor::Priority` (`engine.rs`), but `direct_choice_projection` only
constructs those actions in its `WaitingFor::ManaSourceSelection` arm, so a
label test has to drive the game into a mana-source-selection window rather
than activate at priority. A sacrificial mana source gets there.

Covers a fixed and a flexible source through the same window, so the shared
helper is pinned for both arms rather than only the one the fix changed.

Raised by CodeRabbit on phase-rs#6949's sibling PR.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
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.

Multiplayer: all interaction submissions rejected — ClientMessage has no Interaction variant (live since v0.42.0)

1 participant