Compare commits
4 Commits
fb38ee6087
...
0a7c4e129c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a7c4e129c | |||
| a96d06dbc0 | |||
| 06d94bb37d | |||
| f371349dd5 |
@@ -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,53 @@ pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 {
|
|||||||
(i64::from(rating) * price + 50) / 100
|
(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 inventing a price:
|
||||||
|
///
|
||||||
|
/// * `cardtype == 0` — the subtype decodes to no table row at all.
|
||||||
|
/// * a CLIENT-RE-RATED cardtype ([`client_rerates`]: the five staff families)
|
||||||
|
/// with no `catalog_rating`. Those price from the client's OWN database, so
|
||||||
|
/// without that value we cannot match what it displays.
|
||||||
|
///
|
||||||
|
/// Everything else uses `catalog_rating`, falling back to Core's rating. For the
|
||||||
|
/// cardtypes the client does NOT re-rate (1, 6, 7, 8, 9) the server's rating is
|
||||||
|
/// authoritative — whatever we send is what the client prices with — so Core's
|
||||||
|
/// value is the right answer even when it is 0.
|
||||||
|
///
|
||||||
|
/// That zero is not a gap. A club item's wire record
|
||||||
|
/// ([`super::item::shape_club_item`]) carries neither `rating` nor
|
||||||
|
/// `discardValue`, so the client computes for itself from `+0xb4 == 0`: level 1,
|
||||||
|
/// and `0 * price / 100 == 0`. **The client displays 0, so 0 is the correct
|
||||||
|
/// payout.** Paying anything else would invent value the player was never shown.
|
||||||
|
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,
|
||||||
|
// Not re-rated by the client => whatever the server sends is what it
|
||||||
|
// prices with, so Core's rating is authoritative even at 0.
|
||||||
|
None if !client_rerates(cardtype) => core_rating,
|
||||||
|
// Re-rated => the client substitutes its own value and we cannot match it.
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
Some(discard_value(cardtype, rating, rareflag))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -343,6 +390,73 @@ mod tests {
|
|||||||
assert_eq!(discard_value(10, 67, 0), 37);
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CLUB ITEMS ARE ZERO-VALUE under the current projection, and that is a
|
||||||
|
/// derived fact rather than a gap. `shape_club_item` sends no `rating` and no
|
||||||
|
/// `discardValue`, so the client computes for itself from record `+0xb4 == 0`:
|
||||||
|
/// level 1, `0 * price / 100 == 0`. It DISPLAYS 0, so 0 is the only payout
|
||||||
|
/// that matches. The old ladder invented 150 for each of these.
|
||||||
|
#[test]
|
||||||
|
fn club_items_are_zero_value_not_a_fallback_to_an_invented_price() {
|
||||||
|
use crate::fut::content_taxonomy as tax;
|
||||||
|
for subtype in [
|
||||||
|
tax::KIT_SUBTYPE,
|
||||||
|
tax::STADIUM_SUBTYPE,
|
||||||
|
tax::BADGE_SUBTYPE,
|
||||||
|
tax::BALL_SUBTYPE,
|
||||||
|
tax::LEAGUE_LOGO_SUBTYPE,
|
||||||
|
] {
|
||||||
|
let ct = cardtype_for_subtype(subtype);
|
||||||
|
assert!(
|
||||||
|
!client_rerates(ct),
|
||||||
|
"subtype {subtype} must not be re-rated"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value_for_definition(subtype, 0, None, 0),
|
||||||
|
Some(0),
|
||||||
|
"subtype {subtype} must price at exactly 0, not decline to a ladder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A staff family, by contrast, DECLINES without its rating -- we cannot
|
||||||
|
// know what the client re-rated it to.
|
||||||
|
assert_eq!(value_for_definition(6, 0, None, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// The subtype decode must agree with the settled club-item subtypes and the
|
/// The subtype decode must agree with the settled club-item subtypes and the
|
||||||
/// staff family selector this crate already carries.
|
/// staff family selector this crate already carries.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ use std::net::{TcpListener, TcpStream};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use openfut_adapter_fifa17::fut::catalog::{
|
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
||||||
Fifa17CardCatalog, Fifa17CardIdentity, Fifa17WireItemIdPolicy,
|
|
||||||
};
|
|
||||||
use openfut_adapter_fifa17::fut::club_response::{
|
use openfut_adapter_fifa17::fut::club_response::{
|
||||||
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity,
|
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity,
|
||||||
Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||||
@@ -1839,7 +1837,12 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
|||||||
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
|
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
|
||||||
if discard_table_enabled() {
|
if discard_table_enabled() {
|
||||||
if let Some(ident) = self.catalog.lookup(&item.card_id) {
|
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;
|
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"))
|
*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.
|
/// A JSON response with an explicit status.
|
||||||
fn json_status(status: u16, v: &Value) -> WireResponse {
|
fn json_status(status: u16, v: &Value) -> WireResponse {
|
||||||
let body = serde_json::to_vec(v).unwrap_or_default();
|
let body = serde_json::to_vec(v).unwrap_or_default();
|
||||||
@@ -5130,70 +5106,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::async_bridge::AsyncBridge;
|
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
|
/// 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 {
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Economic impact of the recovered discard table, over OWNED instances.
|
||||||
|
|
||||||
|
Informational only: it exists to make the promotion decision explicit, never to
|
||||||
|
justify altering a value. Values come from the matrix emitted by
|
||||||
|
`cargo run -p openfut-adapter-fifa17 --example discard_matrix`, i.e. the shipped
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/fifa17-discard-impact.py --matrix /tmp/discard-matrix.csv
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import csv
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB = "/home/alex/openfut-sold-staging/staging-core.db"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--matrix", required=True)
|
||||||
|
ap.add_argument("--db", default=DB)
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
matrix = {}
|
||||||
|
with open(a.matrix) as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
matrix[row["definition"]] = row
|
||||||
|
|
||||||
|
con = sqlite3.connect("file:%s?mode=ro" % a.db, uri=True)
|
||||||
|
owned = con.execute("SELECT card_id, content_kind FROM owned_cards").fetchall()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
by_kind = collections.defaultdict(lambda: [0, 0, 0]) # n, legacy, recovered
|
||||||
|
deltas = []
|
||||||
|
missing = 0
|
||||||
|
for card_id, kind in owned:
|
||||||
|
row = matrix.get(card_id)
|
||||||
|
if row is None:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
legacy = int(row["legacy"])
|
||||||
|
rec = int(row["recovered"]) if row["recovered"] != "-" else legacy
|
||||||
|
b = by_kind[kind]
|
||||||
|
b[0] += 1
|
||||||
|
b[1] += legacy
|
||||||
|
b[2] += rec
|
||||||
|
deltas.append((rec - legacy, kind, card_id, legacy, rec))
|
||||||
|
|
||||||
|
print("OWNED-INSTANCE DISCARD IMPACT (informational)")
|
||||||
|
print("%-12s %6s %14s %14s %8s" % ("kind", "n", "legacy", "recovered", "ratio"))
|
||||||
|
tl = tr = tn = 0
|
||||||
|
for kind in sorted(by_kind):
|
||||||
|
n, legacy, rec = by_kind[kind]
|
||||||
|
ratio = (rec / legacy) if legacy else 0
|
||||||
|
print("%-12s %6d %14s %14s %7.2fx" % (kind, n, f"{legacy:,}", f"{rec:,}", ratio))
|
||||||
|
tl += legacy
|
||||||
|
tr += rec
|
||||||
|
tn += n
|
||||||
|
print("%-12s %6d %14s %14s %7.2fx"
|
||||||
|
% ("TOTAL", tn, f"{tl:,}", f"{tr:,}", (tr / tl) if tl else 0))
|
||||||
|
if missing:
|
||||||
|
print("\ndefinitions absent from the matrix: %d" % missing)
|
||||||
|
|
||||||
|
deltas.sort()
|
||||||
|
print("\nlargest DECREASES")
|
||||||
|
for d, kind, cid, legacy, rec in deltas[:5]:
|
||||||
|
print(" %-10s %-18s %6d -> %-7d (%+d)" % (kind, cid, legacy, rec, d))
|
||||||
|
print("largest INCREASES")
|
||||||
|
for d, kind, cid, legacy, rec in deltas[-5:][::-1]:
|
||||||
|
print(" %-10s %-18s %6d -> %-7d (%+d)" % (kind, cid, legacy, rec, d))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+227
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate FIFA17 quick-sell against the recovered discard table, end to end.
|
||||||
|
|
||||||
|
STAGING ONLY. Refuses to run against anything but the staging host/DB, and never
|
||||||
|
touches production.
|
||||||
|
|
||||||
|
For a deterministic fixture drawn from every quick-sell-relevant category it:
|
||||||
|
|
||||||
|
1. snapshots coins + ownership
|
||||||
|
2. quick-sells through the real Rust/Core path
|
||||||
|
3. asserts the payout equals the authoritative table value for that definition
|
||||||
|
(from `cargo run --example discard_matrix`, i.e. the shipped implementation)
|
||||||
|
4. asserts the instance is removed exactly once and nothing else moved
|
||||||
|
5. REPLAYS the same request and asserts no second grant and no resurrection
|
||||||
|
6. (optionally) restarts and re-checks persistence
|
||||||
|
7. runs concurrent duplicate sells and asserts exactly one wins
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/fifa17-discard-validate.py --matrix /tmp/discard-matrix.csv
|
||||||
|
python3 scripts/fifa17-discard-validate.py --matrix ... --concurrency
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HOST = "http://127.0.0.1:8299"
|
||||||
|
DB = "/home/alex/openfut-sold-staging/staging-core.db"
|
||||||
|
HDRS = {"X-OpenFUT-Game": "fifa17"}
|
||||||
|
|
||||||
|
FORBIDDEN = ("/home/alex/openfut-promotion",)
|
||||||
|
|
||||||
|
|
||||||
|
def guard():
|
||||||
|
for f in FORBIDDEN:
|
||||||
|
if DB.startswith(f):
|
||||||
|
raise SystemExit("refusing: DB path is production")
|
||||||
|
if "8299" not in HOST:
|
||||||
|
raise SystemExit("refusing: host is not staging :8299")
|
||||||
|
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
req = urllib.request.Request(HOST + path, headers=HDRS)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def delete(path):
|
||||||
|
req = urllib.request.Request(HOST + path, headers=HDRS, method="DELETE")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, json.loads(r.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, None
|
||||||
|
|
||||||
|
|
||||||
|
def db(query, args=()):
|
||||||
|
con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True)
|
||||||
|
try:
|
||||||
|
return con.execute(query, args).fetchall()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def coins():
|
||||||
|
return db("SELECT coins FROM clubs LIMIT 1")[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def owned_count():
|
||||||
|
return db("SELECT count(*) FROM owned_cards")[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def owned_by_kind():
|
||||||
|
return dict(db("SELECT content_kind, count(*) FROM owned_cards GROUP BY content_kind"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_matrix(path):
|
||||||
|
out = {}
|
||||||
|
with open(path) as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
out[row["definition"]] = row
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def collect_fixture():
|
||||||
|
"""One representative owned instance per (kind, rating band, rareflag)."""
|
||||||
|
picks = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def add(kind, it, note):
|
||||||
|
rid = it.get("resourceId")
|
||||||
|
wire = it.get("id")
|
||||||
|
if rid is None or wire is None:
|
||||||
|
return
|
||||||
|
key = (kind, it.get("rating", 0) // 10, it.get("rareflag", 0))
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
picks.append({
|
||||||
|
"kind": kind, "wire": wire, "definition": "fifa17_%d" % rid,
|
||||||
|
"rating": it.get("rating"), "rareflag": it.get("rareflag"),
|
||||||
|
"subtype": it.get("cardsubtypeid"), "wire_discard": it.get("discardValue"),
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
|
||||||
|
club = get("/ut/game/fifa17/club?type=player&start=0&count=200")
|
||||||
|
for it in club.get("itemData") or []:
|
||||||
|
add("player", it, "club player")
|
||||||
|
|
||||||
|
for tok in ("staff", "manager"):
|
||||||
|
try:
|
||||||
|
b = get("/ut/game/fifa17/club?type=%s&start=0&count=20" % tok)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for it in b.get("itemData") or []:
|
||||||
|
add("staff", it, "club %s" % tok)
|
||||||
|
|
||||||
|
for seg in ("contracts", "training", "fitness", "healing", "playStyle", "position"):
|
||||||
|
try:
|
||||||
|
b = get("/ut/game/fifa17/club/consumables/%s" % seg)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for st in b.get("itemData") or []:
|
||||||
|
add("consumable", st.get("item", st), "consumable/%s" % seg)
|
||||||
|
|
||||||
|
for tok in ("kit", "badge", "stadium", "ball", "misc"):
|
||||||
|
try:
|
||||||
|
b = get("/ut/game/fifa17/club?type=%s&start=0&count=10" % tok)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for it in b.get("itemData") or []:
|
||||||
|
add("clubitem", it, "club %s" % tok)
|
||||||
|
|
||||||
|
return picks
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--matrix", required=True)
|
||||||
|
ap.add_argument("--concurrency", action="store_true")
|
||||||
|
ap.add_argument("--limit", type=int, default=14)
|
||||||
|
ap.add_argument("--per-kind", type=int, default=6,
|
||||||
|
help="cap per content kind so every class is covered")
|
||||||
|
a = ap.parse_args()
|
||||||
|
guard()
|
||||||
|
|
||||||
|
matrix = load_matrix(a.matrix)
|
||||||
|
allf = collect_fixture()
|
||||||
|
per = collections.defaultdict(int)
|
||||||
|
fixture = []
|
||||||
|
for f in allf:
|
||||||
|
if per[f['kind']] >= a.per_kind:
|
||||||
|
continue
|
||||||
|
per[f['kind']] += 1
|
||||||
|
fixture.append(f)
|
||||||
|
fixture = fixture[: a.limit]
|
||||||
|
print("fixture: %d instance(s)\n" % len(fixture))
|
||||||
|
|
||||||
|
hdr = "%-10s %-16s %-5s %-4s %-5s %8s %8s %8s %s"
|
||||||
|
print(hdr % ("kind", "definition", "sub", "rat", "rare", "wire", "expect", "paid", "verdict"))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for f in fixture:
|
||||||
|
row = matrix.get(f["definition"])
|
||||||
|
if row is None:
|
||||||
|
print(hdr % (f["kind"], f["definition"], f["subtype"], f["rating"],
|
||||||
|
f["rareflag"], f["wire_discard"], "?", "-", "NO MATRIX ROW"))
|
||||||
|
results.append(("NO_MATRIX", f))
|
||||||
|
continue
|
||||||
|
expect = int(row["recovered"]) if row["recovered"] != "-" else None
|
||||||
|
|
||||||
|
before_c, before_n = coins(), owned_count()
|
||||||
|
still = db("SELECT count(*) FROM owned_cards")[0][0]
|
||||||
|
status, _ = delete("/ut/game/fifa17/item/%d" % f["wire"])
|
||||||
|
after_c, after_n = coins(), owned_count()
|
||||||
|
paid = after_c - before_c
|
||||||
|
removed = before_n - after_n
|
||||||
|
|
||||||
|
ok = (paid == expect) and removed == 1
|
||||||
|
# replay: must not grant again, must not resurrect
|
||||||
|
st2, _ = delete("/ut/game/fifa17/item/%d" % f["wire"])
|
||||||
|
replay_c, replay_n = coins(), owned_count()
|
||||||
|
replay_ok = (replay_c == after_c) and (replay_n == after_n)
|
||||||
|
|
||||||
|
verdict = "OK" if ok and replay_ok else (
|
||||||
|
"PAYOUT" if not ok else "REPLAY")
|
||||||
|
print(hdr % (f["kind"], f["definition"], f["subtype"], f["rating"],
|
||||||
|
f["rareflag"], f["wire_discard"], expect, paid,
|
||||||
|
"%s%s" % (verdict, "" if replay_ok else " (replay granted!)")))
|
||||||
|
results.append((verdict, f))
|
||||||
|
|
||||||
|
bad = [r for r in results if r[0] != "OK"]
|
||||||
|
print("\n%d/%d exact; %d problem(s)" % (len(results) - len(bad), len(results), len(bad)))
|
||||||
|
|
||||||
|
if a.concurrency:
|
||||||
|
print("\n=== concurrent duplicate quick-sell ===")
|
||||||
|
rest = collect_fixture()
|
||||||
|
target = next((x for x in rest if x["kind"] == "player"), None)
|
||||||
|
if target:
|
||||||
|
before_c, before_n = coins(), owned_count()
|
||||||
|
out = []
|
||||||
|
def worker():
|
||||||
|
out.append(delete("/ut/game/fifa17/item/%d" % target["wire"]))
|
||||||
|
ts = [threading.Thread(target=worker) for _ in range(4)]
|
||||||
|
for t in ts:
|
||||||
|
t.start()
|
||||||
|
for t in ts:
|
||||||
|
t.join()
|
||||||
|
paid = coins() - before_c
|
||||||
|
removed = before_n - owned_count()
|
||||||
|
row = matrix.get(target["definition"])
|
||||||
|
expect = int(row["recovered"]) if row else None
|
||||||
|
print(" 4 concurrent DELETEs on wire %d" % target["wire"])
|
||||||
|
print(" removed=%d (want 1) paid=%d (want %s)" % (removed, paid, expect))
|
||||||
|
print(" VERDICT: %s" % ("OK" if removed == 1 and paid == expect else "FAIL"))
|
||||||
|
|
||||||
|
print("\nownership by kind now: %s" % owned_by_kind())
|
||||||
|
return 1 if bad else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Restart staging Core + host in place (no re-seed) and verify persistence.
|
||||||
|
|
||||||
|
`sold-staging-up.py` re-seeds the club, which would MASK a persistence failure.
|
||||||
|
So this re-execs the same binaries with the same environment against the same
|
||||||
|
DB, and compares state across the restart.
|
||||||
|
|
||||||
|
STAGING ONLY.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DB = "/home/alex/openfut-sold-staging/staging-core.db"
|
||||||
|
ROOT = "/home/alex/openfut-sold-staging"
|
||||||
|
|
||||||
|
|
||||||
|
def state():
|
||||||
|
con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True)
|
||||||
|
try:
|
||||||
|
return (con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
|
||||||
|
con.execute("SELECT count(*) FROM owned_cards").fetchone()[0],
|
||||||
|
con.execute("SELECT count(*) FROM owned_cards WHERE content_kind='player'").fetchone()[0])
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def procinfo(name):
|
||||||
|
out = subprocess.run(["pgrep", "-af", name], capture_output=True, text=True).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
pid = int(line.split()[0])
|
||||||
|
try:
|
||||||
|
exe = os.path.realpath("/proc/%d/exe" % pid)
|
||||||
|
except (PermissionError, FileNotFoundError):
|
||||||
|
# Not ours -- production runs as another user. NEVER touch it.
|
||||||
|
continue
|
||||||
|
if ROOT in exe:
|
||||||
|
env = dict(
|
||||||
|
kv.split("=", 1)
|
||||||
|
for kv in open("/proc/%d/environ" % pid).read().split("\0")
|
||||||
|
if "=" in kv
|
||||||
|
)
|
||||||
|
cwd = os.path.realpath("/proc/%d/cwd" % pid)
|
||||||
|
return pid, exe, env, cwd
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_http(url, timeout=40):
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(url, timeout=3).read()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.4)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
before = state()
|
||||||
|
print("before restart : coins=%d owned=%d players=%d" % before)
|
||||||
|
|
||||||
|
procs = {}
|
||||||
|
for name in ("openfut-core", "openfut-utas-host"):
|
||||||
|
info = procinfo(name)
|
||||||
|
if not info:
|
||||||
|
print("FAIL: %s not found under %s" % (name, ROOT))
|
||||||
|
sys.exit(1)
|
||||||
|
procs[name] = info
|
||||||
|
print(" %-18s pid=%d" % (name, info[0]))
|
||||||
|
|
||||||
|
for name, (pid, exe, _, _) in procs.items():
|
||||||
|
assert ROOT in exe, "refusing to signal a process outside staging: %s" % exe
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
print("sent SIGTERM to both; waiting for exit")
|
||||||
|
for _ in range(60):
|
||||||
|
if all(not os.path.exists("/proc/%d" % p[0]) for p in procs.values()):
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
|
mid = state()
|
||||||
|
print("after stop : coins=%d owned=%d players=%d" % mid)
|
||||||
|
|
||||||
|
for name in ("openfut-core", "openfut-utas-host"):
|
||||||
|
pid, exe, env, cwd = procs[name]
|
||||||
|
log = open("%s/logs/%s.restart.log" % (ROOT, name), "ab")
|
||||||
|
subprocess.Popen([exe], env=env, cwd=cwd, stdout=log, stderr=log,
|
||||||
|
start_new_session=True)
|
||||||
|
print(" respawned %s" % name)
|
||||||
|
|
||||||
|
ok_core = wait_http("http://127.0.0.1:18081/health") or True # health path may differ
|
||||||
|
ok_host = wait_http("http://127.0.0.1:8299/ut/game/fifa17/tradePile/counts")
|
||||||
|
print("host reachable after restart: %s" % ok_host)
|
||||||
|
|
||||||
|
after = state()
|
||||||
|
print("after restart : coins=%d owned=%d players=%d" % after)
|
||||||
|
print("VERDICT: %s" % ("PERSISTED EXACTLY" if before == after == mid else "MISMATCH"))
|
||||||
|
sys.exit(0 if before == after else 1)
|
||||||
Reference in New Issue
Block a user