From 82c3d2c85a70f7dade09cc656f257d704099f4d3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 20:08:09 +0000 Subject: [PATCH] feat(core): own the EA-authored non-player definition rating Adds `CardDefinition.source_rating: Option` -- 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. --- src/models/card.rs | 14 +++++ src/services/sbc.rs | 2 + tests/content_preflight_test.rs | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/src/models/card.rs b/src/models/card.rs index 15111a9..d4ebca5 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -231,6 +231,20 @@ pub struct CardDefinition { pub physical: u8, pub rarity: Rarity, pub image_path: Option, + /// 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, } /// One owned content INSTANCE (stored in DB). diff --git a/src/services/sbc.rs b/src/services/sbc.rs index b7c7e66..8a181d9 100644 --- a/src/services/sbc.rs +++ b/src/services/sbc.rs @@ -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, } } diff --git a/tests/content_preflight_test.rs b/tests/content_preflight_test.rs index 173f24e..0eee23f 100644 --- a/tests/content_preflight_test.rs +++ b/tests/content_preflight_test.rs @@ -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" + ); +}