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:
funman300
2026-08-22 20:08:26 +00:00
parent f0c6dcf238
commit 9026220533
13 changed files with 541 additions and 68 deletions
@@ -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.
+11
View File
@@ -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,
}
}
@@ -118,6 +118,8 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
// contract count, so the round trip proves the PERSISTED number
// reaches the wire rather than a constant.
contract_matches: it["contract"].as_i64(),
source_rating: None,
core_content_kind: None,
},
);
ident.insert(
@@ -343,6 +345,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
// carries no staff contract to mirror: this instance is untracked and
// must fall back to the pack-fresh default.
contract_matches: None,
// Core's authored staff `value`; the squad projection never reads it (the
// client re-rates a manager from its own table), so the round trip is
// unaffected either way.
source_rating: Some(88),
core_content_kind: Some("manager".to_string()),
};
let kicktakers: Vec<KicktakerRef> =
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();