feat(core): own the EA-authored non-player definition rating
CI / Build, lint & test (push) Successful in 3m23s
CI / Build, lint & test (push) Successful in 3m23s
Adds `CardDefinition.source_rating: Option<u8>` -- the authoritative rating a NON-PLAYER definition carries in EA's own tables (a staff card's `value` from managercards/*coachcards/physiocards, a consumable's rating). WHY NOT `overall`. `overall` feeds quick-sell pricing and squad projection, and it is deliberately 0 for every non-player. Reusing it would silently revalue staff, which is out of scope for the manager-contract milestone. `source_rating` is a separate number read only by tier rules, so both pricing paths stay byte-identical: Core's `quick_sell_coins(card.overall)` and the host's `legacy_discard_value(item.rating)` see exactly what they saw before, and `effective_overall` is unchanged. MUST stay Option: CardDefinition has no `#[serde(default)]`, so a required field would reject every already-shipped content pack, whereas a missing Option deserializes to None. A test pins that backwards compatibility, because it is the property that lets Core and the emitter be deployed independently. No migration: Core does not persist definitions at all -- they are JSON content packs parsed at startup into an immutable in-memory CardDb. `/collection` embeds the serialized definition wholesale, so `card.source_rating` reaches the host with no projection change.
This commit is contained in:
@@ -231,6 +231,20 @@ pub struct CardDefinition {
|
||||
pub physical: u8,
|
||||
pub rarity: Rarity,
|
||||
pub image_path: Option<String>,
|
||||
/// EA's authored definition rating for a NON-PLAYER: a staff card's `value`
|
||||
/// from its shipped family table, or a consumable's own rating.
|
||||
///
|
||||
/// This is deliberately NOT `overall`. `overall` feeds pricing and squad
|
||||
/// projection, so it stays 0 for every non-player; `source_rating` is the
|
||||
/// separate authoritative number the game's own tier rules read (bronze
|
||||
/// `<65`, silver `65..=74`, gold `>=75`). `None` for players, whose rating
|
||||
/// IS `overall`, and `None` whenever Core tracks no authored value — a
|
||||
/// caller MUST fail closed rather than substitute a tier.
|
||||
///
|
||||
/// MUST stay `Option`: `CardDefinition` has no `#[serde(default)]`, so a
|
||||
/// required field would reject every already-shipped content pack, whereas
|
||||
/// a missing `Option` deserializes to `None`.
|
||||
pub source_rating: Option<u8>,
|
||||
}
|
||||
|
||||
/// One owned content INSTANCE (stored in DB).
|
||||
|
||||
@@ -817,6 +817,8 @@ mod tests {
|
||||
physical: 60,
|
||||
rarity: Rarity::Bronze,
|
||||
image_path: None,
|
||||
// A player's rating IS `overall`; no separate authored value.
|
||||
source_rating: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +102,106 @@ async fn preflight_passes_when_owned_card_definition_is_loaded() {
|
||||
.await
|
||||
.expect("preflight passes when the owned card's definition is loaded");
|
||||
}
|
||||
|
||||
/// The LOAD-BEARING backwards-compatibility property: `source_rating` was added
|
||||
/// to `CardDefinition` long after packs shipped, and `CardDefinition` has no
|
||||
/// `#[serde(default)]`. Every already-emitted pack omits the key, so a pack
|
||||
/// without it MUST still parse — and land as `None`, never as a fabricated 0
|
||||
/// that a tier rule would read as bronze.
|
||||
#[test]
|
||||
fn content_pack_without_source_rating_still_parses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pack = dir.path().join("legacy-pack.json");
|
||||
std::fs::write(
|
||||
&pack,
|
||||
r#"[{"id":"legacy_1","name":"Legacy Player","overall":84,"position":"ST",
|
||||
"nation":"Nation","league":"League","club":"Club","pace":80,
|
||||
"shooting":85,"passing":70,"dribbling":82,"defending":40,
|
||||
"physical":75,"rarity":"gold","image_path":null}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut db = CardDb {
|
||||
cards: Default::default(),
|
||||
};
|
||||
assert_eq!(db.load_pack(&pack).expect("legacy pack must load"), 1);
|
||||
let def = db.get("legacy_1").expect("definition merged");
|
||||
assert_eq!(def.overall, 84);
|
||||
assert!(
|
||||
def.source_rating.is_none(),
|
||||
"a missing key is None, not a substituted 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pack that DOES carry `source_rating` must round-trip through `CardDb` and
|
||||
/// surface on `/collection` as `card.source_rating` — that envelope field is the
|
||||
/// only authoritative staff/manager tier source a game host has. `overall` stays
|
||||
/// 0 for the non-player because it feeds pricing and squad projection.
|
||||
#[tokio::test]
|
||||
async fn collection_surfaces_source_rating_for_a_non_player() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pack = dir.path().join("staff-pack.json");
|
||||
std::fs::write(
|
||||
&pack,
|
||||
r#"[{"id":"fifa17_3000083","name":"Manager","overall":0,"position":"",
|
||||
"nation":"","league":"","club":"","pace":0,"shooting":0,"passing":0,
|
||||
"dribbling":0,"defending":0,"physical":0,"rarity":"bronze",
|
||||
"image_path":null,"source_rating":88}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = fresh_pool().await;
|
||||
let cfg = openfut_core::config::Config {
|
||||
listen_addr: "127.0.0.1:0".into(),
|
||||
database_url: "sqlite::memory:".into(),
|
||||
data_dir: "data".into(),
|
||||
max_connections: 1,
|
||||
dev_content_games: Vec::new(),
|
||||
content_packs: vec![pack.clone()],
|
||||
};
|
||||
let app = openfut_core::app::build(pool.clone(), cfg.clone())
|
||||
.await
|
||||
.expect("app build with the staff pack");
|
||||
create_profile(&app).await;
|
||||
let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_owned(&pool, "oc-manager", &club, "fifa17_3000083").await;
|
||||
|
||||
// Rebuild so preflight sees the owned row, then read the envelope.
|
||||
let app = openfut_core::app::build(pool.clone(), cfg)
|
||||
.await
|
||||
.expect("preflight passes: the pack carries the definition");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/collection")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let coll: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let entry = coll["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["owned_card_id"] == serde_json::json!("oc-manager"))
|
||||
.expect("the owned manager must project");
|
||||
assert_eq!(entry["card"]["source_rating"], serde_json::json!(88));
|
||||
assert_eq!(
|
||||
entry["card"]["overall"],
|
||||
serde_json::json!(0),
|
||||
"overall stays 0 for a non-player: it feeds pricing and projection"
|
||||
);
|
||||
assert_eq!(
|
||||
entry["effective_overall"],
|
||||
serde_json::json!(0),
|
||||
"the tier source must NOT leak into the projected overall"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user