refactor(fifa17): one authoritative discard implementation + corpus matrix
The pricing DECISION (which rating to trust, when to decline) lived in the host
while the TABLE lived in the adapter, so FIFA semantics were split across two
crates and no single function could be pointed at as authoritative.
Move the decision into the adapter as `discard::value_for_definition(subtype,
rareflag, catalog_rating, core_rating) -> Option<i64>` and have the host call it.
Its three tests move with it. There is now exactly one table implementation, one
decision point (`ItemIdentityResolver::discard_value`), and one deliberately
retained rollback ladder (`legacy_discard_value`).
Add `examples/discard_matrix.rs`, which audits an entire FIFA17 corpus using the
SHIPPED implementation rather than reimplementing the formula, so the matrix
cannot drift from what the server pays. Over the current 1717-definition corpus:
declined -> legacy : 6 (badge, ball, kit x2, misc, stadium -- no catalog rating)
priced zero : 0
negative : 0
implausible : 0
rating boundaries : OK (1/2/3 at <65 / 65..74 / >=75)
Also re-verified both numeric cores against the LIVE client rather than trusting
the earlier notes:
level 0x180141e8a cmp al,0x4b -> 3 ; cmp al,0x41 ; sbb/add 2 -> 2 else 1
value 0x180141119 imul rating*price ; /100 via 0x51eb851f ; imul 0x64 ; sub ;
cmp remainder,0x32 ; jl/inc == round-half-up
`(rating*price + 50)/100` is identical to that for non-negative inputs.
Adapter 247 lib, host 120 lib, fmt and clippy clean.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
//! Emit the discard-pricing matrix for an entire FIFA 17 corpus, and audit it.
|
||||
//!
|
||||
//! Uses the SHIPPED implementation (`fut::discard::value_for_definition`) rather
|
||||
//! than reimplementing the formula, so the matrix cannot drift from what the
|
||||
//! server actually pays.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p openfut-adapter-fifa17 --example discard_matrix -- \
|
||||
//! <catalog.json> <cards.json> [--csv out.csv]
|
||||
//! ```
|
||||
//!
|
||||
//! Prints an audit summary and, with `--csv`, the full per-definition matrix.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use openfut_adapter_fifa17::fut::discard;
|
||||
use openfut_adapter_fifa17::fut::item::legacy_discard_value;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: discard_matrix <catalog.json> <cards.json> [--csv <path>]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let catalog: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&args[1]).expect("read catalog"))
|
||||
.expect("parse catalog");
|
||||
let cards: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&args[2]).expect("read cards"))
|
||||
.expect("parse cards");
|
||||
let csv_path = args
|
||||
.iter()
|
||||
.position(|a| a == "--csv")
|
||||
.map(|i| args[i + 1].clone());
|
||||
|
||||
// Core's rating per definition id (non-players are 0, which is exactly why
|
||||
// the catalog rating matters).
|
||||
let mut core_rating: HashMap<String, u8> = HashMap::new();
|
||||
if let Some(arr) = cards.as_array() {
|
||||
for c in arr {
|
||||
let id = c["id"].as_str().unwrap_or_default().to_string();
|
||||
let r = c["overall"].as_i64().unwrap_or(0).clamp(0, 255) as u8;
|
||||
core_rating.insert(id, r);
|
||||
}
|
||||
}
|
||||
|
||||
let entries = catalog
|
||||
.get("cards")
|
||||
.and_then(|c| c.as_object())
|
||||
.expect("catalog has cards{}");
|
||||
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
rows.push("definition,kind,subtype,cardtype,rareflag,rating_src,rating,level,legacy,recovered,verdict".into());
|
||||
|
||||
let mut by_kind: BTreeMap<String, (usize, usize, i64, i64)> = BTreeMap::new(); // n, declined, legacy, recovered
|
||||
let (mut negatives, mut zero_priced, mut declined_total, mut overflow) =
|
||||
(0usize, 0usize, 0usize, 0usize);
|
||||
let mut boundary_probe_failures = Vec::new();
|
||||
|
||||
for (id, e) in entries {
|
||||
let kind = e["kind"].as_str().unwrap_or("player").to_string();
|
||||
let subtype = e["subtype"].as_i64().unwrap_or(0);
|
||||
let rareflag = e["rareflag"].as_i64().unwrap_or(0);
|
||||
let cat_rating = e["rating"].as_i64().map(|r| r.clamp(0, 255) as u8);
|
||||
let core = *core_rating.get(id).unwrap_or(&0);
|
||||
let cardtype = discard::cardtype_for_subtype(subtype);
|
||||
|
||||
let recovered = discard::value_for_definition(subtype, rareflag, cat_rating, core);
|
||||
let effective_rating = cat_rating.unwrap_or(core);
|
||||
let level = discard::discard_level(effective_rating);
|
||||
let legacy = legacy_discard_value(core);
|
||||
|
||||
let verdict = match recovered {
|
||||
None => {
|
||||
declined_total += 1;
|
||||
"DECLINES->legacy"
|
||||
}
|
||||
Some(v) if v < 0 => {
|
||||
negatives += 1;
|
||||
"NEGATIVE"
|
||||
}
|
||||
Some(0) => {
|
||||
zero_priced += 1;
|
||||
"ZERO"
|
||||
}
|
||||
Some(v) if v > 1_000_000 => {
|
||||
overflow += 1;
|
||||
"IMPLAUSIBLE"
|
||||
}
|
||||
Some(_) => "ok",
|
||||
};
|
||||
|
||||
let ent = by_kind.entry(kind.clone()).or_insert((0, 0, 0, 0));
|
||||
ent.0 += 1;
|
||||
ent.2 += legacy;
|
||||
match recovered {
|
||||
Some(v) => ent.3 += v,
|
||||
None => {
|
||||
ent.1 += 1;
|
||||
ent.3 += legacy; // declining means the legacy ladder is what pays
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(format!(
|
||||
"{id},{kind},{subtype},{cardtype},{rareflag},{},{effective_rating},{level},{legacy},{},{verdict}",
|
||||
if cat_rating.is_some() { "catalog" } else { "core" },
|
||||
recovered.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
|
||||
));
|
||||
}
|
||||
|
||||
// Rating-boundary audit against the client's own ladder (cmp 0x4b / 0x41).
|
||||
for (rating, want) in [(0u8, 1u8), (64, 1), (65, 2), (74, 2), (75, 3), (99, 3)] {
|
||||
let got = discard::discard_level(rating);
|
||||
if got != want {
|
||||
boundary_probe_failures.push(format!("rating {rating}: level {got}, expected {want}"));
|
||||
}
|
||||
}
|
||||
|
||||
println!("== DISCARD MATRIX AUDIT ==");
|
||||
println!("definitions : {}", entries.len());
|
||||
println!("declined -> legacy : {declined_total}");
|
||||
println!("priced zero : {zero_priced}");
|
||||
println!("negative : {negatives}");
|
||||
println!("implausible (>1e6) : {overflow}");
|
||||
println!(
|
||||
"rating boundaries : {}",
|
||||
if boundary_probe_failures.is_empty() {
|
||||
"OK (1/2/3 at <65 / 65..74 / >=75)".to_string()
|
||||
} else {
|
||||
boundary_probe_failures.join("; ")
|
||||
}
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
"{:<12} {:>6} {:>9} {:>14} {:>14}",
|
||||
"kind", "n", "declined", "legacy", "recovered"
|
||||
);
|
||||
let (mut tl, mut tr) = (0i64, 0i64);
|
||||
for (kind, (n, dec, legacy, rec)) in &by_kind {
|
||||
println!("{kind:<12} {n:>6} {dec:>9} {legacy:>14} {rec:>14}");
|
||||
tl += legacy;
|
||||
tr += rec;
|
||||
}
|
||||
println!(
|
||||
"{:<12} {:>6} {:>9} {:>14} {:>14}",
|
||||
"TOTAL",
|
||||
entries.len(),
|
||||
declined_total,
|
||||
tl,
|
||||
tr
|
||||
);
|
||||
if tl > 0 {
|
||||
println!("ratio recovered/legacy : {:.2}x", tr as f64 / tl as f64);
|
||||
}
|
||||
|
||||
if let Some(path) = csv_path {
|
||||
std::fs::write(&path, rows.join("\n") + "\n").expect("write csv");
|
||||
println!("\nwrote {} rows to {path}", rows.len() - 1);
|
||||
}
|
||||
|
||||
let fatal = negatives + overflow + boundary_probe_failures.len();
|
||||
if fatal > 0 {
|
||||
eprintln!("\nFAIL: {fatal} fatal finding(s)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("\nRESULT: OK");
|
||||
}
|
||||
@@ -247,6 +247,44 @@ pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 {
|
||||
(i64::from(rating) * price + 50) / 100
|
||||
}
|
||||
|
||||
/// THE authoritative FIFA 17 discard price for one owned definition, or `None`
|
||||
/// when an input the client itself uses is not in hand.
|
||||
///
|
||||
/// This is the single entry point every caller must use — the wire shaper and
|
||||
/// the quick-sell payout both reach it through
|
||||
/// [`super::item::ItemIdentityResolver::discard_value`], so the number displayed
|
||||
/// and the number credited cannot diverge.
|
||||
///
|
||||
/// `None` means "not known", never "worthless", and the caller falls back to the
|
||||
/// legacy ladder rather than paying 0:
|
||||
///
|
||||
/// * `cardtype == 0` — the subtype decodes to no table row at all.
|
||||
/// * a NON-PLAYER with no `catalog_rating`. Core models every non-player's
|
||||
/// `overall` as 0, and rating 0 prices at 0 coins, so trusting it would pay
|
||||
/// nothing for a real card.
|
||||
///
|
||||
/// A PLAYER with no catalog rating legitimately uses Core's rating: cardtype 1
|
||||
/// is not re-rated by the client, so Core is authoritative there. For the
|
||||
/// cardtypes that ARE re-rated ([`client_rerates`]) the catalog rating is the
|
||||
/// client's own value and must be present, or we cannot match its display.
|
||||
pub fn value_for_definition(
|
||||
subtype: i64,
|
||||
rareflag: i64,
|
||||
catalog_rating: Option<u8>,
|
||||
core_rating: u8,
|
||||
) -> Option<i64> {
|
||||
let cardtype = cardtype_for_subtype(subtype);
|
||||
if cardtype == 0 {
|
||||
return None;
|
||||
}
|
||||
let rating = match catalog_rating {
|
||||
Some(r) => r,
|
||||
None if cardtype == 1 => core_rating,
|
||||
None => return None,
|
||||
};
|
||||
Some(discard_value(cardtype, rating, rareflag))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -343,6 +381,42 @@ mod tests {
|
||||
assert_eq!(discard_value(10, 67, 0), 37);
|
||||
}
|
||||
|
||||
/// A player is priced from Core's rating and the catalog's `rareflag`, so a
|
||||
/// special and a common of the SAME rating price differently. The legacy
|
||||
/// ladder paid 1500 for every one of these.
|
||||
#[test]
|
||||
fn a_players_price_follows_its_rareflag_not_just_its_rating() {
|
||||
// rare 3 (TOTW) at level 3 -> price 12200; 90 * 12200 / 100.
|
||||
assert_eq!(value_for_definition(0, 3, None, 90), Some(10_980));
|
||||
// Same rating, rare 0 (gold common) -> 4 * rating.
|
||||
assert_eq!(value_for_definition(0, 0, None, 90), Some(360));
|
||||
// Same rating, rare 1 (gold rare) -> 8 * rating.
|
||||
assert_eq!(value_for_definition(0, 1, None, 90), Some(720));
|
||||
}
|
||||
|
||||
/// A consumable's rating is EA's authored one from the catalog, never Core's
|
||||
/// 0 — and cardtype 6 is not re-rated, so the server's values are what the
|
||||
/// client itself prices with.
|
||||
#[test]
|
||||
fn a_consumable_prices_from_its_catalog_rating() {
|
||||
// subtype 201 -> cardtype 6, rating 60 -> level 1, rare 0 -> price 5.
|
||||
assert_eq!(value_for_definition(201, 0, Some(60), 0), Some(3));
|
||||
assert!(!client_rerates(cardtype_for_subtype(201)));
|
||||
}
|
||||
|
||||
/// The two "not known" cases MUST decline rather than pay 0.
|
||||
#[test]
|
||||
fn an_unknown_input_declines_instead_of_paying_zero() {
|
||||
// Staff: cardtype 10, no catalog rating. Core's rating is 0, which would
|
||||
// price the card at 0 coins.
|
||||
assert_eq!(cardtype_for_subtype(6), 10);
|
||||
assert_eq!(value_for_definition(6, 0, None, 0), None);
|
||||
// A subtype with no table row at all.
|
||||
assert_eq!(value_for_definition(600, 0, Some(80), 80), None);
|
||||
// Once the rating IS known, staff price normally.
|
||||
assert_eq!(value_for_definition(6, 0, Some(66), 0), Some(36));
|
||||
}
|
||||
|
||||
/// The subtype decode must agree with the settled club-item subtypes and the
|
||||
/// staff family selector this crate already carries.
|
||||
#[test]
|
||||
|
||||
@@ -50,9 +50,7 @@ use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::{
|
||||
Fifa17CardCatalog, Fifa17CardIdentity, Fifa17WireItemIdPolicy,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
||||
use openfut_adapter_fifa17::fut::club_response::{
|
||||
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity,
|
||||
Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
@@ -1839,7 +1837,12 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
|
||||
if discard_table_enabled() {
|
||||
if let Some(ident) = self.catalog.lookup(&item.card_id) {
|
||||
if let Some(price) = table_discard_value(&ident, item.rating) {
|
||||
if let Some(price) = discard::value_for_definition(
|
||||
ident.subtype,
|
||||
ident.rareflag,
|
||||
ident.rating,
|
||||
item.rating,
|
||||
) {
|
||||
return price;
|
||||
}
|
||||
}
|
||||
@@ -4872,33 +4875,6 @@ fn discard_table_enabled() -> bool {
|
||||
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_DISCARD_TABLE").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// The client's own discard price for a catalogued definition, or `None` when an
|
||||
/// input the client uses is not in hand — the caller then keeps the legacy
|
||||
/// ladder rather than inventing a price or paying 0.
|
||||
///
|
||||
/// `None` cases, all "not known" rather than "worthless":
|
||||
/// * the `cardsubtypeid` decodes to cardtype 0, which has no table row at all;
|
||||
/// * a NON-PLAYER with no catalog rating. Core models every non-player's
|
||||
/// `overall` as 0, and rating 0 prices at 0 coins, so trusting it would pay
|
||||
/// nothing for a real card. Staff are exactly this case today: their rating is
|
||||
/// the `value` column of `managercards`/`*coachcards`/`physiocards`, which the
|
||||
/// import does not yet carry.
|
||||
///
|
||||
/// A PLAYER with no catalog rating legitimately falls back to Core's rating,
|
||||
/// which is authoritative for cardtype 1 (the client does not re-rate players).
|
||||
fn table_discard_value(ident: &Fifa17CardIdentity, core_rating: u8) -> Option<i64> {
|
||||
let cardtype = discard::cardtype_for_subtype(ident.subtype);
|
||||
if cardtype == 0 {
|
||||
return None;
|
||||
}
|
||||
let rating = match ident.rating {
|
||||
Some(r) => r,
|
||||
None if cardtype == 1 => core_rating,
|
||||
None => return None,
|
||||
};
|
||||
Some(discard::discard_value(cardtype, rating, ident.rareflag))
|
||||
}
|
||||
|
||||
/// A JSON response with an explicit status.
|
||||
fn json_status(status: u16, v: &Value) -> WireResponse {
|
||||
let body = serde_json::to_vec(v).unwrap_or_default();
|
||||
@@ -5130,70 +5106,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::async_bridge::AsyncBridge;
|
||||
|
||||
/// A catalog identity carrying only the fields discard pricing reads.
|
||||
fn priced_def(subtype: i64, rareflag: i64, rating: Option<u8>) -> Fifa17CardIdentity {
|
||||
Fifa17CardIdentity {
|
||||
asset_id: 1,
|
||||
version: 0,
|
||||
resource_id: 1,
|
||||
rareflag,
|
||||
kind: ContentKind::Player,
|
||||
subtype,
|
||||
card_asset_id: 1,
|
||||
team_id: 0,
|
||||
nation: 0,
|
||||
league_id: 0,
|
||||
rating,
|
||||
amount: None,
|
||||
contract: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A player is priced from Core's rating and the catalog's `rareflag`, on
|
||||
/// the client's own table — so a special and a common of the SAME rating
|
||||
/// price differently. The legacy ladder pays 1500 for every one of these.
|
||||
#[test]
|
||||
fn a_players_price_follows_its_rareflag_not_just_its_rating() {
|
||||
// rare 3 (TOTW) at level 3 -> price 12200; 90 * 12200 / 100.
|
||||
assert_eq!(
|
||||
table_discard_value(&priced_def(0, 3, None), 90),
|
||||
Some(10_980)
|
||||
);
|
||||
// Same rating, rare 0 (gold common) -> price 400; 4 * rating.
|
||||
assert_eq!(table_discard_value(&priced_def(0, 0, None), 90), Some(360));
|
||||
// Same rating, rare 1 (gold rare) -> 8 * rating.
|
||||
assert_eq!(table_discard_value(&priced_def(0, 1, None), 90), Some(720));
|
||||
}
|
||||
|
||||
/// A consumable's rating is EA's authored one from the catalog, never Core's
|
||||
/// 0 — and cardtype 6 is a class the client does NOT re-rate, so the server's
|
||||
/// values are authoritative.
|
||||
#[test]
|
||||
fn a_consumable_prices_from_its_catalog_rating() {
|
||||
// subtype 201 -> cardtype 6, rating 60 -> level 1, rare 0 -> price 5.
|
||||
assert_eq!(
|
||||
table_discard_value(&priced_def(201, 0, Some(60)), 0),
|
||||
Some(3)
|
||||
);
|
||||
assert!(!discard::client_rerates(discard::cardtype_for_subtype(201)));
|
||||
}
|
||||
|
||||
/// The two "not known" cases MUST decline to price rather than pay 0.
|
||||
#[test]
|
||||
fn an_unknown_input_declines_instead_of_paying_zero() {
|
||||
// Staff: cardtype 10, no catalog rating (it lives in `gkcoachcards.value`,
|
||||
// which the import does not carry). Core's rating is 0, which would
|
||||
// price the card at 0 coins.
|
||||
assert_eq!(discard::cardtype_for_subtype(6), 10);
|
||||
assert_eq!(table_discard_value(&priced_def(6, 0, None), 0), None);
|
||||
// A subtype with no table row at all.
|
||||
assert_eq!(table_discard_value(&priced_def(600, 0, Some(80)), 80), None);
|
||||
// But once the rating IS known, staff price normally.
|
||||
assert_eq!(
|
||||
table_discard_value(&priced_def(6, 0, Some(66)), 0),
|
||||
Some(36)
|
||||
);
|
||||
}
|
||||
/// A configurable in-memory economy double: real balance/entitlements, or a
|
||||
/// forced error to prove fail-closed behavior.
|
||||
struct FakeEconomy {
|
||||
|
||||
Reference in New Issue
Block a user