feat(fifa17): manager contracts, from a Core-owned staff tier
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every non-player Core definition while `d.rating` already held EA's authoritative `value` -- and the very next block wrote that same number correctly to the adapter catalog. So the tier existed host-side but never reached Core: Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 -> tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is exactly what the 409 was protecting against, so the refusal was correct. The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating the production pack changes exactly 18 entries and exactly one field each (source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a. WHY value IS the tier source, and why the thresholds are the player ladder: LIVE_PROVEN, not inferred. The client re-rates staff from its own managercards/*coachcards/physiocards by carddbid and applies discard_level's 65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3, coaches 66 -> level 2). The shipped coach tables corroborate: each family has exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2. Manager contracts stop refusing and now resolve the TARGET's tier from Core-owned state. Still fail-closed everywhere it matters: a coach or physio is `contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a manager Core carries no source_rating for is `manager_tier_unknown` rather than a guessed tier. Core's own content_kind token is sent as target_kind, because Core calls the squad manager `manager` while the catalog classifies it `staff`+subtype 4. NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
This commit is contained in:
@@ -159,6 +159,8 @@ mod tests {
|
||||
// Untracked by default, so these fixtures exercise the pack-fresh
|
||||
// fallback; a test that cares sets it explicitly.
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@
|
||||
//! ## Family gating
|
||||
//!
|
||||
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
|
||||
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS/STAFF only. 202 cannot be
|
||||
//! honoured today for a reason that is a data gap, not a policy: staff ratings
|
||||
//! are not imported, so a manager target's tier cannot be determined honestly and
|
||||
//! there is no defensible column to read. The caller must REFUSE a 202 apply
|
||||
//! rather than default the tier — inventing gold (or bronze, or the card's own
|
||||
//! tier) would silently pay out the wrong number with no error anywhere.
|
||||
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS only. A 202 target's tier comes
|
||||
//! from [`staff_tier`], whose input is Core's authored definition rating for the
|
||||
//! staff card (EA's `value` column). When Core carries none, [`staff_tier`]
|
||||
//! answers `None` and the caller must REFUSE the apply — inventing gold (or
|
||||
//! bronze, or the card's own tier) would silently pay out the wrong number with
|
||||
//! no error anywhere.
|
||||
//!
|
||||
//! A contract also cannot be applied to a LOAN item. This crate models no loan
|
||||
//! state, so that gate — like the 201/202 family check — is the caller's: this
|
||||
@@ -87,6 +87,16 @@ impl ContractTier {
|
||||
ContractTier::Gold => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tier's lowercase log token. The three names are the game's own tier
|
||||
/// names, so a log line reads the same as the screen.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ContractTier::Bronze => "bronze",
|
||||
ContractTier::Silver => "silver",
|
||||
ContractTier::Gold => "gold",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Card tier from a rating: gold `>= 75`, silver `65..=74`, bronze `< 65`.
|
||||
@@ -106,6 +116,26 @@ pub fn tier_for_rating(rating: u8) -> ContractTier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier of a STAFF target. `None` when Core carries no authoritative value —
|
||||
/// the caller MUST fail closed and never substitute a tier.
|
||||
///
|
||||
/// `source_rating` is EA's authored `value` for the staff definition, i.e. the
|
||||
/// number the CLIENT ITSELF re-rates the card to: it merges a staff record from
|
||||
/// its own `managercards`/`headcoachcards`/`fitnesscoachcards`/`physiocards`/
|
||||
/// `gkcoachcards` table keyed on `carddbid`, ignoring whatever `rating` the
|
||||
/// server sent. Core's `overall` is deliberately 0 for a non-player (it feeds
|
||||
/// pricing and projection), so `overall` is NOT the tier source and must not be
|
||||
/// read as one.
|
||||
///
|
||||
/// The ladder is [`tier_for_rating`], unchanged and not re-thresholded here:
|
||||
/// staff are LIVE-PROVEN to use the SAME ladder as players. `coach_probe.py` and
|
||||
/// `discard_probe.py` agree 4/4 against the running client — manager `value` 88
|
||||
/// re-rates to discard level 3 (gold) and coaches at `value` 66 to level 2
|
||||
/// (silver), exactly as [`super::discard::discard_level`] scores a player.
|
||||
pub fn staff_tier(source_rating: Option<u8>) -> Option<ContractTier> {
|
||||
source_rating.map(tier_for_rating)
|
||||
}
|
||||
|
||||
/// Matches granted by contract consumable `resource_id` against a target of
|
||||
/// `tier`.
|
||||
///
|
||||
@@ -134,8 +164,8 @@ pub const PACK_FRESH_CONTRACT_MATCHES: i64 = 7;
|
||||
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
|
||||
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
|
||||
|
||||
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers/staff only,
|
||||
/// and is currently unservable — see the module header's family-gating note.
|
||||
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers only; the
|
||||
/// target's tier comes from [`staff_tier`] over Core's authored staff rating.
|
||||
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -155,6 +185,42 @@ mod tests {
|
||||
assert_eq!(tier_for_rating(99), ContractTier::Gold);
|
||||
}
|
||||
|
||||
/// A staff target Core carries no authored rating for has NO tier. `None` is
|
||||
/// what lets the caller refuse; defaulting to bronze would silently under-pay
|
||||
/// a gold manager, and defaulting to gold would over-pay every unknown one.
|
||||
#[test]
|
||||
fn an_unrated_staff_target_has_no_tier() {
|
||||
assert_eq!(staff_tier(None), None);
|
||||
}
|
||||
|
||||
/// Staff read the SAME ladder as players, so the boundaries are the same
|
||||
/// exact 64/65 and 74/75 — `staff_tier` must not re-threshold.
|
||||
#[test]
|
||||
fn staff_tier_boundaries_are_the_player_ladder() {
|
||||
assert_eq!(staff_tier(Some(64)), Some(ContractTier::Bronze));
|
||||
assert_eq!(staff_tier(Some(65)), Some(ContractTier::Silver));
|
||||
assert_eq!(staff_tier(Some(74)), Some(ContractTier::Silver));
|
||||
assert_eq!(staff_tier(Some(75)), Some(ContractTier::Gold));
|
||||
for rating in 0..=99u8 {
|
||||
assert_eq!(
|
||||
staff_tier(Some(rating)),
|
||||
Some(tier_for_rating(rating)),
|
||||
"rating {rating} must not diverge from the shared ladder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two values the live client was actually observed re-rating: the
|
||||
/// squad manager at `value` 88 scored discard level 3 (gold) and the coaches
|
||||
/// at `value` 66 scored level 2 (silver), 4/4 across `coach_probe.py` and
|
||||
/// `discard_probe.py`. These are the ONLY staff tiers with live proof, so
|
||||
/// they are pinned here rather than left to the generic boundary test.
|
||||
#[test]
|
||||
fn the_live_probed_staff_values_score_their_observed_tiers() {
|
||||
assert_eq!(staff_tier(Some(88)), Some(ContractTier::Gold), "manager 88");
|
||||
assert_eq!(staff_tier(Some(66)), Some(ContractTier::Silver), "coach 66");
|
||||
}
|
||||
|
||||
/// The tier ladder must stay locked to the discard ladder it was taken from:
|
||||
/// both index the same three card tiers, and a divergence would mean one of
|
||||
/// the two is no longer the client's.
|
||||
|
||||
@@ -55,6 +55,13 @@ pub struct CoreOwnedItem {
|
||||
/// "untracked" rather than seeding a number, so the game-specific default
|
||||
/// stays on this side of the boundary.
|
||||
pub contract_matches: Option<i64>,
|
||||
/// EA's authored definition rating for a non-player, from Core. `None` = Core
|
||||
/// tracks none; callers MUST fail closed rather than substitute a tier.
|
||||
pub source_rating: Option<u8>,
|
||||
/// Core's own `content_kind` token for this instance, verbatim. Distinct from
|
||||
/// the adapter catalog's kind: Core calls the squad manager `manager` while the
|
||||
/// catalog classifies it `staff` + subtype 4.
|
||||
pub core_content_kind: Option<String>,
|
||||
}
|
||||
|
||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||
@@ -570,6 +577,10 @@ mod tests {
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
// Untracked by default; the contract tests below set it explicitly.
|
||||
contract_matches: None,
|
||||
// Players: their rating IS `overall`, so Core carries no separate
|
||||
// authored definition rating, and these fixtures are player items.
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,8 @@ mod tests {
|
||||
club: "c".into(),
|
||||
attributes: [80, 80, 80, 80, 40, 80],
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user