feat(fifa17): route match completion to Core exactly-once economy
Adapter: new fut/match_wire.rs owns the FIFA17 match wire — endReason enum -> canonical result token (win/draw/loss/dnf/no_contest), match-end payload parse (goals from myMatchStats[0], omitted on DNF/QUIT), and the reward-response projection (only reversed fields; never bidTokens/ qualifiedChampionEventId). Match logic removed from economy_policy.rs (kept pack/fee); registered match_wire in fut/mod.rs. Host: handle_match_end now applies the match to Core's authoritative complete_match (POST /matches/complete) fail-closed — any Core error is a 503, never a Python fallback — and renders Core's authoritative coins. Per-match identity from matchReportId or a body fingerprint keys Core's durable idempotency. New CoreEconomy::complete_match transport + CoreMatchCompletion/CoreMatchReceipt. Tests: adapter endReason/parse/projection; host shaping, fail-closed, malformed, identity dedupe; integration replay + rebased balance chains (match now also grants XP/level-up/achievement coins).
This commit is contained in:
@@ -484,20 +484,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// E: match REWARD + Store BUY concurrently → final balance is one legal
|
||||
/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the
|
||||
/// final balance must equal the start exactly.
|
||||
/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
|
||||
/// update). The two commute, so the final balance must equal exactly one serial
|
||||
/// outcome: the match's reported post-credit balance (`allCoins`), or that minus
|
||||
/// the buy's debit — never a torn value from a clobbered write. Amount-agnostic,
|
||||
/// so it holds even when a WIN also triggers an XP level-up bonus.
|
||||
fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
// Measure the store BUY's deterministic debit once.
|
||||
set_balance(&h.client, 50_000);
|
||||
let pre_probe = h.client.balance().unwrap();
|
||||
let probe = fire(
|
||||
&h.server,
|
||||
vec![(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/store/transaction".into(),
|
||||
b"{\"packId\":1}".to_vec(),
|
||||
)],
|
||||
);
|
||||
assert_eq!(probe[0].status, 200, "probe buy ok");
|
||||
let buy_debit = pre_probe - h.client.balance().unwrap();
|
||||
assert!(buy_debit > 0, "store buy must debit a positive price");
|
||||
|
||||
let start = 8_000i64;
|
||||
for _ in 0..ITERS {
|
||||
for i in 0..ITERS {
|
||||
set_balance(&h.client, start);
|
||||
// A DISTINCT match per iteration (unique matchReportId) so the
|
||||
// exactly-once reward applies every time.
|
||||
let rs = fire(
|
||||
&h.server,
|
||||
vec![
|
||||
(
|
||||
"POST",
|
||||
"/ut/delete/game/fifa17/match".into(),
|
||||
b"{\"endReason\":\"WIN\"}".to_vec(),
|
||||
format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
@@ -507,13 +526,15 @@ fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
],
|
||||
);
|
||||
assert!(rs.iter().all(|r| r.status == 200), "both ops succeed");
|
||||
assert_eq!(
|
||||
h.client.balance().unwrap(),
|
||||
start,
|
||||
"reward(+400) and buy(-400) both applied: no lost update"
|
||||
let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
|
||||
let after = h.client.balance().unwrap();
|
||||
// Both writers serialized: `after` is one of the two legal orderings.
|
||||
assert!(
|
||||
after == all || after == all - buy_debit,
|
||||
"no lost update: after={after}, match allCoins={all}, buy_debit={buy_debit}"
|
||||
);
|
||||
}
|
||||
format!("E reward+buy: {ITERS} iters, final==start ({start}) every time (no lost update)")
|
||||
format!("E reward+buy: {ITERS} iters, no lost update (buy_debit={buy_debit})")
|
||||
}
|
||||
|
||||
/// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold
|
||||
|
||||
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
use openfut_utas_host::market_store::MarketStore;
|
||||
use openfut_utas_host::pile_store::PileStore;
|
||||
use openfut_utas_host::{
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Server, WireResponse,
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy {
|
||||
}
|
||||
self.inner.settle_sale(sale)
|
||||
}
|
||||
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
|
||||
if self.trip("complete_match") {
|
||||
return Err(Self::injected());
|
||||
}
|
||||
self.inner.complete_match(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
||||
|
||||
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
}
|
||||
|
||||
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
|
||||
/// Returns nothing; panics on any mismatch.
|
||||
fn seed_and_exercise(base: &str) {
|
||||
/// Returns the final Core balance so the restart phase can assert persistence.
|
||||
fn seed_and_exercise(base: &str) -> i64 {
|
||||
wait_ready(base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
|
||||
@@ -155,12 +155,30 @@ fn seed_and_exercise(base: &str) {
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
|
||||
|
||||
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end.
|
||||
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#);
|
||||
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
|
||||
// exactly-once complete_match transaction, end to end. The reward is at least
|
||||
// the match coins; Core may also grant XP-driven level-up and first-win
|
||||
// achievement coins, so assert the flat match coins + a relative delta.
|
||||
let before_match = client.balance().unwrap();
|
||||
let m = handle_match_end(&client, 1, br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(m.status, 200);
|
||||
let mb: Value = serde_json::from_slice(&m.body).unwrap();
|
||||
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core");
|
||||
assert_eq!(client.balance().unwrap(), 5400);
|
||||
assert_eq!(mb["matchCoins"], 400, "flat match coins");
|
||||
let after_match = client.balance().unwrap();
|
||||
assert!(after_match >= before_match + 400, "match credited at least +400");
|
||||
assert_eq!(
|
||||
mb["allCoins"].as_i64().unwrap(),
|
||||
after_match,
|
||||
"response echoes the authoritative Core balance"
|
||||
);
|
||||
// Idempotent replay: the SAME match-end body does NOT double-credit.
|
||||
let replay = handle_match_end(&client, 1, br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(replay.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_match,
|
||||
"replay must not re-credit"
|
||||
);
|
||||
|
||||
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
|
||||
post(
|
||||
@@ -169,11 +187,16 @@ fn seed_and_exercise(base: &str) {
|
||||
"/economy/purchase-entitlement",
|
||||
json!({ "cost": 600, "definition_id": "70" }),
|
||||
);
|
||||
assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically");
|
||||
let after_buy = after_match - 600;
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_buy,
|
||||
"debit applied atomically"
|
||||
);
|
||||
|
||||
// credits reflects the debit through the same Core state.
|
||||
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits2["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(credits2["currencies"][0]["funds"], after_buy);
|
||||
|
||||
// purchasegroup full-gen shows the owned pack 70 and NO sentinel.
|
||||
let pg: Value =
|
||||
@@ -194,25 +217,27 @@ fn seed_and_exercise(base: &str) {
|
||||
client.balance().unwrap(),
|
||||
client.entitlements().unwrap().len(),
|
||||
);
|
||||
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy);
|
||||
// Invariant: credits coins == userMassInfo coins == Core balance.
|
||||
assert_eq!(
|
||||
credits2["currencies"][0]["funds"],
|
||||
mass["userInfo"]["currencies"][0]["funds"]
|
||||
);
|
||||
|
||||
after_buy
|
||||
}
|
||||
|
||||
/// After a Core restart from the same DB file, all economy state persists.
|
||||
fn verify_after_restart(base: &str) {
|
||||
fn verify_after_restart(base: &str, expected_balance: i64) {
|
||||
wait_ready(base);
|
||||
let client = HttpCoreClient::new(base, "fifa17");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
4800,
|
||||
expected_balance,
|
||||
"coins persisted across restart"
|
||||
);
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(credits["currencies"][0]["funds"], expected_balance);
|
||||
let pg: Value =
|
||||
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
|
||||
assert!(
|
||||
@@ -239,12 +264,12 @@ async fn economy_end_to_end_and_restart_persistence() {
|
||||
let b1 = base1.clone();
|
||||
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
|
||||
h1.abort();
|
||||
r.expect("exercise phase");
|
||||
let expected_balance = r.expect("exercise phase");
|
||||
|
||||
// --- Core instance #2: same on-disk DB, prove persistence ---
|
||||
let (h2, base2) = start_core(&db_url).await;
|
||||
let b2 = base2.clone();
|
||||
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await;
|
||||
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await;
|
||||
h2.abort();
|
||||
r2.expect("restart phase");
|
||||
|
||||
@@ -422,7 +447,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
"totalCredits == absolute Core balance"
|
||||
);
|
||||
|
||||
// 4) Match END reward through dispatch (WIN = +400).
|
||||
// 4) Match END reward through dispatch. The WIN credits at least the flat
|
||||
// match coins (Core may also add XP level-up / first-win achievement coins).
|
||||
let before_match = client.balance().unwrap();
|
||||
let mm = server
|
||||
.try_handle_economy(
|
||||
@@ -434,10 +460,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
)
|
||||
.expect("match routed");
|
||||
assert_eq!(mm.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
before_match + 400,
|
||||
"WIN credited +400 via Core"
|
||||
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
|
||||
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
|
||||
assert!(
|
||||
client.balance().unwrap() >= before_match + 400,
|
||||
"WIN credited at least +400 via Core"
|
||||
);
|
||||
|
||||
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
||||
|
||||
Reference in New Issue
Block a user