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
|
// Untracked by default, so these fixtures exercise the pack-fresh
|
||||||
// fallback; a test that cares sets it explicitly.
|
// fallback; a test that cares sets it explicitly.
|
||||||
contract_matches: None,
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,12 @@
|
|||||||
//! ## Family gating
|
//! ## Family gating
|
||||||
//!
|
//!
|
||||||
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
|
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
|
||||||
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS/STAFF only. 202 cannot be
|
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS only. A 202 target's tier comes
|
||||||
//! honoured today for a reason that is a data gap, not a policy: staff ratings
|
//! from [`staff_tier`], whose input is Core's authored definition rating for the
|
||||||
//! are not imported, so a manager target's tier cannot be determined honestly and
|
//! staff card (EA's `value` column). When Core carries none, [`staff_tier`]
|
||||||
//! there is no defensible column to read. The caller must REFUSE a 202 apply
|
//! answers `None` and the caller must REFUSE the apply — inventing gold (or
|
||||||
//! rather than default the tier — inventing gold (or bronze, or the card's own
|
//! bronze, or the card's own tier) would silently pay out the wrong number with
|
||||||
//! tier) would silently pay out the wrong number with no error anywhere.
|
//! no error anywhere.
|
||||||
//!
|
//!
|
||||||
//! A contract also cannot be applied to a LOAN item. This crate models no loan
|
//! 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
|
//! state, so that gate — like the 201/202 family check — is the caller's: this
|
||||||
@@ -87,6 +87,16 @@ impl ContractTier {
|
|||||||
ContractTier::Gold => 2,
|
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`.
|
/// 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
|
/// Matches granted by contract consumable `resource_id` against a target of
|
||||||
/// `tier`.
|
/// `tier`.
|
||||||
///
|
///
|
||||||
@@ -134,8 +164,8 @@ pub const PACK_FRESH_CONTRACT_MATCHES: i64 = 7;
|
|||||||
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
|
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
|
||||||
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
|
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
|
||||||
|
|
||||||
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers/staff only,
|
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers only; the
|
||||||
/// and is currently unservable — see the module header's family-gating note.
|
/// target's tier comes from [`staff_tier`] over Core's authored staff rating.
|
||||||
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
|
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -155,6 +185,42 @@ mod tests {
|
|||||||
assert_eq!(tier_for_rating(99), ContractTier::Gold);
|
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:
|
/// 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
|
/// both index the same three card tiers, and a divergence would mean one of
|
||||||
/// the two is no longer the client's.
|
/// 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
|
/// "untracked" rather than seeding a number, so the game-specific default
|
||||||
/// stays on this side of the boundary.
|
/// stays on this side of the boundary.
|
||||||
pub contract_matches: Option<i64>,
|
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
|
/// 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],
|
attributes: [90, 88, 70, 85, 40, 78],
|
||||||
// Untracked by default; the contract tests below set it explicitly.
|
// Untracked by default; the contract tests below set it explicitly.
|
||||||
contract_matches: None,
|
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(),
|
club: "c".into(),
|
||||||
attributes: [80, 80, 80, 80, 40, 80],
|
attributes: [80, 80, 80, 80, 40, 80],
|
||||||
contract_matches: None,
|
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
|
// contract count, so the round trip proves the PERSISTED number
|
||||||
// reaches the wire rather than a constant.
|
// reaches the wire rather than a constant.
|
||||||
contract_matches: it["contract"].as_i64(),
|
contract_matches: it["contract"].as_i64(),
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ident.insert(
|
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
|
// carries no staff contract to mirror: this instance is untracked and
|
||||||
// must fall back to the pack-fresh default.
|
// must fall back to the pack-fresh default.
|
||||||
contract_matches: None,
|
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> =
|
let kicktakers: Vec<KicktakerRef> =
|
||||||
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
||||||
|
|||||||
+1
-1
Submodule openfut-core updated: e8be289660...82c3d2c85a
@@ -1317,6 +1317,15 @@ pub fn emit_content(
|
|||||||
.collect();
|
.collect();
|
||||||
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
|
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
|
||||||
// role name; Core stores them like any other definition (no FIFA concept).
|
// 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 {
|
for d in &report.non_player.supported {
|
||||||
defs.push(serde_json::json!({
|
defs.push(serde_json::json!({
|
||||||
"id": d.card_id,
|
"id": d.card_id,
|
||||||
@@ -1334,6 +1343,7 @@ pub fn emit_content(
|
|||||||
"physical": 0,
|
"physical": 0,
|
||||||
"rarity": "bronze",
|
"rarity": "bronze",
|
||||||
"image_path": serde_json::Value::Null,
|
"image_path": serde_json::Value::Null,
|
||||||
|
"source_rating": d.rating,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let content_pack = content_dir.join("fifa17-production-cards.json");
|
let content_pack = content_dir.join("fifa17-production-cards.json");
|
||||||
|
|||||||
@@ -946,6 +946,17 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
|||||||
assert_eq!(cons["nation"], "");
|
assert_eq!(cons["nation"], "");
|
||||||
assert_eq!(cons["rarity"], "bronze");
|
assert_eq!(cons["rarity"], "bronze");
|
||||||
assert!(cons["image_path"].is_null());
|
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
|
// 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.
|
// 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"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ fn core_owned(m: &Minted) -> CoreOwnedItem {
|
|||||||
// Freshly minted by a pack/Store open, so Core tracks no contract for it
|
// Freshly minted by a pack/Store open, so Core tracks no contract for it
|
||||||
// yet: the shaper substitutes the pack-fresh default.
|
// yet: the shaper substitutes the pack-fresh default.
|
||||||
contract_matches: None,
|
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(),
|
club: "Arsenal".into(),
|
||||||
attributes: [rating; 6],
|
attributes: [rating; 6],
|
||||||
contract_matches: None,
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+354
-59
@@ -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::club_stats::{club_stats_body, ClubStatInput, ContextField};
|
||||||
use openfut_adapter_fifa17::fut::consumables::consumables_response;
|
use openfut_adapter_fifa17::fut::consumables::consumables_response;
|
||||||
use openfut_adapter_fifa17::fut::content_taxonomy::{
|
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::{
|
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,
|
PACK_FRESH_CONTRACT_MATCHES, PLAYER_CONTRACT_SUBTYPE,
|
||||||
};
|
};
|
||||||
use openfut_adapter_fifa17::fut::discard;
|
use openfut_adapter_fifa17::fut::discard;
|
||||||
@@ -1796,6 +1797,19 @@ fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
|
|||||||
attr("physical"),
|
attr("physical"),
|
||||||
],
|
],
|
||||||
contract_matches: e.get("contract_matches").and_then(|v| v.as_i64()),
|
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<CoreOwnedItem> {
|
|||||||
// reasoning as the empty `owned_card_id` above. `None` makes the caller
|
// reasoning as the empty `owned_card_id` above. `None` makes the caller
|
||||||
// substitute the pack-fresh default instead of reading a fabricated 0.
|
// substitute the pack-fresh default instead of reading a fabricated 0.
|
||||||
contract_matches: None,
|
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
|
/// 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.
|
/// it can only be looked up ([`contract_grant`]) — never interpolated.
|
||||||
///
|
///
|
||||||
/// Only the CONTRACT family is served, and only its PLAYER half (subtype
|
/// Only the CONTRACT family is served, in both halves: subtype 201 to a
|
||||||
/// 201). Everything else fails closed. A 200-and-do-nothing here is precisely
|
/// player, 202 to a manager. Each half takes the TARGET's tier from where
|
||||||
/// the defect this route was claimed to end: the Python oracle maps
|
/// that kind's rating actually lives — a player's from Core's `overall`, a
|
||||||
/// `item/resource` method-agnostically to its definition route, so an
|
/// 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
|
/// unclaimed apply returns a definition list, consumes nothing, and the
|
||||||
/// client reports success.
|
/// client reports success.
|
||||||
///
|
///
|
||||||
@@ -4938,40 +4960,23 @@ impl Server {
|
|||||||
);
|
);
|
||||||
return error_response(404, "not_owned");
|
return error_response(404, "not_owned");
|
||||||
};
|
};
|
||||||
match source_ident.subtype {
|
// Only the CONTRACT family's effect is proven — in either of its halves.
|
||||||
PLAYER_CONTRACT_SUBTYPE => {}
|
// Fitness, healing, position, play-style and training grants are not, and
|
||||||
MANAGER_CONTRACT_SUBTYPE => {
|
// answering 200 while changing nothing is the exact failure this route was
|
||||||
// The grant is selected by the TARGET's rating tier, and staff
|
// claimed to end.
|
||||||
// ratings are not imported: a manager's rating lives in the
|
let subtype = source_ident.subtype;
|
||||||
// `value` column of `managercards`/`*coachcards`/`physiocards`
|
if subtype != PLAYER_CONTRACT_SUBTYPE && subtype != MANAGER_CONTRACT_SUBTYPE {
|
||||||
// (see `Fifa17IdentityResolver::discard_value`) and Core models a
|
eprintln!(
|
||||||
// non-player's `overall` as 0. So there is no honest tier for a
|
"utas-host owner=RUST route=economy consumable-apply status=409 \
|
||||||
// manager target — inventing one would silently grant the wrong
|
resource={resource_id} wire={target_wire} subtype={subtype} \
|
||||||
// number of matches, unreversibly. Refuse until staff ratings
|
outcome=apply_effect_unproven"
|
||||||
// are imported.
|
);
|
||||||
eprintln!(
|
return error_response(409, "apply_effect_unproven");
|
||||||
"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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Reverse the target's wire id through the identity store — never a
|
// 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
|
let target = self
|
||||||
.resolver
|
.resolver
|
||||||
.owned_id_for_wire(target_wire)
|
.owned_id_for_wire(target_wire)
|
||||||
@@ -4988,28 +4993,68 @@ impl Server {
|
|||||||
);
|
);
|
||||||
return error_response(404, "not_owned");
|
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);
|
let target_kind = self.resolver.kind_of(target_item);
|
||||||
if target_kind != ContentKind::Player {
|
// The grant COLUMN is the TARGET's tier, and each family reads it from a
|
||||||
eprintln!(
|
// different place because the two target kinds store their rating
|
||||||
"utas-host owner=RUST route=economy consumable-apply status=409 \
|
// differently. Gate the legal kind first, then take the tier.
|
||||||
resource={resource_id} wire={target_wire} target_kind={} \
|
let tier = if subtype == PLAYER_CONTRACT_SUBTYPE {
|
||||||
outcome=contract_target_not_a_player",
|
// Subtype 201 is the PLAYER contract; the client's own family gating
|
||||||
target_kind.as_str()
|
// sends manager contracts (202) to staff. A player contract on a
|
||||||
);
|
// non-player has no proven effect at all.
|
||||||
return error_response(409, "contract_target_not_a_player");
|
if target_kind != ContentKind::Player {
|
||||||
}
|
eprintln!(
|
||||||
let Some(granted) = contract_grant(resource_id, tier_for_rating(target_item.rating)) else {
|
"utas-host owner=RUST route=economy consumable-apply status=409 \
|
||||||
// The subtype said "player contract" while the resource id is not one
|
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
|
// of the 13 known rows: a catalog inconsistency, not a licence to
|
||||||
// substitute a neighbouring row's number.
|
// substitute a neighbouring row's number.
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host owner=RUST route=economy consumable-apply status=409 \
|
"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",
|
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");
|
return error_response(409, "apply_effect_unproven");
|
||||||
};
|
};
|
||||||
@@ -5023,11 +5068,20 @@ impl Server {
|
|||||||
"fifa17:apply:{}->{}",
|
"fifa17:apply:{}->{}",
|
||||||
source_item.owned_card_id, target_core_id
|
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 {
|
let req = ConsumableApplyRequest {
|
||||||
action_identity: &action_identity,
|
action_identity: &action_identity,
|
||||||
source_owned_card_id: &source_item.owned_card_id,
|
source_owned_card_id: &source_item.owned_card_id,
|
||||||
target_owned_card_id: &target_core_id,
|
target_owned_card_id: &target_core_id,
|
||||||
target_kind: target_kind.as_str(),
|
target_kind: core_kind,
|
||||||
effect: AddContractMatches {
|
effect: AddContractMatches {
|
||||||
amount: granted,
|
amount: granted,
|
||||||
cap: CONTRACT_MATCH_CAP,
|
cap: CONTRACT_MATCH_CAP,
|
||||||
@@ -5064,9 +5118,10 @@ impl Server {
|
|||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host owner=RUST route=economy consumable-apply resource={resource_id} \
|
"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_destroyed={}",
|
||||||
source_ident.subtype,
|
source_ident.subtype,
|
||||||
|
tier.as_str(),
|
||||||
outcome.granted,
|
outcome.granted,
|
||||||
outcome.before,
|
outcome.before,
|
||||||
outcome.after,
|
outcome.after,
|
||||||
@@ -5589,12 +5644,24 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::async_bridge::AsyncBridge;
|
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
|
/// A configurable in-memory economy double: real balance/entitlements, or a
|
||||||
/// forced error to prove fail-closed behavior.
|
/// forced error to prove fail-closed behavior.
|
||||||
struct FakeEconomy {
|
struct FakeEconomy {
|
||||||
balance: i64,
|
balance: i64,
|
||||||
entitlements: Vec<EconomyEntitlement>,
|
entitlements: Vec<EconomyEntitlement>,
|
||||||
fail: bool,
|
fail: bool,
|
||||||
|
applies: PlMutex<Vec<RecordedApply>>,
|
||||||
}
|
}
|
||||||
impl FakeEconomy {
|
impl FakeEconomy {
|
||||||
fn ok(balance: i64, ents: usize) -> Self {
|
fn ok(balance: i64, ents: usize) -> Self {
|
||||||
@@ -5607,12 +5674,14 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
fail: false,
|
fail: false,
|
||||||
|
applies: PlMutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn failing() -> Self {
|
fn failing() -> Self {
|
||||||
FakeEconomy {
|
FakeEconomy {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
entitlements: vec![],
|
entitlements: vec![],
|
||||||
|
applies: PlMutex::new(Vec::new()),
|
||||||
fail: true,
|
fail: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5628,6 +5697,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
fail: false,
|
fail: false,
|
||||||
|
applies: PlMutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5701,12 +5771,30 @@ mod tests {
|
|||||||
coins_balance: self.balance,
|
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(
|
fn apply_consumable(
|
||||||
&self,
|
&self,
|
||||||
_req: &ConsumableApplyRequest<'_>,
|
req: &ConsumableApplyRequest<'_>,
|
||||||
) -> Result<ConsumableApplyOutcome, CoreError> {
|
) -> Result<ConsumableApplyOutcome, CoreError> {
|
||||||
// Consumable apply is not exercised through this double.
|
if self.fail {
|
||||||
Err(CoreError::Status(501))
|
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(
|
fn purchase_item(
|
||||||
&self,
|
&self,
|
||||||
@@ -6100,6 +6188,8 @@ mod tests {
|
|||||||
club: "Chelsea".into(),
|
club: "Chelsea".into(),
|
||||||
attributes: [90, 90, 80, 91, 33, 80],
|
attributes: [90, 90, 80, 91, 33, 80],
|
||||||
contract_matches: None,
|
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());
|
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<u8>,
|
||||||
|
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<Fifa17IdentityResolver>) {
|
||||||
|
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<u8> {
|
||||||
|
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]
|
#[test]
|
||||||
fn marketdata_container_types_are_load_bearing() {
|
fn marketdata_container_types_are_load_bearing() {
|
||||||
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
||||||
|
|||||||
@@ -1040,6 +1040,8 @@ mod tests {
|
|||||||
club: String::new(),
|
club: String::new(),
|
||||||
attributes: [80, 80, 80, 80, 80, 80],
|
attributes: [80, 80, 80, 80, 80, 80],
|
||||||
contract_matches: None,
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ fn item(
|
|||||||
club: club.into(),
|
club: club.into(),
|
||||||
attributes: [90, 88, 70, 85, 40, 78],
|
attributes: [90, 88, 70, 85, 40, 78],
|
||||||
contract_matches: None,
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -589,7 +589,14 @@ def materialise(lay: Layout) -> None:
|
|||||||
definitions.append({
|
definitions.append({
|
||||||
"id": mgr["card_id"],
|
"id": mgr["card_id"],
|
||||||
"name": mgr["label"],
|
"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,
|
"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": "",
|
"position": "",
|
||||||
"nation": "",
|
"nation": "",
|
||||||
"league": "",
|
"league": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user