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
|
/// 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
|
/// than randomised so a season's schedule is stable across reloads — the client
|
||||||
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
|
/// 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];
|
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
|
||||||
|
|
||||||
/// One scheduled offline-season round.
|
/// One scheduled offline-season round.
|
||||||
@@ -90,9 +94,9 @@ pub struct SeasonUser {
|
|||||||
pub data: &'static str,
|
pub data: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round(index: i64) -> SeasonMatch {
|
fn round(index: i64, opponents: &[i64]) -> SeasonMatch {
|
||||||
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
|
// Difficulty and reward multiplier are per-round bytes; a flat schedule
|
||||||
// is the honest default until the retail ladder is captured.
|
// is the honest default until the retail ladder is captured.
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -104,13 +108,35 @@ fn round(index: i64) -> SeasonMatch {
|
|||||||
|
|
||||||
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
||||||
/// text (see the module note on key order).
|
/// 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 {
|
let list = SeasonList {
|
||||||
seasons: vec![SeasonElement {
|
seasons: vec![SeasonElement {
|
||||||
kind: "OFFLINE",
|
kind: "OFFLINE",
|
||||||
id: season_id,
|
id: season_id,
|
||||||
division_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")
|
serde_json::to_string(&list).expect("season list serialises")
|
||||||
@@ -146,7 +172,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_emits_a_full_round_schedule() {
|
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];
|
let season = &body["seasons"][0];
|
||||||
assert_eq!(season["type"], "OFFLINE");
|
assert_eq!(season["type"], "OFFLINE");
|
||||||
assert_eq!(season["id"], 1);
|
assert_eq!(season["id"], 1);
|
||||||
@@ -161,7 +187,7 @@ mod tests {
|
|||||||
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_are_never_empty_and_every_round_has_a_team() {
|
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();
|
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||||
assert!(!matches.is_empty());
|
assert!(!matches.is_empty());
|
||||||
for (i, m) in matches.iter().enumerate() {
|
for (i, m) in matches.iter().enumerate() {
|
||||||
@@ -181,7 +207,7 @@ mod tests {
|
|||||||
/// order them alphabetically and break this.
|
/// order them alphabetically and break this.
|
||||||
#[test]
|
#[test]
|
||||||
fn type_is_serialised_before_division_id() {
|
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 type_at = text.find("\"type\"").expect("type key");
|
||||||
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
||||||
assert!(
|
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]
|
#[test]
|
||||||
fn user_state_carries_the_season_position() {
|
fn user_state_carries_the_season_position() {
|
||||||
let body = parsed(&season_user_body(1, 10, 3, 6));
|
let body = parsed(&season_user_body(1, 10, 3, 6));
|
||||||
|
|||||||
@@ -4964,6 +4964,28 @@ impl Server {
|
|||||||
json_status(200, &non_economy::feature_off_body())
|
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.
|
/// `…/season…` — FIFA 17 offline Seasons.
|
||||||
///
|
///
|
||||||
/// The client will not open the mode until it has a schedule: `season/list`
|
/// 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 {
|
let (kind, body) = match sub {
|
||||||
"list" => (
|
"list" => (
|
||||||
"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" => (
|
||||||
"user",
|
"user",
|
||||||
|
|||||||
Reference in New Issue
Block a user