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
10 changes: 10 additions & 0 deletions client/src/components/board/__tests__/PermanentCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,16 @@ describe("PermanentCard", () => {
);
});

it("renders the engine-authored permanent can't-be-blocked badge without temporary attribution", () => {
const gameState = makeState();
gameState.derived = { cant_be_blocked: [1] };
useGameStore.setState({ gameState, waitingFor: gameState.waiting_for });

renderPermanent();

expect(screen.getByLabelText("Can't be blocked")).toBeInTheDocument();
});

it("renders the engine-authored temporary can't-be-blocked badge on a face-down recipient without source attribution", () => {
const gameState = makeState();
gameState.objects[1].face_down = true;
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ pub(crate) fn targets_commit_crime(
Zone::Graveyard => {
super::players::is_opponent(state, controller, object.owner)
}
_ => false,
Zone::Library | Zone::Hand | Zone::Exile | Zone::Command => false,
})
}
})
Expand Down
12 changes: 11 additions & 1 deletion crates/engine/src/game/combat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1334,7 +1334,17 @@ fn block_restriction_statics_against_from_precomputed<'a>(
/// restrictions intentionally remain distinct.
pub fn has_cant_be_blocked_static(state: &GameState, attacker_id: ObjectId) -> bool {
let restrictions = collect_block_restriction_statics(state);
block_restriction_statics_against_from_precomputed(state, attacker_id, &restrictions)
has_cant_be_blocked_static_from_precomputed(state, attacker_id, &restrictions)
}

/// CR 509.1b: Precomputed counterpart of `has_cant_be_blocked_static` for
/// callers deriving multiple battlefield-object views from one game state.
pub fn has_cant_be_blocked_static_from_precomputed(
state: &GameState,
attacker_id: ObjectId,
restrictions: &[(ObjectId, StaticDefinition)],
) -> bool {
block_restriction_statics_against_from_precomputed(state, attacker_id, restrictions)
.into_iter()
.any(|(definition, _)| definition.mode == StaticMode::CantBeBlocked)
}
Expand Down
10 changes: 9 additions & 1 deletion crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::types::ability::{
};
use crate::types::attribution::EffectRef;
use crate::types::card::TokenImageRef;
use crate::types::card_type::CoreType;
use crate::types::counter::CounterType;
use crate::types::events::GameEvent;
use crate::types::format::GameFormat;
Expand Down Expand Up @@ -891,6 +892,7 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
// O(battlefield size); the BTreeMap stays empty (and `skip_serializing_if`
// omits the field) when no Auras are enchanting any player, which is the
// dominant case.
let block_restrictions = crate::game::combat::collect_block_restriction_statics(state);
for &obj_id in &state.battlefield {
let Some(obj) = state.objects.get(&obj_id) else {
continue;
Expand All @@ -910,7 +912,13 @@ pub fn derive_views(state: &GameState, viewer: Option<PlayerId>) -> DerivedViews
if let Some(source_id) = temporary_cant_be_blocked_source(state, obj_id) {
views.temporary_cant_be_blocked.insert(obj_id, source_id);
}
if crate::game::combat::has_cant_be_blocked_static(state, obj_id) {
if obj.card_types.core_types.contains(&CoreType::Creature)
&& crate::game::combat::has_cant_be_blocked_static_from_precomputed(
state,
obj_id,
&block_restrictions,
)
{
views.cant_be_blocked.push(obj_id);
}
// CR 613.2a + CR 707.2 / CR 708.2: see `copied_permanents`. Matched
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,9 @@ pub fn apply_resolved_ledger_edit(
.ok_or(ResolvedLedgerEditReplayInvariantError::UnknownPlayer(
*player,
))?;
if player_state.crimes_committed_this_turn != *expected_turn_count {
if *expected_turn_count != 0
|| player_state.crimes_committed_this_turn != *expected_turn_count
{
return Err(
ResolvedLedgerEditReplayInvariantError::CrimeCommittedPreconditionMismatch,
);
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/types/resolved_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3197,7 +3197,7 @@ pub(crate) fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool {
ResolvedLedgerEdit::CrimeCommitted {
expected_turn_count,
..
} => *expected_turn_count == u32::MAX,
} => *expected_turn_count != 0,
ResolvedLedgerEdit::CardsDrawn {
drawn_object,
attempted_empty_library,
Expand Down
35 changes: 31 additions & 4 deletions crates/engine/tests/integration/crime_tracking.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
//! CR 700.13: committed crimes are durable only after a successful action and
//! reset at the next turn boundary.
//! CR 700.13: a player commits a crime by targeting an opponent, specified
//! opponent-controlled objects, or an opponent-owned graveyard card.

use engine::game::ledger::record_crime_committed;
use engine::game::ledger::{record_crime_committed, resolve_and_apply_ledger_edit};
use engine::game::turns::start_next_turn;
use engine::types::game_state::GameState;
use engine::types::player::PlayerId;
use engine::types::resolved_commands::{
ResolvedLedgerEdit, ResolvedLedgerEditReplayInvariantError,
};

#[test]
fn crime_ledger_edit_is_turn_scoped() {
let mut state = GameState::new_two_player(42);

record_crime_committed(&mut state, PlayerId(0)).expect("live player records a crime");
record_crime_committed(&mut state, PlayerId(0)).expect("repeat crime preserves turn fact");
record_crime_committed(&mut state, PlayerId(1)).expect("other player records their crime");
assert_eq!(state.players[0].crimes_committed_this_turn, 1);
assert_eq!(state.players[1].crimes_committed_this_turn, 1);

start_next_turn(&mut state, &mut Vec::new());
assert_eq!(
state.players[0].crimes_committed_this_turn, 0,
"a new turn clears the CR 700.13 per-turn record"
"a new turn clears the first player's per-turn engine record"
);
assert_eq!(
state.players[1].crimes_committed_this_turn, 0,
"a new turn clears every player's per-turn engine record"
);
}

#[test]
fn crime_ledger_replay_rejects_a_second_turn_record() {
let mut state = GameState::new_two_player(42);
let edit = ResolvedLedgerEdit::CrimeCommitted {
player: PlayerId(0),
expected_turn_count: 0,
};

resolve_and_apply_ledger_edit(&mut state, edit.clone())
.expect("the first exact crime edit establishes the turn fact");

assert_eq!(
resolve_and_apply_ledger_edit(&mut state, edit),
Err(ResolvedLedgerEditReplayInvariantError::CrimeCommittedPreconditionMismatch),
);
assert_eq!(state.players[0].crimes_committed_this_turn, 1);
}
Loading