feat(fifa17): wire async economy handlers into the host via a runtime bridge (unrouted)
Bridges the synchronous thread-per-connection host to the async
transfer-market/pile handlers WITHOUT flipping the classifier. classify()
is untouched; production still proxies every economy route to Python. The
new dispatch is exercised only by the integration harness via
Server::try_handle_economy — handler wiring, not authority cutover.
async_bridge.rs: AsyncBridge owns ONE process-lifetime multi-threaded Tokio
runtime, shared by every connection via Arc. block_on() runs a future from
the sync dispatch thread; if invoked from within an ambient runtime it
offloads onto its own runtime + a std channel instead of panicking
("cannot start a runtime from within a runtime"). 4 unit tests incl. the
nested-runtime-safety case and concurrent multi-thread drivers.
lib.rs: EconomyRoute + classify_economy (mirrors the Python route table:
credits, purchasegroup, store/transaction, purchased, item DELETE/PUT,
ut/delete match/item/trade, auctionhouse/transfermarket, tradePile, trade).
EconomyServices (Core econ transport + durable MarketStore/PileStore + the
bridge + the pack-content pool), attached via Server::with_economy (kept out
of `new`/`from_config` so existing tests build a DB-less Server; production
from_config attachment is the barrier step). Server::try_handle_economy
dispatches: sync handlers (credits/purchasegroup/store-buy/pack-open/
quick-sell/match) inline; async handlers (market list/query/buy/cancel,
move) on the bridge via owned `async move` blocks. build_content_pool
derives the resolvable FIFA∩Core candidate pool from Core content.
market.rs: FIX the load-bearing hazard the FakeEconomy tests missed — the
async market handlers call the BLOCKING reqwest Core client, which panics
(reqwest::blocking::wait::enter) when run while a Tokio runtime is entered.
off_runtime() hops each Core call to a fresh OS thread with no runtime
entered, so blocking is legal. handle_move_items resolver gains `+ Sync`
(future must be Send for the bridge).
tests/economy_integration.rs: economy_full_sequence_through_dispatch drives
the WHOLE cluster through the REAL Server dispatch + bridge against a live
in-process Core (seeded with fifa17 dev content: 100k coins + owned cards),
on a plain OS thread (direct bridge path), over the real blocking
HttpCoreClient — no fakes: Store BUY (pool draw + shape + debit 400 + mint 5),
credits, quick-sell (reverse-resolve + credit), match WIN (+400), market
list->query->buy->query(sold)->second-buy-fails(no double debit), cancel
(cancelled not buyable), move-items, then reopen the durable market/pile
stores from disk (sold + pile persist). Deterministic. start_core_seeded
loads fifa17 dev content so /collection renders real definitions.
Tests: host 68 lib (+4 bridge) + 2 economy_integration + 24 host_test, all
green; adapter unchanged-green; clippy -D warnings + fmt clean.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
//! faked (see `club_response`). With today's empty mapping, `/club` returns
|
||||
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
|
||||
|
||||
pub mod async_bridge;
|
||||
pub mod config;
|
||||
pub mod economy_store;
|
||||
pub mod market;
|
||||
@@ -56,6 +57,7 @@ use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::owned_query::{
|
||||
is_special_rareflag, map_to_core, parse_club_query, MapError,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
@@ -69,6 +71,7 @@ use openfut_adapter_fifa17::fut::store_session::{
|
||||
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
|
||||
};
|
||||
use openfut_identity::ExternalIdentityStore;
|
||||
use rand::SeedableRng;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use config::HostConfig;
|
||||
@@ -162,6 +165,87 @@ fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A FIFA17 economy route, classified separately from [`classify`]. This is the
|
||||
/// *target* ownership map for the economy cutover. It is deliberately NOT wired
|
||||
/// into [`Server::handle_with_ip`] yet: handler wiring and authority cutover are
|
||||
/// distinct steps. Until the single barrier commit flips the whole cluster,
|
||||
/// production classification ([`classify`]) still sends every one of these to
|
||||
/// Python; only integration tests drive them through [`Server::try_handle_economy`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EconomyRoute {
|
||||
/// `GET …/user/credits` — coins + unopened-pack count.
|
||||
Credits,
|
||||
/// `GET …/store/purchasegroup` — catalogue + owned packs, full-generated.
|
||||
PurchaseGroup,
|
||||
/// `PUT …/store/transaction` — Store BUY (open-on-buy).
|
||||
StoreBuy,
|
||||
/// `POST …/purchased` — open a pack / redeem an owned entitlement.
|
||||
PackOpen,
|
||||
/// `DELETE …/item/<id>` — single-card quick-sell.
|
||||
QuickSellPath,
|
||||
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
|
||||
QuickSellBody,
|
||||
/// `PUT …/item` — FutMoveCard pile move.
|
||||
MoveItems,
|
||||
/// `POST /ut/delete/game/<sku>/match` — match END (the coin-crediting call).
|
||||
MatchEnd,
|
||||
/// `…/auctionhouse` | `…/transfermarket` — list-for-sale / browse.
|
||||
MarketList,
|
||||
/// `GET …/tradePile` — the user's own active listings.
|
||||
MarketQuery,
|
||||
/// `…/trade/<id>` — view / buy-now.
|
||||
MarketBuy,
|
||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — cancel a listing.
|
||||
MarketCancel,
|
||||
}
|
||||
|
||||
/// `item/<digits>` — the single-card quick-sell tail (DELETE).
|
||||
fn is_item_id_tail(tail: &str) -> bool {
|
||||
match tail.strip_prefix("item/") {
|
||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
||||
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
||||
/// non-economy path. Path is already query-stripped by the caller.
|
||||
pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
||||
let get = method.eq_ignore_ascii_case("GET");
|
||||
let put = method.eq_ignore_ascii_case("PUT");
|
||||
let post = method.eq_ignore_ascii_case("POST");
|
||||
let delete = method.eq_ignore_ascii_case("DELETE");
|
||||
|
||||
// The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed.
|
||||
if let Some(rest) = path.strip_prefix("/ut/delete/game/") {
|
||||
if let Some((_sku, tail)) = rest.split_once('/') {
|
||||
if tail == "item" && post {
|
||||
return Some(EconomyRoute::QuickSellBody);
|
||||
}
|
||||
if tail.starts_with("trade") && delete {
|
||||
return Some(EconomyRoute::MarketCancel);
|
||||
}
|
||||
if tail == "match" && post {
|
||||
return Some(EconomyRoute::MatchEnd);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
match ut_tail(path) {
|
||||
Some("user/credits") if get => Some(EconomyRoute::Credits),
|
||||
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
|
||||
Some("store/transaction") if put => Some(EconomyRoute::StoreBuy),
|
||||
Some("purchased") if post => Some(EconomyRoute::PackOpen),
|
||||
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
|
||||
Some("item") if put => Some(EconomyRoute::MoveItems),
|
||||
Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
|
||||
Some("tradePile") if get => Some(EconomyRoute::MarketQuery),
|
||||
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Core access boundary ─────────────────────────
|
||||
|
||||
/// Failure reaching or reading OpenFUT Core.
|
||||
@@ -1594,6 +1678,61 @@ impl PassClient {
|
||||
|
||||
// ───────────────────────────── Server ───────────────────────────────────────
|
||||
|
||||
/// The durable services the FIFA17 economy handlers need, wired into [`Server`]
|
||||
/// ONCE at construction (never per request). All are process-lifetime `Arc`s:
|
||||
/// the Core economy transport, the two host-owned durable SQLite stores (listing
|
||||
/// and pile), the shared Tokio runtime bridge, and the pack-content candidate
|
||||
/// pool (the resolvable FIFA∩Core card universe).
|
||||
#[derive(Clone)]
|
||||
pub struct EconomyServices {
|
||||
pub econ: Arc<dyn CoreEconomy>,
|
||||
pub market: Arc<crate::market_store::MarketStore>,
|
||||
pub piles: Arc<crate::pile_store::PileStore>,
|
||||
pub bridge: Arc<crate::async_bridge::AsyncBridge>,
|
||||
/// The resolvable FIFA∩Core card universe a pack can award (empty → the Store
|
||||
/// fail-closes: it draws nothing and debits nothing).
|
||||
pub pool: Arc<Vec<GeneratedCandidate>>,
|
||||
}
|
||||
|
||||
/// Build the pack-content candidate pool from Core's current content, evidenced
|
||||
/// by the owned inventory: every distinct owned card definition that resolves to
|
||||
/// a real FIFA asset id is a candidate (`gold` = rating ≥ 75; `special` from the
|
||||
/// catalog `rareflag > 1`). This is the resolvable FIFA∩Core card universe — the
|
||||
/// cards a pack can award and the shared shaper can render. An empty pool (no
|
||||
/// content, or Core unreachable) is fail-closed by construction: the generator
|
||||
/// returns no cards, so the Store neither mints nor debits.
|
||||
pub fn build_content_pool(
|
||||
core: &dyn CoreAccess,
|
||||
resolver: &Fifa17IdentityResolver,
|
||||
) -> Vec<GeneratedCandidate> {
|
||||
let owned = match core.all_owned() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut pool = Vec::new();
|
||||
for item in &owned {
|
||||
if !seen.insert(item.card_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = resolver.resolve(item) else {
|
||||
continue;
|
||||
};
|
||||
pool.push(GeneratedCandidate {
|
||||
card_id: item.card_id.clone(),
|
||||
rating: item.rating,
|
||||
position: item.position.clone(),
|
||||
nation: item.nation.clone(),
|
||||
league: item.league.clone(),
|
||||
club: item.club.clone(),
|
||||
attributes: item.attributes,
|
||||
gold: item.rating >= 75,
|
||||
special: id.rareflag > 1,
|
||||
});
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
/// The migration host. Cheap to clone (all shared state is `Arc`).
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
@@ -1613,6 +1752,11 @@ pub struct Server {
|
||||
sessions: Arc<Mutex<SessionStore>>,
|
||||
/// Monotonic clock origin for the session/pending TTLs.
|
||||
start: Instant,
|
||||
/// FIFA17 economy authority services (Core transport + durable listing/pile
|
||||
/// stores + runtime bridge + content pool). `None` until wired via
|
||||
/// [`Server::with_economy`]; the economy dispatch is inert without it, and
|
||||
/// `handle_with_ip` does not consult it until the classifier barrier.
|
||||
economy: Option<Arc<EconomyServices>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -1632,6 +1776,7 @@ impl Server {
|
||||
persona_id,
|
||||
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
||||
start: Instant::now(),
|
||||
economy: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1658,6 +1803,7 @@ impl Server {
|
||||
persona_id: cfg.persona_id,
|
||||
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
||||
start: Instant::now(),
|
||||
economy: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1671,6 +1817,139 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the FIFA17 economy authority services. Kept separate from
|
||||
/// construction so the (many) squad/club tests build a `Server` without a
|
||||
/// database, while the economy integration path wires real durable stores +
|
||||
/// the runtime bridge once.
|
||||
pub fn with_economy(mut self, economy: Arc<EconomyServices>) -> Self {
|
||||
self.economy = Some(economy);
|
||||
self
|
||||
}
|
||||
|
||||
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
|
||||
/// is not an economy route (or no economy services are wired). This is the
|
||||
/// handler-wiring entry point exercised by the integration harness; it is
|
||||
/// deliberately NOT called by `handle_with_ip` yet — handler wiring and the
|
||||
/// authority cutover are distinct steps, and the classifier barrier is one
|
||||
/// later coherent flip. Sync handlers run inline; the async transfer-market /
|
||||
/// move handlers run on the shared runtime via the bridge.
|
||||
pub fn try_handle_economy(
|
||||
&self,
|
||||
method: &str,
|
||||
target: &str,
|
||||
headers: &[(String, String)],
|
||||
body: &[u8],
|
||||
client_ip: Option<&str>,
|
||||
) -> Option<WireResponse> {
|
||||
let svc = self.economy.as_ref()?;
|
||||
let path = target.split('?').next().unwrap_or(target);
|
||||
let route = classify_economy(method, path)?;
|
||||
use crate::economy_store::{
|
||||
handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy,
|
||||
CoreItemLookup, QuickSellDeps, StoreDeps,
|
||||
};
|
||||
let resp = match route {
|
||||
EconomyRoute::Credits => handle_credits(svc.econ.as_ref()),
|
||||
EconomyRoute::PurchaseGroup => {
|
||||
let sid = header(headers, "x-ut-sid").unwrap_or("");
|
||||
let mode =
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.empty_mypacks_mode(sid, client_ip, self.now());
|
||||
handle_purchasegroup(svc.econ.as_ref(), mode)
|
||||
}
|
||||
EconomyRoute::StoreBuy => {
|
||||
let mut rng = rand::rngs::StdRng::from_entropy();
|
||||
let deps = StoreDeps {
|
||||
econ: svc.econ.as_ref(),
|
||||
assets: self.resolver.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
pool: svc.pool.as_ref(),
|
||||
};
|
||||
handle_store_buy(body, &deps, &mut rng)
|
||||
}
|
||||
EconomyRoute::PackOpen => {
|
||||
let mut rng = rand::rngs::StdRng::from_entropy();
|
||||
let deps = StoreDeps {
|
||||
econ: svc.econ.as_ref(),
|
||||
assets: self.resolver.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
pool: svc.pool.as_ref(),
|
||||
};
|
||||
handle_pack_open(body, &deps, &mut rng)
|
||||
}
|
||||
EconomyRoute::QuickSellPath => {
|
||||
let id = ut_tail(path)
|
||||
.and_then(|t| t.strip_prefix("item/"))
|
||||
.and_then(|d| d.parse::<i64>().ok())?;
|
||||
let lookup = CoreItemLookup {
|
||||
core: self.core.as_ref(),
|
||||
};
|
||||
let deps = QuickSellDeps {
|
||||
econ: svc.econ.as_ref(),
|
||||
reverse: self.resolver.as_ref(),
|
||||
items: &lookup,
|
||||
};
|
||||
handle_quick_sell_path(id, &deps)
|
||||
}
|
||||
EconomyRoute::QuickSellBody => {
|
||||
let lookup = CoreItemLookup {
|
||||
core: self.core.as_ref(),
|
||||
};
|
||||
let deps = QuickSellDeps {
|
||||
econ: svc.econ.as_ref(),
|
||||
reverse: self.resolver.as_ref(),
|
||||
items: &lookup,
|
||||
};
|
||||
handle_quick_sell_body(body, &deps)
|
||||
}
|
||||
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body),
|
||||
EconomyRoute::MoveItems => {
|
||||
let (bridge, piles, resolver) =
|
||||
(svc.bridge.clone(), svc.piles.clone(), self.resolver.clone());
|
||||
let body = body.to_vec();
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_move_items(&body, resolver.as_ref(), piles.as_ref()).await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketList => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
let (m, body) = (method.to_string(), body.to_vec());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_list(&m, &body, econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketQuery => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_query("active", econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketBuy => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketCancel => {
|
||||
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
||||
let (p, owner) = (path.to_string(), client_ip.map(|s| s.to_string()));
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await
|
||||
})
|
||||
}
|
||||
};
|
||||
Some(resp)
|
||||
}
|
||||
|
||||
/// 4-arg entrypoint (tests + callers without a peer address). Session-bound
|
||||
/// routes fall back to a `None` client IP.
|
||||
pub fn handle(
|
||||
|
||||
Reference in New Issue
Block a user