Files
OpenFUT/openfut-adapter-fifa17/examples/discard_matrix.rs
T
funman300 f371349dd5 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.
2026-08-21 23:49:01 +00:00

168 lines
5.9 KiB
Rust

//! 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");
}