diff --git a/openfut-adapter-fifa17/src/fut/season_wire.rs b/openfut-adapter-fifa17/src/fut/season_wire.rs index 39f941d..d23760a 100644 --- a/openfut-adapter-fifa17/src/fut/season_wire.rs +++ b/openfut-adapter-fifa17/src/fut/season_wire.rs @@ -42,6 +42,10 @@ pub const SEASON_ROUNDS: i64 = 10; /// resolves to a team the client can actually render. They are cycled rather /// than randomised so a season's schedule is stable across reloads — the client /// re-reads `season/list` and a shifting schedule would renumber fixtures. +/// +/// The club's OWN kit team is filtered out at schedule time — see +/// [`season_list_body`]. Fixing this list to exclude one id would not do, because +/// which team the club wears is ownership state, not a constant. const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243]; /// One scheduled offline-season round. @@ -90,9 +94,9 @@ pub struct SeasonUser { pub data: &'static str, } -fn round(index: i64) -> SeasonMatch { +fn round(index: i64, opponents: &[i64]) -> SeasonMatch { SeasonMatch { - team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()], + team_id: opponents[(index as usize) % opponents.len()], // Difficulty and reward multiplier are per-round bytes; a flat schedule // is the honest default until the retail ladder is captured. difficulty: 1, @@ -104,13 +108,35 @@ fn round(index: i64) -> SeasonMatch { /// `GET …/season/list` — the offline competitions the club can enter, as wire /// text (see the module note on key order). -pub fn season_list_body(season_id: i64, division_id: i64) -> String { +/// +/// `own_kit_team_id` is the team whose kit the club wears, taken from its active +/// kit items. That team is EXCLUDED from the schedule, because the pre-match kit +/// clone resolves both sides out of the same `teamkits` table keyed on +/// `teamtechid`: drawing your own kit team makes the opponent render your kit, so +/// both sides appear in identical strips. It is also simply wrong data — a club +/// would be playing itself. +/// +/// Passing `None` (or a team not in the rotation) keeps the full schedule. +pub fn season_list_body(season_id: i64, division_id: i64, own_kit_team_id: Option) -> String { + let opponents: Vec = OPPONENT_TEAM_IDS + .iter() + .copied() + .filter(|id| Some(*id) != own_kit_team_id) + .collect(); + // Never emit an empty rotation: `matches` must be non-empty or StartSeason + // dereferences NULL (see the module note), so an exclusion that would empty + // the list is ignored rather than allowed to crash the client. + let opponents: &[i64] = if opponents.is_empty() { + OPPONENT_TEAM_IDS + } else { + &opponents + }; let list = SeasonList { seasons: vec![SeasonElement { kind: "OFFLINE", id: season_id, division_id, - matches: (0..SEASON_ROUNDS).map(round).collect(), + matches: (0..SEASON_ROUNDS).map(|i| round(i, opponents)).collect(), }], }; serde_json::to_string(&list).expect("season list serialises") @@ -146,7 +172,7 @@ mod tests { #[test] fn list_emits_a_full_round_schedule() { - let body = parsed(&season_list_body(1, 10)); + let body = parsed(&season_list_body(1, 10, None)); let season = &body["seasons"][0]; assert_eq!(season["type"], "OFFLINE"); assert_eq!(season["id"], 1); @@ -161,7 +187,7 @@ mod tests { /// (CardsDLL+0xfc5b5), so the schedule can never be empty. #[test] fn matches_are_never_empty_and_every_round_has_a_team() { - let body = parsed(&season_list_body(3, 7)); + let body = parsed(&season_list_body(3, 7, None)); let matches = body["seasons"][0]["matches"].as_array().unwrap(); assert!(!matches.is_empty()); for (i, m) in matches.iter().enumerate() { @@ -181,7 +207,7 @@ mod tests { /// order them alphabetically and break this. #[test] fn type_is_serialised_before_division_id() { - let text = season_list_body(1, 10); + let text = season_list_body(1, 10, None); let type_at = text.find("\"type\"").expect("type key"); let division_at = text.find("\"divisionId\"").expect("divisionId key"); assert!( @@ -190,6 +216,44 @@ mod tests { ); } + /// The pre-match kit clone resolves both sides out of the same `teamkits` + /// table keyed on `teamtechid`, so drawing the club's own kit team puts the + /// opponent in the club's strip. It is also a club playing itself. + #[test] + fn own_kit_team_is_never_scheduled_as_an_opponent() { + let own = OPPONENT_TEAM_IDS[0]; + let body = parsed(&season_list_body(1, 10, Some(own))); + let matches = body["seasons"][0]["matches"].as_array().unwrap(); + assert_eq!(matches.len(), SEASON_ROUNDS as usize, "still a full ladder"); + for m in matches { + assert_ne!( + m["teamId"].as_i64().unwrap(), + own, + "the club's own kit team must not be an opponent: {m}" + ); + } + // The remaining teams are still cycled, so the schedule stays stable and + // every round names a renderable team. + for (i, m) in matches.iter().enumerate() { + assert_eq!(m["roundId"], i as i64); + assert!(m["teamId"].as_i64().is_some_and(|t| t > 0)); + } + } + + /// Excluding a team that would empty the rotation must NOT produce an empty + /// `matches` array, because that crashes StartSeason. + #[test] + fn an_exclusion_that_would_empty_the_rotation_is_ignored() { + // Stand-in for the degenerate case: pretend every id is the own team by + // excluding each in turn and asserting the ladder is always full. + for own in OPPONENT_TEAM_IDS { + let body = parsed(&season_list_body(1, 10, Some(*own))); + let matches = body["seasons"][0]["matches"].as_array().unwrap(); + assert_eq!(matches.len(), SEASON_ROUNDS as usize); + assert!(!matches.is_empty(), "matches must never be empty"); + } + } + #[test] fn user_state_carries_the_season_position() { let body = parsed(&season_user_body(1, 10, 3, 6)); diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 0762c48..ce526d8 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -4964,6 +4964,28 @@ impl Server { json_status(200, &non_economy::feature_off_body()) } + /// The team whose kit this club currently wears, from its active kit items. + /// + /// The pre-match kit clone resolves BOTH sides out of the client's own + /// `teamkits` table keyed on `teamtechid`, with only `teamkittypetechid` + /// distinguishing home from away. So if a season fixture names the club's own + /// kit team, the opponent renders the club's kit and both sides appear in + /// identical strips — which is also just wrong data, a club playing itself. + /// [`season_wire::season_list_body`] excludes this team from the schedule. + /// + /// Best-effort: any Core hiccup yields `None` and the full rotation, which is + /// the pre-existing behaviour rather than a failed request. + fn own_kit_team_id(&self) -> Option { + let active = self.core.get_active_kits().ok()?; + let designated = active.home_owned_card_id.or(active.away_owned_card_id)?; + let page = self.core.query_owned(&[]).ok()?; + let item = page + .items + .iter() + .find(|item| item.owned_card_id == designated)?; + self.resolver.resolve_kit(item).map(|kit| kit.team_id) + } + /// `…/season…` — FIFA 17 offline Seasons. /// /// The client will not open the mode until it has a schedule: `season/list` @@ -4987,7 +5009,7 @@ impl Server { let (kind, body) = match sub { "list" => ( "list", - season_wire::season_list_body(SEASON_ID, DIVISION_ID), + season_wire::season_list_body(SEASON_ID, DIVISION_ID, self.own_kit_team_id()), ), "user" => ( "user",