diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index 20be7a2..344c57d 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -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, } } diff --git a/openfut-adapter-fifa17/src/fut/contract_cards.rs b/openfut-adapter-fifa17/src/fut/contract_cards.rs index 4aabba8..e9b0cb6 100644 --- a/openfut-adapter-fifa17/src/fut/contract_cards.rs +++ b/openfut-adapter-fifa17/src/fut/contract_cards.rs @@ -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) -> Option { + 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. diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index b13ceeb..c6f5259 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -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, + /// 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, + /// 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, } /// 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, } } diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index 501a9d7..0bf7473 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -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, } } diff --git a/openfut-adapter-fifa17/tests/squad_projection.rs b/openfut-adapter-fifa17/tests/squad_projection.rs index 6a471cd..9c6c39f 100644 --- a/openfut-adapter-fifa17/tests/squad_projection.rs +++ b/openfut-adapter-fifa17/tests/squad_projection.rs @@ -118,6 +118,8 @@ fn oracle_tables() -> (HashMap, 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 = serde_json::from_value(oracle["kicktakers"].clone()).unwrap(); diff --git a/openfut-core b/openfut-core index e8be289..82c3d2c 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit e8be28966092b25f47cd517cf84f8467e75b30e6 +Subproject commit 82c3d2c85a70f7dade09cc656f257d704099f4d3 diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs index 7fe0635..aa4fae3 100644 --- a/openfut-import-fifa17/src/lib.rs +++ b/openfut-import-fifa17/src/lib.rs @@ -1317,6 +1317,15 @@ pub fn emit_content( .collect(); // Non-player CardDefinitions use NEUTRAL player fields + the honest family/ // role name; Core stores them like any other definition (no FIFA concept). + // + // `overall` STAYS 0 while `source_rating` carries EA's authored value, and + // both are correct at once because they feed different consumers: `overall` + // is what Core prices and projects from (a non-zero one would silently + // re-price every staff quick sell), while `source_rating` is the authored + // number the game's own tier rules read (bronze <65 / silver 65..=74 / + // gold >=75) — the number a manager-contract grant needs. `d.rating` is the + // SAME value written to the host catalog below as `"rating": d.rating`, so + // the content pack and the catalog can never disagree about a card's tier. for d in &report.non_player.supported { defs.push(serde_json::json!({ "id": d.card_id, @@ -1334,6 +1343,7 @@ pub fn emit_content( "physical": 0, "rarity": "bronze", "image_path": serde_json::Value::Null, + "source_rating": d.rating, })); } let content_pack = content_dir.join("fifa17-production-cards.json"); diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs index fc938f9..3f45744 100644 --- a/openfut-import-fifa17/src/tests.rs +++ b/openfut-import-fifa17/src/tests.rs @@ -946,6 +946,17 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() { assert_eq!(cons["nation"], ""); assert_eq!(cons["rarity"], "bronze"); assert!(cons["image_path"].is_null()); + // `source_rating` mirrors the wire verbatim: this consumable really sends + // `"rating":0`, so 0 is the authored value, not a substituted default. + assert_eq!(cons["source_rating"], 0, "{cons}"); + // This fixture's `entities()` carries no staff table, so the coach's value is + // UNKNOWN — it must stay `null`, never a fabricated 0 a tier rule reads as + // bronze. + let coach = arr.iter().find(|c| c["id"] == "fifa17_3000083").unwrap(); + assert!( + coach["source_rating"].is_null(), + "an unknown authored value must stay null: {coach}" + ); // Catalog: kind+subtype on player AND non-player; staff asset falls back to // resourceId; and the emitted catalog LOADS in the adapter with kind_of. @@ -1141,3 +1152,55 @@ fn enrich_staff_fills_staff_and_leaves_everything_else_alone() { "the wire rating is left untouched" ); } + +/// A MANAGER CONTRACT grant needs the target's TIER, and the only authoritative +/// source is EA's authored `value` from the staff family table. Emit must carry +/// it into Core's content pack as `source_rating` — the SAME number the host +/// catalog carries as `rating`, so the two artifacts can never disagree — while +/// `overall` stays 0, because `overall` is what Core prices and projects from. +/// +/// Before this, a non-player reached Core with `overall: 0` and nothing else, so +/// `/collection` reported `effective_overall: 0` and a gold (76) manager graded +/// bronze. +#[test] +fn emit_content_carries_ea_authored_value_as_source_rating() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables"); + let ent = Entities::from_tables_dir(dir).expect("committed tables load"); + + let items = vec![ + staff(100000427, 1000001, 4), // manager — managercards.value = 76 (gold) + staff(100000280, 9000081, 6), // GK coach — gkcoachcards.value = 66 (silver) + ]; + let rep = analyze(&profile(&items, "[]", 100000500), &roster(), &ent, &none()); + assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); + + let out = tempfile::tempdir().unwrap(); + let sum = emit_content(&rep, out.path(), "fp").unwrap(); + let pack: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap(); + let cat: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap(); + + for (card_id, value) in [("fifa17_1000001", 76), ("fifa17_9000081", 66)] { + let def = pack + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == card_id) + .unwrap_or_else(|| panic!("{card_id} must be in the content pack")); + assert_eq!( + def["source_rating"], + serde_json::json!(value), + "EA's authored value must reach Core: {def}" + ); + assert_eq!( + def["overall"], + serde_json::json!(0), + "overall stays 0 for a non-player: it feeds pricing and projection" + ); + assert_eq!( + def["source_rating"], cat["cards"][card_id]["rating"], + "one source, two artifacts — they must never disagree on a tier" + ); + } +} diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index ca10611..c44bdd2 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -110,6 +110,10 @@ fn core_owned(m: &Minted) -> CoreOwnedItem { // Freshly minted by a pack/Store open, so Core tracks no contract for it // yet: the shaper substitutes the pack-fresh default. contract_matches: None, + // A pack mints PLAYER cards, whose rating IS `overall`, so there is no + // separate authored definition rating to carry. + source_rating: None, + core_content_kind: None, } } @@ -1002,6 +1006,8 @@ mod tests { club: "Arsenal".into(), attributes: [rating; 6], contract_matches: None, + source_rating: None, + core_content_kind: None, } } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 51abf62..84a6adf 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -58,10 +58,11 @@ use openfut_adapter_fifa17::fut::club_response::{ use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField}; use openfut_adapter_fifa17::fut::consumables::consumables_response; use openfut_adapter_fifa17::fut::content_taxonomy::{ - consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup, + consumable_families_for_category, consumable_family, position_group, ContentKind, + PositionGroup, MANAGER_SUBTYPE, }; use openfut_adapter_fifa17::fut::contract_cards::{ - contract_grant, tier_for_rating, CONTRACT_MATCH_CAP, MANAGER_CONTRACT_SUBTYPE, + contract_grant, staff_tier, tier_for_rating, CONTRACT_MATCH_CAP, MANAGER_CONTRACT_SUBTYPE, PACK_FRESH_CONTRACT_MATCHES, PLAYER_CONTRACT_SUBTYPE, }; use openfut_adapter_fifa17::fut::discard; @@ -1796,6 +1797,19 @@ fn core_item_from_json(e: &Value) -> Option { attr("physical"), ], contract_matches: e.get("contract_matches").and_then(|v| v.as_i64()), + // Core's authored definition rating for a NON-PLAYER (a staff card's EA + // `value`). It is a separate key from `overall`, which Core deliberately + // keeps at 0 for non-players because that number feeds pricing. + source_rating: card + .get("source_rating") + .and_then(|v| v.as_i64()) + .map(|r| r as u8), + // Core's own kind token, verbatim: Core says `manager` where the FIFA + // catalog says `staff` + subtype 4, and Core compares against its own. + core_content_kind: e + .get("content_kind") + .and_then(|v| v.as_str()) + .map(str::to_string), }) } @@ -1832,6 +1846,10 @@ fn core_item_from_definition(card: &Value) -> Option { // reasoning as the empty `owned_card_id` above. `None` makes the caller // substitute the pack-fresh default instead of reading a fabricated 0. contract_matches: None, + // Likewise: a definition is not an instance, so it carries neither an + // instance-scoped authored rating nor Core's per-instance kind token. + source_rating: None, + core_content_kind: None, }) } @@ -4875,10 +4893,14 @@ impl Server { /// tier, not by the consumable's own tier, and the table is not monotonic, so /// it can only be looked up ([`contract_grant`]) — never interpolated. /// - /// Only the CONTRACT family is served, and only its PLAYER half (subtype - /// 201). Everything else fails closed. A 200-and-do-nothing here is precisely - /// the defect this route was claimed to end: the Python oracle maps - /// `item/resource` method-agnostically to its definition route, so an + /// Only the CONTRACT family is served, in both halves: subtype 201 to a + /// player, 202 to a manager. Each half takes the TARGET's tier from where + /// that kind's rating actually lives — a player's from Core's `overall`, a + /// manager's from Core's `source_rating` (EA's staff `value`, which the client + /// itself re-rates from) — and refuses when the number is absent rather than + /// defaulting a tier. Everything else fails closed. A 200-and-do-nothing here + /// is precisely the defect this route was claimed to end: the Python oracle + /// maps `item/resource` method-agnostically to its definition route, so an /// unclaimed apply returns a definition list, consumes nothing, and the /// client reports success. /// @@ -4938,40 +4960,23 @@ impl Server { ); return error_response(404, "not_owned"); }; - match source_ident.subtype { - PLAYER_CONTRACT_SUBTYPE => {} - MANAGER_CONTRACT_SUBTYPE => { - // The grant is selected by the TARGET's rating tier, and staff - // ratings are not imported: a manager's rating lives in the - // `value` column of `managercards`/`*coachcards`/`physiocards` - // (see `Fifa17IdentityResolver::discard_value`) and Core models a - // non-player's `overall` as 0. So there is no honest tier for a - // manager target — inventing one would silently grant the wrong - // number of matches, unreversibly. Refuse until staff ratings - // are imported. - eprintln!( - "utas-host owner=RUST route=economy consumable-apply status=409 \ - resource={resource_id} wire={target_wire} subtype={} \ - outcome=manager_contract_unsupported reason=staff_ratings_not_imported", - source_ident.subtype - ); - return error_response(409, "manager_contract_unsupported"); - } - other => { - // Only the contract family's effect is proven. Fitness, healing, - // position, play-style and training grants are not, and answering - // 200 while changing nothing is the exact failure this route was - // claimed to end. - eprintln!( - "utas-host owner=RUST route=economy consumable-apply status=409 \ - resource={resource_id} wire={target_wire} subtype={other} \ - outcome=apply_effect_unproven" - ); - return error_response(409, "apply_effect_unproven"); - } + // Only the CONTRACT family's effect is proven — in either of its halves. + // Fitness, healing, position, play-style and training grants are not, and + // answering 200 while changing nothing is the exact failure this route was + // claimed to end. + let subtype = source_ident.subtype; + if subtype != PLAYER_CONTRACT_SUBTYPE && subtype != MANAGER_CONTRACT_SUBTYPE { + eprintln!( + "utas-host owner=RUST route=economy consumable-apply status=409 \ + resource={resource_id} wire={target_wire} subtype={subtype} \ + outcome=apply_effect_unproven" + ); + return error_response(409, "apply_effect_unproven"); } // Reverse the target's wire id through the identity store — never a - // guess, and never the wire id itself. + // guess, and never the wire id itself. Both contract families resolve + // their target the same way; only the legal target KIND and the source of + // its tier differ, so the resolution is shared and the branch is below. let target = self .resolver .owned_id_for_wire(target_wire) @@ -4988,28 +4993,68 @@ impl Server { ); return error_response(404, "not_owned"); }; - // Subtype 201 is the PLAYER contract; the client's own family gating - // sends manager contracts (202) to staff. A player contract on a - // non-player has no proven effect at all. let target_kind = self.resolver.kind_of(target_item); - if target_kind != ContentKind::Player { - eprintln!( - "utas-host owner=RUST route=economy consumable-apply status=409 \ - resource={resource_id} wire={target_wire} target_kind={} \ - outcome=contract_target_not_a_player", - target_kind.as_str() - ); - return error_response(409, "contract_target_not_a_player"); - } - let Some(granted) = contract_grant(resource_id, tier_for_rating(target_item.rating)) else { - // The subtype said "player contract" while the resource id is not one + // The grant COLUMN is the TARGET's tier, and each family reads it from a + // different place because the two target kinds store their rating + // differently. Gate the legal kind first, then take the tier. + let tier = if subtype == PLAYER_CONTRACT_SUBTYPE { + // Subtype 201 is the PLAYER contract; the client's own family gating + // sends manager contracts (202) to staff. A player contract on a + // non-player has no proven effect at all. + if target_kind != ContentKind::Player { + eprintln!( + "utas-host owner=RUST route=economy consumable-apply status=409 \ + resource={resource_id} wire={target_wire} target_kind={} \ + outcome=contract_target_not_a_player", + target_kind.as_str() + ); + return error_response(409, "contract_target_not_a_player"); + } + // A player's rating IS Core's `overall`, so the card ladder reads it. + tier_for_rating(target_item.rating) + } else { + // Subtype 202 is the MANAGER contract, and a MANAGER specifically: + // all five staff families share `ContentKind::Staff`, so the kind + // alone would let a head coach, fitness coach, physio or GK coach + // through. Only `cardsubtypeid` 4 is the squad manager. + let target_subtype = self.resolver.subtype_of(target_item); + if target_subtype != MANAGER_SUBTYPE { + eprintln!( + "utas-host owner=RUST route=economy consumable-apply status=409 \ + resource={resource_id} wire={target_wire} target_kind={} \ + target_subtype={target_subtype} outcome=contract_target_not_a_manager", + target_kind.as_str() + ); + return error_response(409, "contract_target_not_a_manager"); + } + // A manager's authoritative rating is EA's `value` column, which Core + // carries as `source_rating`; its `overall` is deliberately 0 for a + // non-player because that number feeds pricing. Reading `overall` + // here would score every gold manager as bronze. + let Some(tier) = staff_tier(target_item.source_rating) else { + // FAIL CLOSED: an un-imported manager has no honest tier, and + // inventing one would silently grant the wrong number of matches, + // irreversibly. + eprintln!( + "utas-host owner=RUST route=economy consumable-apply status=409 \ + resource={resource_id} wire={target_wire} target_kind={} \ + outcome=manager_tier_unknown reason=core_carries_no_source_rating", + target_kind.as_str() + ); + return error_response(409, "manager_tier_unknown"); + }; + tier + }; + let Some(granted) = contract_grant(resource_id, tier) else { + // The subtype said "contract card" while the resource id is not one // of the 13 known rows: a catalog inconsistency, not a licence to // substitute a neighbouring row's number. eprintln!( "utas-host owner=RUST route=economy consumable-apply status=409 \ - resource={resource_id} wire={target_wire} rating={} \ + resource={resource_id} wire={target_wire} rating={} tier={} \ outcome=apply_effect_unproven reason=resource_not_a_contract_card", - target_item.rating + target_item.rating, + tier.as_str() ); return error_response(409, "apply_effect_unproven"); }; @@ -5023,11 +5068,20 @@ impl Server { "fifa17:apply:{}->{}", source_item.owned_card_id, target_core_id ); + // Core's `require_kind` compares against ITS OWN token, not the FIFA + // catalog's: Core calls the squad manager `manager` where the catalog says + // `staff` + subtype 4, so sending the catalog kind would make Core refuse + // every manager apply. Use the token Core itself published for this + // instance, falling back to the catalog kind only when Core sent none. + let core_kind = target_item + .core_content_kind + .as_deref() + .unwrap_or_else(|| target_kind.as_str()); let req = ConsumableApplyRequest { action_identity: &action_identity, source_owned_card_id: &source_item.owned_card_id, target_owned_card_id: &target_core_id, - target_kind: target_kind.as_str(), + target_kind: core_kind, effect: AddContractMatches { amount: granted, cap: CONTRACT_MATCH_CAP, @@ -5064,9 +5118,10 @@ impl Server { }; eprintln!( "utas-host owner=RUST route=economy consumable-apply resource={resource_id} \ - wire={target_wire} subtype={} granted={} before={} after={} applied={} \ + wire={target_wire} subtype={} tier={} granted={} before={} after={} applied={} \ source_destroyed={}", source_ident.subtype, + tier.as_str(), outcome.granted, outcome.before, outcome.after, @@ -5589,12 +5644,24 @@ mod tests { use super::*; use crate::async_bridge::AsyncBridge; + /// One recorded consumable apply. It captures the three things the caller — + /// not Core — decides: which instance is mutated, the KIND token Core will + /// dispatch on, and the resolved grant. A wrong tier shows up here as a wrong + /// `amount`, which is exactly the silent mis-credit the 202 arm had to avoid. + #[derive(Debug, Clone, PartialEq, Eq)] + struct RecordedApply { + target_owned_card_id: String, + target_kind: String, + amount: i64, + } + /// A configurable in-memory economy double: real balance/entitlements, or a /// forced error to prove fail-closed behavior. struct FakeEconomy { balance: i64, entitlements: Vec, fail: bool, + applies: PlMutex>, } impl FakeEconomy { fn ok(balance: i64, ents: usize) -> Self { @@ -5607,12 +5674,14 @@ mod tests { }) .collect(), fail: false, + applies: PlMutex::new(Vec::new()), } } fn failing() -> Self { FakeEconomy { balance: 0, entitlements: vec![], + applies: PlMutex::new(Vec::new()), fail: true, } } @@ -5628,6 +5697,7 @@ mod tests { }) .collect(), fail: false, + applies: PlMutex::new(Vec::new()), } } } @@ -5701,12 +5771,30 @@ mod tests { coins_balance: self.balance, }) } + /// Stands in for Core's atomic transaction: it records what the caller + /// asked for and echoes the arithmetic Core would perform, so a test can + /// assert BOTH the refusals (nothing recorded) and the accepted grants. fn apply_consumable( &self, - _req: &ConsumableApplyRequest<'_>, + req: &ConsumableApplyRequest<'_>, ) -> Result { - // Consumable apply is not exercised through this double. - Err(CoreError::Status(501)) + if self.fail { + return Err(CoreError::Status(503)); + } + self.applies.lock().push(RecordedApply { + target_owned_card_id: req.target_owned_card_id.to_string(), + target_kind: req.target_kind.to_string(), + amount: req.effect.amount, + }); + let before = req.effect.default_when_unset; + Ok(ConsumableApplyOutcome { + applied: true, + source_destroyed: true, + source_quantity_after: None, + granted: req.effect.amount, + before, + after: (before + req.effect.amount).min(req.effect.cap), + }) } fn purchase_item( &self, @@ -6100,6 +6188,8 @@ mod tests { club: "Chelsea".into(), attributes: [90, 90, 80, 91, 33, 80], contract_matches: None, + source_rating: None, + core_content_kind: None, } } @@ -6794,6 +6884,211 @@ mod tests { assert!(parse_apply_targets(br#"{"apply":[{"noid":7}]}"#).is_empty()); } + // ── Consumable apply: the contract family, both halves ─────────────────── + + /// The apply-path catalog, carrying the same kind+subtype fields the + /// production catalog carries so `resolve_consumable`, `kind_of` and + /// `subtype_of` all answer exactly as they do live. A contract card's + /// `asset_id` IS its `resourceId` (version 0), which is how the shipped + /// `fcc_contractcards` rows are keyed. + /// + /// Both contract cards are authored `rating: 66` — SILVER as a card. That is + /// deliberate: it makes the card's own tier differ from a gold target's, so a + /// regression that reads the CARD's tier instead of the TARGET's is caught by + /// the grant number rather than passing silently. + fn apply_catalog() -> Fifa17CardCatalog { + Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "card_player":{"asset_id":20801}, + "card_manager":{"asset_id":1000509,"kind":"staff","subtype":4}, + "card_coach":{"asset_id":1000601,"kind":"staff","subtype":5}, + "contract_player":{"asset_id":5001002,"kind":"consumable","subtype":201,"rating":66}, + "contract_manager":{"asset_id":5001008,"kind":"consumable","subtype":202,"rating":66} + }}"#, + ) + .unwrap() + } + + /// A NON-PLAYER owned instance as Core actually reports one: `overall` 0 + /// (that number feeds pricing, so Core keeps it at 0), the authoritative EA + /// `value` in `source_rating`, and Core's OWN kind token — `manager` for the + /// squad manager, where the FIFA catalog says `staff` + subtype 4. + fn staff_owned( + owned_id: &str, + card: &str, + source_rating: Option, + core_kind: &str, + ) -> CoreOwnedItem { + CoreOwnedItem { + rating: 0, + source_rating, + core_content_kind: Some(core_kind.to_string()), + ..owned(owned_id, card) + } + } + + fn apply_test_server(items: &[CoreOwnedItem]) -> (Server, Arc) { + let core = Arc::new(FakeSbcCore { + owned: Mutex::new(items.to_vec()), + ..FakeSbcCore::default() + }); + let store = + openfut_identity::JsonIdentityStore::open(temp_store_path("apply-identity")).unwrap(); + let resolver = Arc::new(Fifa17IdentityResolver::new( + apply_catalog(), + Arc::new(store), + )); + let server = Server::new( + core, + Arc::new(Fifa17Entities::default()), + resolver.clone(), + Arc::new(PassClient::new("http://127.0.0.1:9")), + 1, + ); + (server, resolver) + } + + /// The wire id the client holds for this instance, minted through the + /// PRODUCTION resolver so the handler reverses exactly what production would. + fn wire_of(resolver: &Fifa17IdentityResolver, it: &CoreOwnedItem) -> i64 { + let minted = match ItemIdentityResolver::kind_of(resolver, it) { + ContentKind::Player => resolver.resolve(it).map(|i| i.item_id), + _ => resolver.resolve_staff(it).map(|i| i.item_id), + }; + i64::from(minted.expect("a catalogued fixture must resolve to a wire id")) + } + + fn apply_body(wire: i64) -> Vec { + format!("{{\"apply\":[{{\"id\":{wire}}}]}}").into_bytes() + } + + /// THE discriminating case. `5001008` is `[bronze 8, silver 10, gold 8]`, the + /// manager target's `value` is 88 (GOLD) and the CARD is silver, so: + /// * 8 = the target's tier, which is the invariant. + /// * 10 = the card's own tier — the classic backwards read. + /// + /// It also proves the `target_kind` Core receives is CORE's token (`manager`), + /// not the catalog's (`staff`), which is what Core's `require_kind` compares. + #[test] + fn manager_contract_grants_the_target_managers_tier() { + let source = owned("oc-contract-mgr", "contract_manager"); + let target = staff_owned("oc-manager", "card_manager", Some(88), "manager"); + let (server, resolver) = apply_test_server(&[source, target.clone()]); + let wire = wire_of(&resolver, &target); + let econ = FakeEconomy::ok(0, 0); + + let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ); + + assert_eq!(resp.status, 200, "a gold manager is a servable 202 target"); + assert_eq!(resp.body, br#"{"itemData":[]}"#); + let applied = econ.applies.lock().clone(); + assert_eq!( + applied, + vec![RecordedApply { + target_owned_card_id: "oc-manager".to_string(), + target_kind: "manager".to_string(), + amount: 8, + }], + "gold TARGET column (8), not the silver CARD column (10)" + ); + } + + /// The regression that made 202 unservable: Core keeps a non-player's + /// `overall` at 0, and 0 scores BRONZE. `5001008`'s bronze column is 8 and its + /// silver column is 10, so a silver (`value` 66) manager separates the two + /// reads — 10 proves `source_rating` was read, 8 would prove `overall` was. + #[test] + fn a_manager_targets_tier_comes_from_source_rating_not_cores_zero_overall() { + let source = owned("oc-contract-mgr", "contract_manager"); + let target = staff_owned("oc-manager", "card_manager", Some(66), "manager"); + let (server, resolver) = apply_test_server(&[source, target.clone()]); + let wire = wire_of(&resolver, &target); + let econ = FakeEconomy::ok(0, 0); + + let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ); + + assert_eq!(resp.status, 200); + assert_eq!(target.rating, 0, "Core's own `overall` for a non-player"); + assert_eq!( + econ.applies.lock()[0].amount, + 10, + "silver TARGET column (10); reading `overall` 0 would have paid bronze (8)" + ); + } + + /// A COACH is not a manager. All five staff families share + /// `ContentKind::Staff`, so only `cardsubtypeid` 4 may be granted manager + /// contracts — and the refusal must mutate nothing. + #[test] + fn manager_contract_refuses_a_coach_target() { + let source = owned("oc-contract-mgr", "contract_manager"); + let target = staff_owned("oc-coach", "card_coach", Some(66), "staff"); + let (server, resolver) = apply_test_server(&[source, target.clone()]); + let wire = wire_of(&resolver, &target); + let econ = FakeEconomy::ok(0, 0); + + let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ); + + assert_eq!(resp.status, 409); + assert_eq!(resp.body, br#"{"error":"contract_target_not_a_manager"}"#); + assert!(econ.applies.lock().is_empty(), "nothing was mutated"); + } + + /// A PLAYER is not a manager either: 202 on a footballer has no proven effect, + /// and a player's catalog subtype is 0, so it fails the same gate. + #[test] + fn manager_contract_refuses_a_player_target() { + let source = owned("oc-contract-mgr", "contract_manager"); + let target = owned("oc-player", "card_player"); + let (server, resolver) = apply_test_server(&[source, target.clone()]); + let wire = wire_of(&resolver, &target); + let econ = FakeEconomy::ok(0, 0); + + let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ); + + assert_eq!(resp.status, 409); + assert_eq!(resp.body, br#"{"error":"contract_target_not_a_manager"}"#); + assert!(econ.applies.lock().is_empty(), "nothing was mutated"); + } + + /// The mirror gate, unchanged: a PLAYER contract on a manager is refused by + /// kind, so implementing 202 did not loosen 201. + #[test] + fn player_contract_refuses_a_manager_target() { + let source = owned("oc-contract-plr", "contract_player"); + let target = staff_owned("oc-manager", "card_manager", Some(88), "manager"); + let (server, resolver) = apply_test_server(&[source, target.clone()]); + let wire = wire_of(&resolver, &target); + let econ = FakeEconomy::ok(0, 0); + + let resp = server.handle_consumable_apply(5_001_002, &apply_body(wire), &econ); + + assert_eq!(resp.status, 409); + assert_eq!(resp.body, br#"{"error":"contract_target_not_a_player"}"#); + assert!(econ.applies.lock().is_empty(), "nothing was mutated"); + } + + /// FAIL CLOSED. A manager Core carries no `source_rating` for has no honest + /// tier, and every possible default is a silent mis-credit: bronze under-pays + /// a gold manager, gold over-pays a bronze one. Refuse, mutate nothing. + #[test] + fn an_unrated_manager_target_is_refused_rather_than_defaulted() { + let source = owned("oc-contract-mgr", "contract_manager"); + let target = staff_owned("oc-manager", "card_manager", None, "manager"); + let (server, resolver) = apply_test_server(&[source, target.clone()]); + let wire = wire_of(&resolver, &target); + let econ = FakeEconomy::ok(0, 0); + + let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ); + + assert_eq!(resp.status, 409); + assert_eq!(resp.body, br#"{"error":"manager_tier_unknown"}"#); + assert!( + econ.applies.lock().is_empty(), + "an unknown tier must never reach Core" + ); + } + #[test] fn marketdata_container_types_are_load_bearing() { // /pricelimits MUST be a bare ARRAY (object-where-array froze a live client). diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 1fb457f..b0b7d1a 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -1040,6 +1040,8 @@ mod tests { club: String::new(), attributes: [80, 80, 80, 80, 80, 80], contract_matches: None, + source_rating: None, + core_content_kind: None, } } diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 8a889fd..dc65d56 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -234,6 +234,8 @@ fn item( club: club.into(), attributes: [90, 88, 70, 85, 40, 78], contract_matches: None, + source_rating: None, + core_content_kind: None, } } diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index e258e69..928d918 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -589,7 +589,14 @@ def materialise(lay: Layout) -> None: definitions.append({ "id": mgr["card_id"], "name": mgr["label"], + # `overall` STAYS 0. It feeds pricing and squad projection, and a + # staff card is not a player; the authoritative number lives in + # `source_rating` below, which is what the contract-tier rules read. "overall": 0, + # managercards.value, the same number the catalog carries as + # `rating`. Core owns it so the manager-contract tier is resolved + # from Core-owned state rather than from adapter projection. + "source_rating": mgr["rating"], "position": "", "nation": "", "league": "",