Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce5d4204ac | |||
| 6ca735749e |
@@ -218,18 +218,38 @@ pub fn consumable_needs(family: &str) -> ConsumableNeeds {
|
|||||||
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
||||||
/// and this route is the door).
|
/// and this route is the door).
|
||||||
///
|
///
|
||||||
/// The segment names are the consumable UI group table at `0x180203260` (seven
|
/// The segment names are the client's own CONSUMABLE_TYPE→segment switch,
|
||||||
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
|
/// recovered live 2026-08-22 from CardsDLL: the literal table at `0x1801f5a38`
|
||||||
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
|
/// (under `MyClubAdapterClass`/`CONSUMABLE_TYPE`) and the jump table at
|
||||||
/// on the wire and the singular `contract` is accepted because the client has
|
/// `0x180048820`, which indexes by `enum + 1` through the byte table at
|
||||||
/// used both spellings. Segments are matched lower-cased.
|
/// `0x180048a90`. Nine segments, not seven:
|
||||||
///
|
///
|
||||||
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
|
/// | enum | segment |
|
||||||
/// correspondence is checkable against the panel: training→42, contracts→13,
|
/// |------|---------|
|
||||||
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
|
/// | -1 (unset) | `development` |
|
||||||
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
|
/// | 1, 2 | `contracts` |
|
||||||
/// NO group code, so no segment can reach them — that is the client's own gap,
|
/// | 3 | `healing` |
|
||||||
/// not an omission here.
|
/// | 4 | `fitness` |
|
||||||
|
/// | 16 | `formation` |
|
||||||
|
/// | 17 | `position` |
|
||||||
|
/// | 23 | `playStyle` |
|
||||||
|
/// | 24 | `managerLeagueModifier` |
|
||||||
|
/// | 0, 5..15, 18..22 | `training` (the switch default) |
|
||||||
|
///
|
||||||
|
/// This CORRECTS the previous note here, which read the seven-code UI group
|
||||||
|
/// table at `0x180203260` and concluded the two formation-modifier families
|
||||||
|
/// "have NO group code, so no segment can reach them — the client's own gap".
|
||||||
|
/// The client does have a `formation` segment (enum 16), and it asked for
|
||||||
|
/// `development` live, so both were server-side gaps, not client ones.
|
||||||
|
///
|
||||||
|
/// `development` is the **type-unset** bucket: index 0 of a table indexed by
|
||||||
|
/// `enum + 1`, i.e. no type filter was set. It is therefore the unfiltered view
|
||||||
|
/// and maps to every family — which is consistent, since the eight TYPED
|
||||||
|
/// segments already reach all thirteen families exactly once.
|
||||||
|
///
|
||||||
|
/// `training` and `contracts` are CONFIRMED on the wire, `development` was
|
||||||
|
/// observed live, and the singular `contract` is accepted because the client has
|
||||||
|
/// used both spellings. Segments are matched lower-cased.
|
||||||
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
||||||
Some(match segment {
|
Some(match segment {
|
||||||
"training" => &["gk_training", "player_training"],
|
"training" => &["gk_training", "player_training"],
|
||||||
@@ -239,10 +259,31 @@ pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'sta
|
|||||||
"position" => &["position_mod"],
|
"position" => &["position_mod"],
|
||||||
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
||||||
"managerleaguemodifier" => &["manager_league"],
|
"managerleaguemodifier" => &["manager_league"],
|
||||||
|
"formation" => &["manager_formation_mod", "formation_mod"],
|
||||||
|
"development" => ALL_CONSUMABLE_FAMILIES,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every consumable family, i.e. the `development` (type-unset) view. Kept as one
|
||||||
|
/// list so a new family cannot be added to the taxonomy and silently omitted from
|
||||||
|
/// the unfiltered screen.
|
||||||
|
pub const ALL_CONSUMABLE_FAMILIES: &[&str] = &[
|
||||||
|
"gk_training",
|
||||||
|
"player_training",
|
||||||
|
"player_contract",
|
||||||
|
"manager_contract",
|
||||||
|
"player_fitness",
|
||||||
|
"squad_fitness",
|
||||||
|
"healing",
|
||||||
|
"position_mod",
|
||||||
|
"player_playstyle",
|
||||||
|
"gk_playstyle",
|
||||||
|
"manager_league",
|
||||||
|
"manager_formation_mod",
|
||||||
|
"formation_mod",
|
||||||
|
];
|
||||||
|
|
||||||
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
||||||
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
||||||
///
|
///
|
||||||
@@ -430,7 +471,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn consumable_route_categories_partition_the_reachable_families() {
|
fn consumable_route_categories_partition_the_reachable_families() {
|
||||||
// The seven group codes, plus the singular `contract` spelling.
|
// The eight TYPED segments of the client's own switch (enum 1,2,3,4,16,
|
||||||
|
// 17,23,24 plus the default), and the singular `contract` spelling.
|
||||||
let segments = [
|
let segments = [
|
||||||
"training",
|
"training",
|
||||||
"contracts",
|
"contracts",
|
||||||
@@ -439,6 +481,7 @@ mod tests {
|
|||||||
"position",
|
"position",
|
||||||
"playstyle",
|
"playstyle",
|
||||||
"managerleaguemodifier",
|
"managerleaguemodifier",
|
||||||
|
"formation",
|
||||||
];
|
];
|
||||||
let mut seen: Vec<&str> = Vec::new();
|
let mut seen: Vec<&str> = Vec::new();
|
||||||
for seg in segments {
|
for seg in segments {
|
||||||
@@ -452,22 +495,27 @@ mod tests {
|
|||||||
consumable_families_for_category("contracts"),
|
consumable_families_for_category("contracts"),
|
||||||
"both spellings the client has used mean the same set"
|
"both spellings the client has used mean the same set"
|
||||||
);
|
);
|
||||||
// Eleven of the thirteen families are reachable; the two formation
|
// All THIRTEEN families are reachable: the client does have a `formation`
|
||||||
// modifiers have no group code in the client's own table.
|
// segment (enum 16), so the two formation modifiers were a server-side
|
||||||
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
|
// gap, not the client gap this test used to assert.
|
||||||
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
|
assert_eq!(seen.len(), 13, "no duplicates: {seen:?}");
|
||||||
|
for subtype in [51, 61, 71, 91, 121, 201, 202, 211, 219, 220, 250, 269, 300] {
|
||||||
let (family, _) = consumable_family(subtype).unwrap();
|
let (family, _) = consumable_family(subtype).unwrap();
|
||||||
assert!(seen.contains(&family), "no category serves {family}");
|
assert!(seen.contains(&family), "no category serves {family}");
|
||||||
}
|
}
|
||||||
for unreachable in [71, 121] {
|
// `development` is the type-UNSET bucket (index 0 of an `enum + 1` table),
|
||||||
let (family, _) = consumable_family(unreachable).unwrap();
|
// i.e. the unfiltered view. It deliberately overlaps the typed segments,
|
||||||
assert!(
|
// and must stay exactly the union of them so a new family cannot be added
|
||||||
!seen.contains(&family),
|
// to the taxonomy and silently vanish from the unfiltered screen.
|
||||||
"{family} has no group code; claiming it would invent a segment"
|
let mut dev = consumable_families_for_category("development")
|
||||||
);
|
.unwrap()
|
||||||
}
|
.to_vec();
|
||||||
|
dev.sort_unstable();
|
||||||
|
let mut all = seen.clone();
|
||||||
|
all.sort_unstable();
|
||||||
|
assert_eq!(dev, all, "development must be exactly the unfiltered set");
|
||||||
// Not a consumables segment (and NOT a `?type=` token either).
|
// Not a consumables segment (and NOT a `?type=` token either).
|
||||||
for s in ["", "player", "kit", "Training", "development"] {
|
for s in ["", "player", "kit", "Training"] {
|
||||||
assert!(
|
assert!(
|
||||||
consumable_families_for_category(s).is_none(),
|
consumable_families_for_category(s).is_none(),
|
||||||
"{s:?} is not a consumable category"
|
"{s:?} is not a consumable category"
|
||||||
|
|||||||
+233
-44
@@ -186,6 +186,17 @@ pub enum Route {
|
|||||||
/// Container type is load-bearing (object-where-array froze a live client);
|
/// Container type is load-bearing (object-where-array froze a live client);
|
||||||
/// the handler picks it from the path. Constant band 150..15000.
|
/// the handler picks it from the path. Constant band 150..15000.
|
||||||
MarketData,
|
MarketData,
|
||||||
|
/// `POST …/item/resource/<resourceId>` — consumable APPLICATION
|
||||||
|
/// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as
|
||||||
|
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
|
||||||
|
/// the path; the targets are owned-item wire ids in the body.
|
||||||
|
///
|
||||||
|
/// This classifies unconditionally so the route table stays a pure function of
|
||||||
|
/// (method, path) and remains testable, but the handler is a STAGING-ONLY
|
||||||
|
/// DIAGNOSTIC: without `OPENFUT_FIFA17_APPLY_PROBE=1` it declines and the
|
||||||
|
/// request falls through to the Python passthrough exactly as it does today.
|
||||||
|
/// The effect of a consumable is UNREVERSED, so nothing is ever mutated here.
|
||||||
|
ConsumableApplyProbe,
|
||||||
/// Anything else — proxied verbatim to the Python oracle.
|
/// Anything else — proxied verbatim to the Python oracle.
|
||||||
Passthrough,
|
Passthrough,
|
||||||
}
|
}
|
||||||
@@ -285,6 +296,16 @@ pub fn classify(method: &str, path: &str) -> Route {
|
|||||||
Some("clubUser") if get => Route::FeatureOffEmpty,
|
Some("clubUser") if get => Route::FeatureOffEmpty,
|
||||||
Some("user/list") if get => Route::FeatureOffEmpty,
|
Some("user/list") if get => Route::FeatureOffEmpty,
|
||||||
Some("item/resource") if get => Route::ItemDefs,
|
Some("item/resource") if get => Route::ItemDefs,
|
||||||
|
// The apply re-uses the item-definition PATH with a different VERB and a
|
||||||
|
// trailing resource id, which is why it fell through to Python: the
|
||||||
|
// `item/resource` arm above is GET-only. Live-captured 2026-08-21.
|
||||||
|
Some(t)
|
||||||
|
if post
|
||||||
|
&& t.strip_prefix("item/resource/")
|
||||||
|
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
|
||||||
|
{
|
||||||
|
Route::ConsumableApplyProbe
|
||||||
|
}
|
||||||
Some("defid") if get => Route::ItemDefs,
|
Some("defid") if get => Route::ItemDefs,
|
||||||
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
|
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
|
||||||
_ => Route::Passthrough,
|
_ => Route::Passthrough,
|
||||||
@@ -4019,55 +4040,69 @@ impl Server {
|
|||||||
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
|
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
|
||||||
Route::Season => self.handle_season(path),
|
Route::Season => self.handle_season(path),
|
||||||
Route::ItemDefs => self.handle_item_defs(target),
|
Route::ItemDefs => self.handle_item_defs(target),
|
||||||
|
Route::ConsumableApplyProbe => {
|
||||||
|
match self.handle_consumable_apply_probe(target, body) {
|
||||||
|
Some(resp) => resp,
|
||||||
|
// Gate off: identical to today — proxy it verbatim.
|
||||||
|
None => self.passthrough(method, target, headers, body),
|
||||||
|
}
|
||||||
|
}
|
||||||
Route::MarketData => self.handle_marketdata(path, target),
|
Route::MarketData => self.handle_marketdata(path, target),
|
||||||
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
|
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
|
||||||
Route::Passthrough => {
|
Route::Passthrough => self.passthrough(method, target, headers, body),
|
||||||
// Name the request BEFORE forwarding. On staging the upstream is
|
|
||||||
// deliberately dead, so this line is the only record of what the
|
|
||||||
// client asked for -- which is exactly how an unclaimed route is
|
|
||||||
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
|
|
||||||
eprintln!(
|
|
||||||
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
// The BODY is what identifies an unknown mutation's operands, but
|
|
||||||
// it is also the one place a request can carry something we should
|
|
||||||
// not write to a log, so it is opt-in and capped. Staging probe
|
|
||||||
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
|
|
||||||
if passthrough_body_logging() && !body.is_empty() {
|
|
||||||
let cap = body.len().min(512);
|
|
||||||
eprintln!(
|
|
||||||
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
|
|
||||||
body.len(),
|
|
||||||
String::from_utf8_lossy(&body[..cap])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let resp = match self.pass.forward(method, target, headers, body) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"utas-host ERROR passthrough failed method={method} path={target}: {e}"
|
|
||||||
);
|
|
||||||
WireResponse {
|
|
||||||
status: 502,
|
|
||||||
headers: vec![(
|
|
||||||
"Content-Type".to_string(),
|
|
||||||
"application/json".to_string(),
|
|
||||||
)],
|
|
||||||
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
|
|
||||||
transport: ResponseTransport::Normal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
eprintln!(
|
|
||||||
"utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
|
|
||||||
method, path, resp.status
|
|
||||||
);
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proxy a request verbatim to the Python oracle. Extracted so the declined
|
||||||
|
/// consumable-apply probe takes EXACTLY this path — with the gate off there is
|
||||||
|
/// no behavioural difference from before the probe existed.
|
||||||
|
fn passthrough(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
target: &str,
|
||||||
|
headers: &[(String, String)],
|
||||||
|
body: &[u8],
|
||||||
|
) -> WireResponse {
|
||||||
|
let path = target.split('?').next().unwrap_or(target);
|
||||||
|
// Name the request BEFORE forwarding. On staging the upstream is
|
||||||
|
// deliberately dead, so this line is the only record of what the
|
||||||
|
// client asked for -- which is exactly how an unclaimed route is
|
||||||
|
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
// The BODY is what identifies an unknown mutation's operands, but
|
||||||
|
// it is also the one place a request can carry something we should
|
||||||
|
// not write to a log, so it is opt-in and capped. Staging probe
|
||||||
|
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
|
||||||
|
if passthrough_body_logging() && !body.is_empty() {
|
||||||
|
let cap = body.len().min(512);
|
||||||
|
eprintln!(
|
||||||
|
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
|
||||||
|
body.len(),
|
||||||
|
String::from_utf8_lossy(&body[..cap])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let resp = match self.pass.forward(method, target, headers, body) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("utas-host ERROR passthrough failed method={method} path={target}: {e}");
|
||||||
|
WireResponse {
|
||||||
|
status: 502,
|
||||||
|
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||||
|
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
|
||||||
|
transport: ResponseTransport::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
|
||||||
|
method, path, resp.status
|
||||||
|
);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
/// Monotonic seconds since server start — the clock for session/pending TTLs.
|
/// Monotonic seconds since server start — the clock for session/pending TTLs.
|
||||||
fn now(&self) -> f64 {
|
fn now(&self) -> f64 {
|
||||||
self.start.elapsed().as_secs_f64()
|
self.start.elapsed().as_secs_f64()
|
||||||
@@ -4644,6 +4679,88 @@ impl Server {
|
|||||||
json_status(200, &non_economy::item_defs_body(&ids))
|
json_status(200, &non_economy::item_defs_body(&ids))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST …/item/resource/<resourceId>` — STAGING-ONLY consumable-apply
|
||||||
|
/// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless
|
||||||
|
/// `OPENFUT_FIFA17_APPLY_PROBE=1`.
|
||||||
|
///
|
||||||
|
/// NON-AUTHORITATIVE BY CONSTRUCTION. It consumes no source card, mutates no
|
||||||
|
/// target, touches no contract/fitness/chemistry/training/injury state, mints
|
||||||
|
/// no coins and changes no ownership. It exists to observe what the client
|
||||||
|
/// does with a success, because the EFFECT of a consumable is unreversed and
|
||||||
|
/// implementing one on an inferred value is not acceptable.
|
||||||
|
///
|
||||||
|
/// RESPONSE SHAPE, from static RE rather than convenience: the apply
|
||||||
|
/// completion handler (CardsDLL `0x180035520`) tests exactly one field,
|
||||||
|
/// `[obj+0x1c]`, and raises `EVENT_CARDS_APPLY_CARD_SUCCESS` when it is zero,
|
||||||
|
/// `EVENT_CARDS_APPLY_CARD_FAILURE` otherwise. It never inspects the body —
|
||||||
|
/// unlike the move ack (`0x180128600`), which builds per-item verdict records
|
||||||
|
/// and fails on an EMPTY vector. The response object's constructor
|
||||||
|
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so empty is a legal
|
||||||
|
/// parse result here. `{"itemData":[]}` is therefore the smallest candidate
|
||||||
|
/// consistent with both the client and the oracle, whose `item/resource` route
|
||||||
|
/// is method-agnostic and answers this path with an `itemData` object.
|
||||||
|
/// It is a PROBE, not a proven contract.
|
||||||
|
fn handle_consumable_apply_probe(&self, target: &str, body: &[u8]) -> Option<WireResponse> {
|
||||||
|
if !apply_probe_enabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = target.split('?').next().unwrap_or(target);
|
||||||
|
let resource_id: i64 = path.rsplit('/').next().and_then(|s| s.parse().ok())?;
|
||||||
|
let targets = parse_apply_targets(body);
|
||||||
|
// `apply` is an ARRAY, but only len==1 has ever been observed. Batch
|
||||||
|
// semantics (atomic? partial?) are unknown, so a multi-target request is
|
||||||
|
// reported and refused rather than guessed at.
|
||||||
|
if targets.len() != 1 {
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=apply-probe status=refused resource={resource_id} \
|
||||||
|
targets={} reason=batch_semantics_unproven body={}",
|
||||||
|
targets.len(),
|
||||||
|
String::from_utf8_lossy(&body[..body.len().min(256)])
|
||||||
|
);
|
||||||
|
return Some(error_response(400, "apply_batch_unsupported"));
|
||||||
|
}
|
||||||
|
// Read-only identification of both operands, so the capture names what was
|
||||||
|
// applied to what. No write path is reachable from here.
|
||||||
|
let owned = self.core.all_owned().unwrap_or_default();
|
||||||
|
// A Core card id is "<sku>_<resourceId>", so the path's resource id names
|
||||||
|
// the DEFINITION directly; no new resolver method is needed for a probe.
|
||||||
|
let is_source = |it: &CoreOwnedItem| {
|
||||||
|
it.card_id
|
||||||
|
.rsplit_once('_')
|
||||||
|
.and_then(|(_, n)| n.parse::<i64>().ok())
|
||||||
|
== Some(resource_id)
|
||||||
|
};
|
||||||
|
let copies = owned.iter().filter(|it| is_source(it)).count();
|
||||||
|
let source_desc = match owned.iter().find(|it| is_source(it)) {
|
||||||
|
Some(it) => format!(
|
||||||
|
"owned kind={:?} subtype={} copies={}",
|
||||||
|
self.resolver.kind_of(it),
|
||||||
|
self.resolver.subtype_of(it),
|
||||||
|
copies
|
||||||
|
),
|
||||||
|
None => "NOT_OWNED".to_string(),
|
||||||
|
};
|
||||||
|
// Reverse the wire id through the identity store -- never a guess.
|
||||||
|
let target_desc = match self.resolver.owned_id_for_wire(targets[0]) {
|
||||||
|
Some(core_id) => match owned.iter().find(|it| it.owned_card_id == core_id) {
|
||||||
|
Some(it) => format!(
|
||||||
|
"owned card={} rating={} kind={:?}",
|
||||||
|
it.card_id,
|
||||||
|
it.rating,
|
||||||
|
self.resolver.kind_of(it)
|
||||||
|
),
|
||||||
|
None => format!("known_wire_id={core_id} NOT_IN_CLUB"),
|
||||||
|
},
|
||||||
|
None => "UNRESOLVED_WIRE_ID".to_string(),
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=apply-probe status=200 PROBE_ONLY resource={resource_id} \
|
||||||
|
source={source_desc} target={} target_item={target_desc} mutated=NOTHING",
|
||||||
|
targets[0]
|
||||||
|
);
|
||||||
|
Some(json_text_status(200, "{\"itemData\":[]}".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
|
/// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
|
||||||
/// the bare ARRAY (one band per queried defId); any other `/marketdata` returns
|
/// the bare ARRAY (one band per queried defId); any other `/marketdata` returns
|
||||||
/// the OBJECT band. Container type is chosen from the path (load-bearing: the
|
/// the OBJECT band. Container type is chosen from the path (load-bearing: the
|
||||||
@@ -4876,6 +4993,34 @@ fn commerce_settings_enabled() -> bool {
|
|||||||
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1"))
|
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire ids from a consumable-apply body: `{"apply":[{"id":N}, …]}`.
|
||||||
|
///
|
||||||
|
/// Captured live 2026-08-21. Only `len == 1` has ever been observed; the caller
|
||||||
|
/// refuses anything else rather than invent batch semantics.
|
||||||
|
fn parse_apply_targets(body: &[u8]) -> Vec<i64> {
|
||||||
|
serde_json::from_slice::<serde_json::Value>(body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("apply").and_then(|a| a.as_array()).cloned())
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|e| e.get("id").and_then(|i| i.as_i64()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the STAGING-ONLY consumable-apply diagnostic answers.
|
||||||
|
///
|
||||||
|
/// OFF unless `OPENFUT_FIFA17_APPLY_PROBE=1`. With it off the route falls through
|
||||||
|
/// to the Python passthrough, i.e. byte-for-byte today's behaviour, so production
|
||||||
|
/// cannot accidentally serve a diagnostic. The probe exists ONLY to observe the
|
||||||
|
/// client's success path: the consumable EFFECT is unreversed, so it consumes
|
||||||
|
/// nothing and mutates nothing.
|
||||||
|
fn apply_probe_enabled() -> bool {
|
||||||
|
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_APPLY_PROBE").as_deref() == Ok("1"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether to log unclaimed (passthrough) request BODIES.
|
/// Whether to log unclaimed (passthrough) request BODIES.
|
||||||
///
|
///
|
||||||
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
|
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
|
||||||
@@ -6254,6 +6399,50 @@ mod tests {
|
|||||||
.is_empty());
|
.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The apply re-uses the definition-lookup PATH with a different VERB, which
|
||||||
|
/// is exactly why it went unclaimed. Lock that boundary.
|
||||||
|
#[test]
|
||||||
|
fn consumable_apply_is_classified_by_verb_and_resource_id() {
|
||||||
|
// Live-captured 2026-08-21: POST ut/<sku>/item/resource/<resourceId>.
|
||||||
|
assert_eq!(
|
||||||
|
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
|
||||||
|
Route::ConsumableApplyProbe
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
|
||||||
|
Route::ConsumableApplyProbe
|
||||||
|
);
|
||||||
|
// A non-numeric tail is not a resource id, so it is not the apply.
|
||||||
|
assert_eq!(
|
||||||
|
classify("POST", "/ut/game/fifa17/item/resource/bogus"),
|
||||||
|
Route::Passthrough
|
||||||
|
);
|
||||||
|
// The definition lookup keeps the path under its own verb.
|
||||||
|
assert_eq!(
|
||||||
|
classify("GET", "/ut/game/fifa17/item/resource"),
|
||||||
|
Route::ItemDefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_targets_parse_from_the_captured_body() {
|
||||||
|
// The exact bytes the client sent, 2026-08-21.
|
||||||
|
assert_eq!(
|
||||||
|
parse_apply_targets(br#"{"apply":[{"id":100000003}]}"#),
|
||||||
|
vec![100000003]
|
||||||
|
);
|
||||||
|
// A batch is parsed but the handler refuses it: semantics unproven.
|
||||||
|
assert_eq!(
|
||||||
|
parse_apply_targets(br#"{"apply":[{"id":1},{"id":2}]}"#),
|
||||||
|
vec![1, 2]
|
||||||
|
);
|
||||||
|
// Never invent a target.
|
||||||
|
assert!(parse_apply_targets(b"").is_empty());
|
||||||
|
assert!(parse_apply_targets(br#"{"apply":[]}"#).is_empty());
|
||||||
|
assert!(parse_apply_targets(br#"{"nope":[{"id":7}]}"#).is_empty());
|
||||||
|
assert!(parse_apply_targets(br#"{"apply":[{"noid":7}]}"#).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn marketdata_container_types_are_load_bearing() {
|
fn marketdata_container_types_are_load_bearing() {
|
||||||
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
||||||
|
|||||||
Reference in New Issue
Block a user