fix(fifa17): never schedule the club's own kit team as a season opponent
The pre-match kit clone resolves BOTH sides out of the client's own
teamkits table keyed on teamtechid, with only teamkittypetechid (0 home,
1 away) telling the strips apart:
teamtechid == record+0x94 (wire teamid)
teamkittypetechid == 0 for activeHomeKit, 1 for activeAwayKit
year == record+0xba (wire year)
Our club wears team 21's kit (fcc_kitcards carddbid 6300006/6400003 are
both teamid 21), and the offline-season ladder cycled a fixed opponent
list whose first entry was also 21. So round 0 put the club against the
team whose kit it wears and both sides rendered the same strip. It is
also simply wrong data: a club playing itself.
The schedule now excludes the club's own kit team, which the host derives
from Core's active kit designations via resolve_kit. An exclusion that
would empty the rotation is ignored, because an empty matches array makes
StartSeason dereference NULL at CardsDLL+0xfc5b5.
This is not a kit-pipeline change: squad.actives already produces the two
resident cardtype-7 records with the correct itemStates, and the clone
query is satisfied by that data unchanged.
This commit is contained in:
@@ -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<i64>) -> String {
|
||||
let opponents: Vec<i64> = 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));
|
||||
|
||||
@@ -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<i64> {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user