Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions crates/engine/src/game/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8869,6 +8869,41 @@ pub fn preview_interaction(
}
}

/// Materialize the `GameAction` an interaction response denotes, **without**
/// applying it.
///
/// [`submit_interaction`] is the mutating path and delegates here for everything
/// up to the reducer, so the two cannot drift. This variant exists for consumers
/// that own their own dispatch and need the action itself — the ManaBrew adapter
/// translates a client's prompt answer into a `GameAction` and returns it to its
/// caller rather than applying it.
///
/// Such a consumer must never re-derive this mapping. `materialize_response`
/// matches exhaustively on `HumanResponseModel` with no catch-all arm, so the
/// compiler forces every new decision family through it; a reimplementation
/// living outside the engine would keep compiling while silently going stale.
///
/// Dropping the mutation does not weaken authorization. `slot_for_submission`
/// still authenticates `actor` against the slot, so this cannot be used to
/// materialize a decision that belongs to another player.
pub fn resolve_interaction_response(
state: &GameState,
actor: PlayerId,
submission: &InteractionSubmission,
) -> Result<GameAction, InteractionSubmitError> {
bound_string(submission.interaction_id.as_str())?;
validate_response_bounds(&submission.response)?;
slot_for_submission(state, actor, &submission.interaction_id)?;
let filtered = visibility::filter_state_for_viewer(state, actor);
let (action, _) = materialize_response(
state,
&filtered,
&submission.interaction_id,
&submission.response,
)?;
Ok(action)
}

/// Hidden engine-only submission entry point. The opaque interaction and choice
/// IDs are looked up against current trusted state, authorization is rechecked,
/// projection is recomputed from a viewer-filtered clone, and the materialized
Expand All @@ -8878,17 +8913,13 @@ pub fn submit_interaction(
actor: PlayerId,
submission: InteractionSubmission,
) -> Result<ActionResult, InteractionSubmitError> {
bound_string(submission.interaction_id.as_str())?;
validate_response_bounds(&submission.response)?;
let action = resolve_interaction_response(state, actor, &submission)?;
// Re-read the slot rather than threading it out of `resolve_*`: keeping that
// function's return to the action alone is what makes it usable as a public
// seam. The lookup is a scan of `active_interaction_slots`, which holds one
// slot per pending decision, and it has already succeeded once here.
let semantic_owner =
PlayerId(slot_for_submission(state, actor, &submission.interaction_id)?.semantic_owner);
let filtered = visibility::filter_state_for_viewer(state, actor);
let (action, _) = materialize_response(
state,
&filtered,
&submission.interaction_id,
&submission.response,
)?;
apply_interaction(state, actor, semantic_owner, action).map_err(|_error: EngineError| {
InteractionSubmitError {
code: InteractionReasonCode::ReducerRejected,
Expand Down
32 changes: 31 additions & 1 deletion crates/engine/tests/integration/interaction_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use engine::analysis::decision_template::{
};
use engine::game::engine::apply;
use engine::game::interaction::{
bind_interaction_authority, derive_viewer_interaction, preview_interaction, submit_interaction,
bind_interaction_authority, derive_viewer_interaction, preview_interaction,
resolve_interaction_response, submit_interaction,
};
use engine::game::scenario::{GameScenario, P0, P1};
use engine::game::scenario_db::GameScenarioDbExt;
Expand Down Expand Up @@ -291,6 +292,35 @@ fn bottom_card_opportunities_use_and_only_materialize_select_responses() {
);
}

#[test]
fn resolving_a_response_materializes_the_advertised_action_under_the_same_authorization() {
let mut state = GameState::new_two_player(42);
bind(&mut state, "resolve-seam");
let witness = progress_witness(&state, P0);

// Authorization parity with `submit_interaction` is the entire risk of a
// non-mutating sibling: without the actor check it would become a way to
// materialize — and therefore to read — a decision belonging to another
// seat. Nothing here asserts that the state is unchanged, because
// `resolve_interaction_response` takes `&GameState`: non-mutation is a
// borrow-checker guarantee, and a test of it would pass for reasons that
// have nothing to do with this function.
let unauthorized = resolve_interaction_response(&state, P1, &witness)
.expect_err("resolving authorizes against the actor, not merely the interaction id");
assert_eq!(unauthorized.code, InteractionReasonCode::NotAuthorized);

let action = resolve_interaction_response(&state, P0, &witness)
.expect("the advertised progress witness resolves to the action it denotes");
assert_eq!(action, GameAction::PassPriority);

// The same witness really is submittable, so the resolution above concerns a
// live decision rather than one the engine would have refused anyway.
// Equivalence between the two paths needs no assertion: `submit_interaction`
// delegates here, so they cannot disagree.
submit_interaction(&mut state, P0, witness)
.expect("the witness the projection advertised is submittable");
}

#[test]
fn priority_projection_previews_submits_and_rejects_stale_or_unauthorized_ids() {
let mut state = GameState::new_two_player(42);
Expand Down
Loading
Loading