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:
funman300
2026-08-24 18:26:01 +00:00
parent a5e5628039
commit 98f30931a0
2 changed files with 94 additions and 8 deletions
+71 -7
View File
@@ -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));
+23 -1
View File
@@ -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",