|
|
|
@@ -0,0 +1,445 @@
|
|
|
|
|
//! FIFA 17 Offline Seasons game-setup team compatibility candidate.
|
|
|
|
|
//!
|
|
|
|
|
//! `FUT::SeasonsManagerOfflineHelper` first projects the authentic dynamic pair
|
|
|
|
|
//! `[fixture_team, user_team]`. Later, `futSelectTeam::SetupTeamsInfo()` asks
|
|
|
|
|
//! `CardsGameSetupAdapter.GetTeam(side)` while rebuilding its panel state. The
|
|
|
|
|
//! first native reads expose the correct fixture and user teams on distinct GetTeam
|
|
|
|
|
//! sides. A later repeated read of the user-returning side is fed into SetTeam's
|
|
|
|
|
//! inverse side mapping and duplicates the user's XI over the opponent.
|
|
|
|
|
//!
|
|
|
|
|
//! This default-off candidate corrects that source read, not SetTeam or the final
|
|
|
|
|
//! writer. It records the projector pair, requires one native observation of each
|
|
|
|
|
//! team on distinct sides, and permits one correction on the next repeated
|
|
|
|
|
//! user-team read. Missing or conflicting evidence always preserves native behavior.
|
|
|
|
|
|
|
|
|
|
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
|
|
|
use std::sync::Mutex;
|
|
|
|
|
|
|
|
|
|
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
|
|
|
|
|
|
|
|
|
use crate::sbc_trace::{readable_range, target_va, validate_cards_build};
|
|
|
|
|
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32};
|
|
|
|
|
use crate::write_log;
|
|
|
|
|
|
|
|
|
|
const ENABLE_ENV: &str = "OPENFUT_FIFA17_SEASON_TEAM_COMPAT";
|
|
|
|
|
|
|
|
|
|
const FIXTURE_PROJECTOR_RVA: usize = 0x0fc500;
|
|
|
|
|
const GET_TEAM_RVA: usize = 0x0054a0;
|
|
|
|
|
|
|
|
|
|
const FIXTURE_PROJECTOR_SIGNATURE: [u8; 19] = [
|
|
|
|
|
0x40, 0x57, 0x41, 0x54, 0x41, 0x56, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
|
|
|
|
|
0xff, 0xff, 0xff,
|
|
|
|
|
];
|
|
|
|
|
const GET_TEAM_SIGNATURE: [u8; 17] = [
|
|
|
|
|
0x48, 0x8b, 0x05, 0xb9, 0x8a, 0x2d, 0x00, 0x4c, 0x8b, 0x80, 0x50, 0x03, 0x00, 0x00, 0x49, 0xff,
|
|
|
|
|
0xe0,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const UNKNOWN_SIDE: i32 = -1;
|
|
|
|
|
|
|
|
|
|
static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false);
|
|
|
|
|
static FIXTURE_PROJECTOR_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
|
static GET_TEAM_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
|
static TEAM_STATE: Mutex<TeamState> = Mutex::new(TeamState::empty());
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
struct TeamState {
|
|
|
|
|
fixture_team: i32,
|
|
|
|
|
user_team: i32,
|
|
|
|
|
fixture_get_side: i32,
|
|
|
|
|
user_get_side: i32,
|
|
|
|
|
correction_used: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TeamState {
|
|
|
|
|
const fn empty() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
fixture_team: 0,
|
|
|
|
|
user_team: 0,
|
|
|
|
|
fixture_get_side: UNKNOWN_SIDE,
|
|
|
|
|
user_get_side: UNKNOWN_SIDE,
|
|
|
|
|
correction_used: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn capture(&mut self, fixture_team: i32, user_team: i32) -> bool {
|
|
|
|
|
if !valid_pair(fixture_team, user_team) {
|
|
|
|
|
*self = Self::empty();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
*self = Self {
|
|
|
|
|
fixture_team,
|
|
|
|
|
user_team,
|
|
|
|
|
fixture_get_side: UNKNOWN_SIDE,
|
|
|
|
|
user_get_side: UNKNOWN_SIDE,
|
|
|
|
|
correction_used: false,
|
|
|
|
|
};
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn observe_get_team(&mut self, side: i32, native_team: i32) -> GetTeamDecision {
|
|
|
|
|
if !valid_side(side) || !valid_pair(self.fixture_team, self.user_team) {
|
|
|
|
|
return GetTeamDecision::native(native_team);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if native_team == self.fixture_team {
|
|
|
|
|
if (self.fixture_get_side != UNKNOWN_SIDE && self.fixture_get_side != side)
|
|
|
|
|
|| self.user_get_side == side
|
|
|
|
|
{
|
|
|
|
|
return self.clear_on_conflict(native_team);
|
|
|
|
|
}
|
|
|
|
|
let first_observation = self.fixture_get_side == UNKNOWN_SIDE;
|
|
|
|
|
self.fixture_get_side = side;
|
|
|
|
|
return GetTeamDecision {
|
|
|
|
|
team: native_team,
|
|
|
|
|
event: if self.user_get_side != UNKNOWN_SIDE {
|
|
|
|
|
DecisionEvent::NativePairConfirmed
|
|
|
|
|
} else if first_observation {
|
|
|
|
|
DecisionEvent::FixtureObserved
|
|
|
|
|
} else {
|
|
|
|
|
DecisionEvent::None
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if native_team == self.user_team {
|
|
|
|
|
if self.user_get_side == UNKNOWN_SIDE {
|
|
|
|
|
if self.fixture_get_side == side {
|
|
|
|
|
return self.clear_on_conflict(native_team);
|
|
|
|
|
}
|
|
|
|
|
self.user_get_side = side;
|
|
|
|
|
return GetTeamDecision {
|
|
|
|
|
team: native_team,
|
|
|
|
|
event: if self.fixture_get_side != UNKNOWN_SIDE {
|
|
|
|
|
DecisionEvent::NativePairConfirmed
|
|
|
|
|
} else {
|
|
|
|
|
DecisionEvent::UserObserved
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if self.user_get_side != side {
|
|
|
|
|
return self.clear_on_conflict(native_team);
|
|
|
|
|
}
|
|
|
|
|
if !self.correction_used && self.fixture_get_side != UNKNOWN_SIDE {
|
|
|
|
|
self.correction_used = true;
|
|
|
|
|
return GetTeamDecision {
|
|
|
|
|
team: self.fixture_team,
|
|
|
|
|
event: DecisionEvent::Corrected,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GetTeamDecision::native(native_team)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn clear_on_conflict(&mut self, native_team: i32) -> GetTeamDecision {
|
|
|
|
|
*self = Self::empty();
|
|
|
|
|
GetTeamDecision {
|
|
|
|
|
team: native_team,
|
|
|
|
|
event: DecisionEvent::ConflictingNativePair,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
enum DecisionEvent {
|
|
|
|
|
None,
|
|
|
|
|
FixtureObserved,
|
|
|
|
|
UserObserved,
|
|
|
|
|
NativePairConfirmed,
|
|
|
|
|
ConflictingNativePair,
|
|
|
|
|
Corrected,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
struct GetTeamDecision {
|
|
|
|
|
team: i32,
|
|
|
|
|
event: DecisionEvent,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GetTeamDecision {
|
|
|
|
|
const fn native(team: i32) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
team,
|
|
|
|
|
event: DecisionEvent::None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fn valid_side(side: i32) -> bool {
|
|
|
|
|
side == 0 || side == 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fn valid_pair(fixture_team: i32, user_team: i32) -> bool {
|
|
|
|
|
fixture_team > 0 && user_team > 0 && fixture_team != user_team
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn enabled(value: Option<&str>) -> bool {
|
|
|
|
|
value == Some("1")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn exact_signature(current: &[u8], expected: &[u8]) -> bool {
|
|
|
|
|
current == expected
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unsafe fn target_matches(base: usize, rva: usize, signature: &[u8]) -> bool {
|
|
|
|
|
let Some(target) = target_va(base, rva) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
readable_range(target, signature.len())
|
|
|
|
|
&& exact_signature(
|
|
|
|
|
core::slice::from_raw_parts(target as *const u8, signature.len()),
|
|
|
|
|
signature,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FixtureProjectorFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize;
|
|
|
|
|
type GetTeamFn = unsafe extern "system" fn(usize, i32) -> i32;
|
|
|
|
|
|
|
|
|
|
unsafe extern "system" fn fixture_projector_wrapper(
|
|
|
|
|
context: usize,
|
|
|
|
|
output_pair: usize,
|
|
|
|
|
r8: usize,
|
|
|
|
|
r9: usize,
|
|
|
|
|
) -> usize {
|
|
|
|
|
let trampoline = FIXTURE_PROJECTOR_TRAMPOLINE.load(Ordering::Acquire);
|
|
|
|
|
if trampoline == 0 {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
let original: FixtureProjectorFn = core::mem::transmute(trampoline);
|
|
|
|
|
let result = original(context, output_pair, r8, r9);
|
|
|
|
|
|
|
|
|
|
let pair = rd_i32(output_pair).zip(rd_i32(output_pair.saturating_add(4)));
|
|
|
|
|
let captured = pair.is_some_and(|(fixture_team, user_team)| {
|
|
|
|
|
TEAM_STATE
|
|
|
|
|
.lock()
|
|
|
|
|
.map(|mut state| state.capture(fixture_team, user_team))
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
});
|
|
|
|
|
match pair {
|
|
|
|
|
Some((fixture_team, user_team)) if captured => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: fixture captured fixture={fixture_team} user={user_team}\n"
|
|
|
|
|
)),
|
|
|
|
|
Some((fixture_team, user_team)) => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: invalid fixture pair [{fixture_team},{user_team}]; inactive\n"
|
|
|
|
|
)),
|
|
|
|
|
None => {
|
|
|
|
|
if let Ok(mut state) = TEAM_STATE.lock() {
|
|
|
|
|
*state = TeamState::empty();
|
|
|
|
|
}
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: unreadable fixture pair; inactive\n");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unsafe extern "system" fn get_team_wrapper(adapter: usize, side: i32) -> i32 {
|
|
|
|
|
let trampoline = GET_TEAM_TRAMPOLINE.load(Ordering::Acquire);
|
|
|
|
|
if trampoline == 0 {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
let original: GetTeamFn = core::mem::transmute(trampoline);
|
|
|
|
|
let native_team = original(adapter, side);
|
|
|
|
|
if !REPAIR_ACTIVE.load(Ordering::Acquire) {
|
|
|
|
|
return native_team;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let decision = TEAM_STATE
|
|
|
|
|
.lock()
|
|
|
|
|
.map(|mut state| state.observe_get_team(side, native_team))
|
|
|
|
|
.unwrap_or_else(|_| GetTeamDecision::native(native_team));
|
|
|
|
|
match decision.event {
|
|
|
|
|
DecisionEvent::FixtureObserved => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: fixture observed side={side} team={native_team}\n"
|
|
|
|
|
)),
|
|
|
|
|
DecisionEvent::UserObserved => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: user observed side={side} team={native_team}\n"
|
|
|
|
|
)),
|
|
|
|
|
DecisionEvent::NativePairConfirmed => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: native pair confirmed side={side} team={native_team}\n"
|
|
|
|
|
)),
|
|
|
|
|
DecisionEvent::ConflictingNativePair => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: conflicting native pair at side={side}; state cleared\n"
|
|
|
|
|
)),
|
|
|
|
|
DecisionEvent::Corrected => write_log(&format!(
|
|
|
|
|
"SEASON_TEAM_COMPAT: corrected GetTeam side={side} native={native_team} fixture={}\n",
|
|
|
|
|
decision.team
|
|
|
|
|
)),
|
|
|
|
|
DecisionEvent::None => {}
|
|
|
|
|
}
|
|
|
|
|
decision.team
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unsafe fn worker() {
|
|
|
|
|
let mut base = 0usize;
|
|
|
|
|
for _ in 0..600u32 {
|
|
|
|
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
|
|
|
|
if base != 0 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
|
|
|
}
|
|
|
|
|
if base == 0 || !validate_cards_build(base) {
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: CardsDLL unavailable/invalid; inactive\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if !target_matches(base, FIXTURE_PROJECTOR_RVA, &FIXTURE_PROJECTOR_SIGNATURE)
|
|
|
|
|
|| !target_matches(base, GET_TEAM_RVA, &GET_TEAM_SIGNATURE)
|
|
|
|
|
{
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: target signature mismatch; inactive\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !install_detour(
|
|
|
|
|
base,
|
|
|
|
|
FIXTURE_PROJECTOR_RVA,
|
|
|
|
|
"OfflineSeason_fixture_projector",
|
|
|
|
|
FIXTURE_PROJECTOR_SIGNATURE.len(),
|
|
|
|
|
&FIXTURE_PROJECTOR_SIGNATURE,
|
|
|
|
|
fixture_projector_wrapper as *const () as usize,
|
|
|
|
|
&FIXTURE_PROJECTOR_TRAMPOLINE,
|
|
|
|
|
) {
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: fixture projector hook failed; inactive\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if !install_detour_reloc(
|
|
|
|
|
base,
|
|
|
|
|
GET_TEAM_RVA,
|
|
|
|
|
"CardsGameSetupAdapter_GetTeam",
|
|
|
|
|
GET_TEAM_SIGNATURE.len(),
|
|
|
|
|
&GET_TEAM_SIGNATURE,
|
|
|
|
|
3,
|
|
|
|
|
7,
|
|
|
|
|
get_team_wrapper as *const () as usize,
|
|
|
|
|
&GET_TEAM_TRAMPOLINE,
|
|
|
|
|
) {
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: GetTeam hook failed; inactive\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
REPAIR_ACTIVE.store(true, Ordering::Release);
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: candidate ARMED; exact fixture evidence gate enabled\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn install() {
|
|
|
|
|
if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) {
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: disabled\n");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
write_log("SEASON_TEAM_COMPAT: requested; deferred signature validation starting\n");
|
|
|
|
|
std::thread::spawn(|| unsafe { worker() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn feature_is_default_off() {
|
|
|
|
|
assert!(!enabled(None));
|
|
|
|
|
assert!(!enabled(Some("0")));
|
|
|
|
|
assert!(!enabled(Some("true")));
|
|
|
|
|
assert!(enabled(Some("1")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn signature_validation_is_exact() {
|
|
|
|
|
assert!(exact_signature(&GET_TEAM_SIGNATURE, &GET_TEAM_SIGNATURE));
|
|
|
|
|
let mut changed = GET_TEAM_SIGNATURE;
|
|
|
|
|
changed[0] ^= 1;
|
|
|
|
|
assert!(!exact_signature(&changed, &GET_TEAM_SIGNATURE));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn invalid_fixture_never_arms_state() {
|
|
|
|
|
let mut state = TeamState::empty();
|
|
|
|
|
assert!(!state.capture(0, 130000));
|
|
|
|
|
assert!(!state.capture(73, 73));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(0, 130000),
|
|
|
|
|
GetTeamDecision::native(130000)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn fixture_must_be_observed_natively_before_correction() {
|
|
|
|
|
let mut state = TeamState::empty();
|
|
|
|
|
assert!(state.capture(73, 130000));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(1, 130000),
|
|
|
|
|
GetTeamDecision {
|
|
|
|
|
team: 130000,
|
|
|
|
|
event: DecisionEvent::UserObserved
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(1, 130000),
|
|
|
|
|
GetTeamDecision::native(130000)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(state.fixture_get_side, UNKNOWN_SIDE);
|
|
|
|
|
assert!(!state.correction_used);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn correction_requires_native_pair_then_is_one_shot() {
|
|
|
|
|
let mut state = TeamState::empty();
|
|
|
|
|
assert!(state.capture(73, 130000));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(0, 73),
|
|
|
|
|
GetTeamDecision {
|
|
|
|
|
team: 73,
|
|
|
|
|
event: DecisionEvent::FixtureObserved
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(1, 130000),
|
|
|
|
|
GetTeamDecision {
|
|
|
|
|
team: 130000,
|
|
|
|
|
event: DecisionEvent::NativePairConfirmed
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(1, 130000),
|
|
|
|
|
GetTeamDecision {
|
|
|
|
|
team: 73,
|
|
|
|
|
event: DecisionEvent::Corrected
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(1, 130000),
|
|
|
|
|
GetTeamDecision::native(130000)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn second_fixture_replaces_all_prior_state() {
|
|
|
|
|
let mut state = TeamState::empty();
|
|
|
|
|
assert!(state.capture(73, 130000));
|
|
|
|
|
assert_eq!(state.observe_get_team(0, 73).team, 73);
|
|
|
|
|
assert_eq!(state.observe_get_team(1, 130000).team, 130000);
|
|
|
|
|
assert_eq!(state.observe_get_team(1, 130000).team, 73);
|
|
|
|
|
|
|
|
|
|
assert!(state.capture(240, 130000));
|
|
|
|
|
assert_eq!(state.fixture_get_side, UNKNOWN_SIDE);
|
|
|
|
|
assert_eq!(state.user_get_side, UNKNOWN_SIDE);
|
|
|
|
|
assert!(!state.correction_used);
|
|
|
|
|
assert_eq!(state.observe_get_team(0, 240).team, 240);
|
|
|
|
|
assert_eq!(state.observe_get_team(1, 130000).team, 130000);
|
|
|
|
|
assert_eq!(state.observe_get_team(1, 130000).team, 240);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn conflicting_fixture_sides_fail_closed() {
|
|
|
|
|
let mut state = TeamState::empty();
|
|
|
|
|
assert!(state.capture(73, 130000));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(0, 73).event,
|
|
|
|
|
DecisionEvent::FixtureObserved
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.observe_get_team(1, 73).event,
|
|
|
|
|
DecisionEvent::ConflictingNativePair
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(state, TeamState::empty());
|
|
|
|
|
}
|
|
|
|
|
}
|