4 Commits

Author SHA1 Message Date
funman300 0a7c4e129c docs(fifa17): record discard economy validation evidence and impact
Adds scripts/fifa17-discard-impact.py (owned-instance economic impact, computed
from the shipped implementation's matrix -- informational, never a reason to
alter a value) and records the measured results.

Owned club, 1993 instances: legacy 1,820,700 -> recovered 19,128,031 = 10.51x.
Players 10.53x, manager 1.88x, consumables 0.19x (the ladder overpaid them ~5x),
staff 0.24x, club items 900 -> 0.
2026-08-21 23:59:35 +00:00
funman300 a96d06dbc0 test(fifa17): validate discard payouts, replay, concurrency and persistence
Adds two staging-only harnesses and records the results.

scripts/fifa17-discard-validate.py drives the REAL Rust/Core quick-sell path for
a fixture spanning every quick-sell-relevant category, and checks each against
the authoritative table value emitted by the discard_matrix example (i.e. the
shipped implementation, not a reimplementation). Per item it asserts the payout
is exact, the instance is removed exactly once, and a REPLAY of the same request
grants nothing and resurrects nothing.

Results with OPENFUT_FIFA17_DISCARD_TABLE=1 on the real 1993-item club:

  players     6/6 exact   752 .. 74,400 (rareflag 1,3,4,5,6,11,21,22,23,24)
  staff       2/2 exact   36 (gk coach, fitness coach)
  consumables 4/4 exact   3, 3, 32, 38
  club item   1/1 exact   0  (kit -- and 0 is what the client displays)
  TOTAL      12/12 exact, 0 replay grants

  wire discardValue == expected == actual payout for every player, so what the
  client is shown and what Core credits are the same number by construction.

Concurrency: 4 simultaneous DELETEs on one wire id -> removed exactly 1, paid
exactly once (23,280).

scripts/fifa17-restart-persistence.py restarts Core and host IN PLACE with their
own environment rather than via the bring-up script, because `up` re-seeds the
club and would mask a persistence failure. It refuses to signal any process
outside the staging root -- production runs as another user and is skipped
explicitly. Result across SIGTERM + respawn of both: coins 29,967,428, owned
1978, players 1958 -> PERSISTED EXACTLY.

Production untouched; staging only, flag set only in staging.
2026-08-21 23:56:24 +00:00
funman300 06d94bb37d fix(fifa17): club items are zero-value, not a fallback to an invented price
`value_for_definition` declined for anything non-player without a catalog rating,
which sent club items into the legacy ladder and paid an invented 150 each.

That is wrong, and the client says so. `shape_club_item` sends neither `rating`
nor `discardValue`, and cardtype 7/9 are NOT re-rated by the client (the merge
jump table sends them to the shared tail), so the client computes for itself from
record +0xb4 == 0: level 1, `0 * price / 100` == 0. It DISPLAYS 0. Paying 150
invents value the player was never shown.

Move the decline boundary onto the real distinction, which is `client_rerates`:

  * NOT re-rated (cardtypes 1, 6, 7, 8, 9) -> the server's rating is what the
    client prices with, so Core's value is authoritative even at 0.
  * RE-RATED (2, 3, 4, 5, 10 -- the staff families) -> the client substitutes its
    own database value, so without a catalog rating we genuinely cannot match it
    and must decline rather than guess.

Over the 1717-definition corpus this takes "declined -> legacy" from 6 to ZERO:
every definition is now priced by the one authoritative table and no generic
fallback is reachable in the current corpus. The six club items price at exactly
0; staff and consumables are unchanged.

Adapter 248 lib, host 120 lib, fmt and clippy clean.
2026-08-21 23:53:14 +00:00
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
6 changed files with 692 additions and 95 deletions
@@ -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");
}
+114
View File
@@ -247,6 +247,53 @@ 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 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)]
mod tests {
use super::*;
@@ -343,6 +390,73 @@ 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));
}
/// 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
/// staff family selector this crate already carries.
#[test]
+7 -95
View File
@@ -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 {
+76
View File
@@ -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()
+227
View File
@@ -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())
+101
View File
@@ -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)